fix(496): per-library server identity for music videos — itemId diff + soft trash #607

Merged
timothy merged 5 commits from fix/496-musicvideo-identity into main 2026-07-25 18:08:13 +02:00
22 changed files with 16352 additions and 657 deletions
@@ -0,0 +1,7 @@
namespace ErsatzTV.Core.Domain;
public class JellyfinMusicVideo : MusicVideo
{
public string ItemId { get; set; }
public string Etag { get; set; }
}
@@ -21,7 +21,7 @@ public interface IJellyfinApiClient
JellyfinLibrary library,
MediaServerProjectionFailureCounter projectionFailures = null);
IAsyncEnumerable<Tuple<MusicVideo, int>> GetMusicVideoLibraryItems(
IAsyncEnumerable<Tuple<JellyfinMusicVideo, int>> GetMusicVideoLibraryItems(
string address,
string authorizationHeader,
JellyfinLibrary library,
@@ -0,0 +1,10 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Jellyfin;
namespace ErsatzTV.Core.Interfaces.Repositories;
public interface
IJellyfinMusicVideoRepository : IMediaServerMusicVideoRepository<JellyfinLibrary, JellyfinMusicVideo,
JellyfinItemEtag>
{
}
@@ -0,0 +1,37 @@
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);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,48 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.MySql.Migrations
{
/// <inheritdoc />
public partial class Add_JellyfinMusicVideo : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "JellyfinMusicVideo",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false),
ItemId = table.Column<string>(type: "varchar(36)", unicode: false, maxLength: 36, nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
Etag = table.Column<string>(type: "varchar(36)", unicode: false, maxLength: 36, nullable: true)
.Annotation("MySql:CharSet", "utf8mb4")
},
constraints: table =>
{
table.PrimaryKey("PK_JellyfinMusicVideo", x => x.Id);
table.ForeignKey(
name: "FK_JellyfinMusicVideo_MusicVideo_Id",
column: x => x.Id,
principalTable: "MusicVideo",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_JellyfinMusicVideo_ItemId",
table: "JellyfinMusicVideo",
column: "ItemId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "JellyfinMusicVideo");
}
}
}
@@ -4452,6 +4452,25 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.ToTable("PlexMovie", (string)null);
});
modelBuilder.Entity("ErsatzTV.Core.Domain.JellyfinMusicVideo", b =>
{
b.HasBaseType("ErsatzTV.Core.Domain.MusicVideo");
b.Property<string>("Etag")
.HasMaxLength(36)
.IsUnicode(false)
.HasColumnType("varchar(36)");
b.Property<string>("ItemId")
.HasMaxLength(36)
.IsUnicode(false)
.HasColumnType("varchar(36)");
b.HasIndex("ItemId");
b.ToTable("JellyfinMusicVideo", (string)null);
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlexOtherVideo", b =>
{
b.HasBaseType("ErsatzTV.Core.Domain.OtherVideo");
@@ -6730,6 +6749,15 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
.IsRequired();
});
modelBuilder.Entity("ErsatzTV.Core.Domain.JellyfinMusicVideo", b =>
{
b.HasOne("ErsatzTV.Core.Domain.MusicVideo", null)
.WithOne()
.HasForeignKey("ErsatzTV.Core.Domain.JellyfinMusicVideo", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlexOtherVideo", b =>
{
b.HasOne("ErsatzTV.Core.Domain.OtherVideo", null)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,46 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.Sqlite.Migrations
{
/// <inheritdoc />
public partial class Add_JellyfinMusicVideo : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "JellyfinMusicVideo",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
ItemId = table.Column<string>(type: "TEXT", unicode: false, maxLength: 36, nullable: true),
Etag = table.Column<string>(type: "TEXT", unicode: false, maxLength: 36, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_JellyfinMusicVideo", x => x.Id);
table.ForeignKey(
name: "FK_JellyfinMusicVideo_MusicVideo_Id",
column: x => x.Id,
principalTable: "MusicVideo",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_JellyfinMusicVideo_ItemId",
table: "JellyfinMusicVideo",
column: "ItemId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "JellyfinMusicVideo");
}
}
}
@@ -4277,6 +4277,25 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.ToTable("PlexMovie", (string)null);
});
modelBuilder.Entity("ErsatzTV.Core.Domain.JellyfinMusicVideo", b =>
{
b.HasBaseType("ErsatzTV.Core.Domain.MusicVideo");
b.Property<string>("Etag")
.HasMaxLength(36)
.IsUnicode(false)
.HasColumnType("TEXT");
b.Property<string>("ItemId")
.HasMaxLength(36)
.IsUnicode(false)
.HasColumnType("TEXT");
b.HasIndex("ItemId");
b.ToTable("JellyfinMusicVideo", (string)null);
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlexOtherVideo", b =>
{
b.HasBaseType("ErsatzTV.Core.Domain.OtherVideo");
@@ -6555,6 +6574,15 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
.IsRequired();
});
modelBuilder.Entity("ErsatzTV.Core.Domain.JellyfinMusicVideo", b =>
{
b.HasOne("ErsatzTV.Core.Domain.MusicVideo", null)
.WithOne()
.HasForeignKey("ErsatzTV.Core.Domain.JellyfinMusicVideo", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlexOtherVideo", b =>
{
b.HasOne("ErsatzTV.Core.Domain.OtherVideo", null)
@@ -0,0 +1,23 @@
using ErsatzTV.Core.Domain;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ErsatzTV.Infrastructure.Data.Configurations;
public class JellyfinMusicVideoConfiguration : IEntityTypeConfiguration<JellyfinMusicVideo>
{
public void Configure(EntityTypeBuilder<JellyfinMusicVideo> builder)
{
builder.ToTable("JellyfinMusicVideo");
builder.Property(mv => mv.Etag)
.HasMaxLength(36)
.IsUnicode(false);
builder.Property(mv => mv.ItemId)
.HasMaxLength(36)
.IsUnicode(false);
builder.HasIndex(mv => mv.ItemId);
}
}
@@ -0,0 +1,416 @@
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());
}
}
}
@@ -92,6 +92,7 @@ public class TvContext : DbContext
public DbSet<PlexEpisode> PlexEpisodes { get; set; }
public DbSet<PlexCollection> PlexCollections { get; set; }
public DbSet<JellyfinMovie> JellyfinMovies { get; set; }
public DbSet<JellyfinMusicVideo> JellyfinMusicVideos { get; set; }
public DbSet<JellyfinShow> JellyfinShows { get; set; }
public DbSet<JellyfinSeason> JellyfinSeasons { get; set; }
public DbSet<JellyfinEpisode> JellyfinEpisodes { get; set; }
@@ -95,7 +95,7 @@ public class JellyfinApiClient : IJellyfinApiClient
(maybeLibrary, item) => WithLibrary(maybeLibrary, lib => ProjectToMovie(lib, item)),
projectionFailures);
public IAsyncEnumerable<Tuple<MusicVideo, int>> GetMusicVideoLibraryItems(
public IAsyncEnumerable<Tuple<JellyfinMusicVideo, int>> GetMusicVideoLibraryItems(
string address,
string authorizationHeader,
JellyfinLibrary library,
@@ -701,7 +701,7 @@ public class JellyfinApiClient : IJellyfinApiClient
return metadata;
}
private MediaServerProjectionResult<MusicVideo> ProjectToMusicVideo(
private MediaServerProjectionResult<JellyfinMusicVideo> ProjectToMusicVideo(
JellyfinLibrary library,
JellyfinLibraryItemResponse item)
{
@@ -709,13 +709,13 @@ public class JellyfinApiClient : IJellyfinApiClient
{
if (item.LocationType != "FileSystem")
{
return MediaServerProjectionResult<MusicVideo>.Skipped();
return MediaServerProjectionResult<JellyfinMusicVideo>.Skipped();
}
if (Path.GetExtension(item.Path)?.ToLowerInvariant() == ".strm")
{
_logger.LogInformation("STRM files are not supported; skipping {Path}", item.Path);
return MediaServerProjectionResult<MusicVideo>.Skipped();
return MediaServerProjectionResult<JellyfinMusicVideo>.Skipped();
}
string path = item.Path ?? string.Empty;
@@ -752,8 +752,10 @@ public class JellyfinApiClient : IJellyfinApiClient
MusicVideoMetadata metadata = ProjectToMusicVideoMetadata(item);
var musicVideo = new MusicVideo
var musicVideo = new JellyfinMusicVideo
{
ItemId = item.Id,
Etag = item.Etag,
MediaVersions = [version],
MusicVideoMetadata = [metadata],
TraktListItems = []
@@ -764,7 +766,7 @@ public class JellyfinApiClient : IJellyfinApiClient
catch (Exception ex)
{
_logger.LogWarning(ex, "Error projecting Jellyfin music video");
return MediaServerProjectionResult<MusicVideo>.Failed();
return MediaServerProjectionResult<JellyfinMusicVideo>.Failed();
}
}
@@ -1,6 +1,6 @@
using System.IO.Abstractions;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Jellyfin;
using ErsatzTV.Core.Interfaces.Repositories;
@@ -12,40 +12,44 @@ using Microsoft.Extensions.Logging;
namespace ErsatzTV.Scanner.Core.Jellyfin;
public class JellyfinMusicVideoLibraryScanner : IJellyfinMusicVideoLibraryScanner
public class JellyfinMusicVideoLibraryScanner :
MediaServerMusicVideoLibraryScanner<JellyfinConnectionParameters, JellyfinLibrary, JellyfinMusicVideo,
JellyfinItemEtag>,
IJellyfinMusicVideoLibraryScanner
{
private const string UnknownArtist = "Unknown Artist";
private readonly IArtistRepository _artistRepository;
private readonly IJellyfinApiClient _jellyfinApiClient;
private readonly IJellyfinPathReplacementService _pathReplacementService;
private readonly ILibraryRepository _libraryRepository;
private readonly ILogger<JellyfinMusicVideoLibraryScanner> _logger;
private readonly IJellyfinMusicVideoRepository _jellyfinMusicVideoRepository;
private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly IMetadataRepository _metadataRepository;
private readonly IMusicVideoRepository _musicVideoRepository;
private readonly IScannerProxy _scannerProxy;
private readonly IJellyfinPathReplacementService _pathReplacementService;
public JellyfinMusicVideoLibraryScanner(
IScannerProxy scannerProxy,
IJellyfinApiClient jellyfinApiClient,
IJellyfinMusicVideoRepository jellyfinMusicVideoRepository,
IJellyfinPathReplacementService pathReplacementService,
IMediaSourceRepository mediaSourceRepository,
IArtistRepository artistRepository,
IMusicVideoRepository musicVideoRepository,
ILibraryRepository libraryRepository,
IMetadataRepository metadataRepository,
IFileSystem fileSystem,
ILogger<JellyfinMusicVideoLibraryScanner> logger)
: base(
scannerProxy,
fileSystem,
artistRepository,
libraryRepository,
metadataRepository,
logger)
{
_scannerProxy = scannerProxy;
_jellyfinApiClient = jellyfinApiClient;
_jellyfinMusicVideoRepository = jellyfinMusicVideoRepository;
_pathReplacementService = pathReplacementService;
_mediaSourceRepository = mediaSourceRepository;
_artistRepository = artistRepository;
_musicVideoRepository = musicVideoRepository;
_libraryRepository = libraryRepository;
_metadataRepository = metadataRepository;
_logger = logger;
}
public async Task<Either<BaseError, Unit>> ScanLibrary(
@@ -54,211 +58,51 @@ public class JellyfinMusicVideoLibraryScanner : IJellyfinMusicVideoLibraryScanne
bool deepScan,
CancellationToken cancellationToken)
{
try
Option<LibraryPath> maybeLibraryPath = Optional(library.Paths).Flatten().HeadOrNone();
foreach (LibraryPath libraryPath in maybeLibraryPath)
{
Option<LibraryPath> maybeLibraryPath = Optional(library.Paths).Flatten().HeadOrNone();
return await maybeLibraryPath.Match(
libraryPath => ScanLibrary(connectionParameters, library, libraryPath, cancellationToken),
() => Task.FromResult<Either<BaseError, Unit>>(
BaseError.New($"Jellyfin library {library.Id} has no library path")));
}
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
{
return new ScanCanceled();
List<JellyfinPathReplacement> pathReplacements =
await _mediaSourceRepository.GetJellyfinPathReplacements(library.MediaSourceId);
string GetLocalPath(JellyfinMusicVideo musicVideo) =>
_pathReplacementService.GetReplacementJellyfinPath(
pathReplacements,
musicVideo.GetHeadVersion().MediaFiles.Head().Path,
false);
return await ScanLibrary(
_jellyfinMusicVideoRepository,
connectionParameters,
library,
libraryPath,
GetLocalPath,
deepScan,
cancellationToken);
}
return BaseError.New($"Jellyfin library {library.Id} has no library path");
}
private async Task<Either<BaseError, Unit>> ScanLibrary(
protected override string MediaServerItemId(JellyfinMusicVideo musicVideo) => musicVideo.ItemId;
protected override string MediaServerEtag(JellyfinMusicVideo musicVideo) => musicVideo.Etag;
protected override IAsyncEnumerable<Tuple<JellyfinMusicVideo, int>> GetMusicVideoLibraryItems(
JellyfinConnectionParameters connectionParameters,
JellyfinLibrary library,
LibraryPath libraryPath,
CancellationToken cancellationToken)
{
List<JellyfinPathReplacement> pathReplacements =
await _mediaSourceRepository.GetJellyfinPathReplacements(library.MediaSourceId);
var processed = 0;
var incomingPaths = new List<string>();
// #484: one counter per enumeration, created here and read only after the enumeration completes.
// It is never a field on the (singleton) api client, so concurrent scans of different libraries
// cannot leak failures into each other's sweep decision.
var projectionFailures = new MediaServerProjectionFailureCounter();
await foreach ((MusicVideo incoming, int totalCount) in _jellyfinApiClient
.GetMusicVideoLibraryItems(
connectionParameters.Address,
connectionParameters.AuthorizationHeader,
library,
projectionFailures)
.WithCancellation(cancellationToken))
{
if (cancellationToken.IsCancellationRequested)
{
return new ScanCanceled();
}
processed++;
decimal percentCompletion = totalCount == 0 ? 1 : Math.Clamp((decimal)processed / totalCount, 0, 1);
if (!await _scannerProxy.UpdateProgress(percentCompletion, cancellationToken))
{
return new ScanCanceled();
}
incomingPaths.Add(GetLocalPath(pathReplacements, incoming));
Either<BaseError, MediaItemScanResult<MusicVideo>> maybeMusicVideo =
await ProcessMusicVideo(library, libraryPath, pathReplacements, incoming, cancellationToken);
foreach (BaseError error in maybeMusicVideo.LeftToSeq())
{
_logger.LogWarning("Error processing Jellyfin music video: {Error}", error.Value);
}
foreach (MediaItemScanResult<MusicVideo> result in maybeMusicVideo.RightToSeq()
.Filter(result => result.IsAdded || result.IsUpdated))
{
if (!await _scannerProxy.ReindexMediaItems([result.Item.Id], cancellationToken))
{
_logger.LogWarning("Failed to reindex media items from scanner process");
}
}
}
await TrashMissingMusicVideos(
MediaServerProjectionFailureCounter projectionFailures) =>
_jellyfinApiClient.GetMusicVideoLibraryItems(
connectionParameters.Address,
connectionParameters.AuthorizationHeader,
library,
libraryPath,
incomingPaths,
projectionFailures,
cancellationToken);
projectionFailures);
return Unit.Default;
}
// ersatztv#494: remove music videos (and now-empty artists) that Jellyfin no longer reports.
//
// Identity is LIBRARY-SCOPED by (LibraryPathId, path): FindMusicVideoPaths and DeleteByPath both filter
// LibraryPathId AND join the concrete MusicVideo table, so this can never touch a Movie/Show that shares
// the same LibraryPath (a mixed library) — the cross-delete safety is a property of those queries, not of
// the media kind. Unlike the MediaServer{Movie,Television,OtherVideo} base scanners, music videos carry no
// server ItemId/Etag (there is no JellyfinMusicVideo entity), so we diff on the local path instead of the
// server item id, and hard-delete rather than soft-trash (there is no per-item FileNotFound seam here).
//
// Known limitation: MusicVideoRepository.GetOrAdd dedups a path GLOBALLY (no LibraryPathId predicate), so a
// file served by two libraries with overlapping local paths is a single row owned by whichever library
// scanned it first. If that owning library later stops reporting the file while another library still
// serves it, this sweep removes the shared row. A proper fix needs per-library music-video identity (a
// JellyfinMusicVideo etag entity + migration) — the issue's deferred "option 2"; tracked as a follow-up.
private async Task TrashMissingMusicVideos(
JellyfinLibrary library,
LibraryPath libraryPath,
List<string> incomingPaths,
MediaServerProjectionFailureCounter projectionFailures,
protected override async Task<Either<BaseError, MediaItemScanResult<JellyfinMusicVideo>>> UpdateMetadata(
MediaItemScanResult<JellyfinMusicVideo> result,
JellyfinMusicVideo incoming,
CancellationToken cancellationToken)
{
var existingPaths = (await _musicVideoRepository.FindMusicVideoPaths(libraryPath)).ToList();
// #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 wipe the whole library.
// #484: also refuse when the api client silently dropped items whose projection threw — those are
// items Jellyfin DID return, so treating them as deletions would hard-delete healthy rows.
if (!MediaServerReconciliationGuard.ShouldFlagMissing(
_logger,
library.Name,
incomingPaths.Count,
existingPaths.Count,
projectionFailures.Count))
{
return;
}
foreach (string path in existingPaths.Except(incomingPaths))
{
List<int> musicVideoIds = await _musicVideoRepository.DeleteByPath(libraryPath, path);
if (musicVideoIds.Count > 0 &&
!await _scannerProxy.RemoveMediaItems(musicVideoIds.ToArray(), cancellationToken))
{
_logger.LogWarning("Failed to remove media items from scanner process");
}
}
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<MusicVideo>>> ProcessMusicVideo(
JellyfinLibrary library,
LibraryPath libraryPath,
List<JellyfinPathReplacement> pathReplacements,
MusicVideo incoming,
CancellationToken cancellationToken)
{
string localPath = GetLocalPath(pathReplacements, incoming);
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(artist, libraryPath, libraryFolder, localPath))
.BindT(result => UpdateMusicVideo(result, incoming, localPath, cancellationToken));
}
private string GetLocalPath(List<JellyfinPathReplacement> pathReplacements, MusicVideo musicVideo) =>
_pathReplacementService.GetReplacementJellyfinPath(
pathReplacements,
musicVideo.GetHeadVersion().MediaFiles.Head().Path,
false);
private async Task<Either<BaseError, Artist>> GetOrAddArtist(LibraryPath libraryPath, MusicVideo 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<MusicVideo>>> UpdateMusicVideo(
MediaItemScanResult<MusicVideo> result,
MusicVideo incoming,
string localPath,
CancellationToken cancellationToken)
{
result.LocalPath = localPath;
MusicVideoMetadata incomingMetadata = incoming.MusicVideoMetadata.Head();
bool updated = await UpdateMetadata(result.Item, incomingMetadata);
updated = await _metadataRepository.UpdateStatistics(result.Item, incoming.GetHeadVersion()) || updated;
if (updated)
if (await UpdateMetadata(result.Item, incoming.MusicVideoMetadata.Head()))
{
result.IsUpdated = true;
}
@@ -289,7 +133,7 @@ public class JellyfinMusicVideoLibraryScanner : IJellyfinMusicVideoLibraryScanne
bool updated = await _metadataRepository.Update(existing);
// ersatztv#497: the scalar Update above marks only the metadata row Modified; it does NOT touch
// child collections, and MusicVideoRepository.GetOrAdd loads them AsNoTracking — so tag/genre/studio/
// child collections, and the repository's GetOrAdd loads them AsNoTracking — so tag/genre/studio/
// artist edits made in Jellyfin never reached an EXISTING music video (only the Add path persisted
// them). Reconcile the collections that BOTH the Add path persists AND GetOrAdd eager-loads:
// Genres, Tags, Studios, Artists. (Guids are add-persisted but not eager-loaded here — reconciling
@@ -0,0 +1,408 @@
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
{
// ersatztv#484: one counter per enumeration, created here and read only after the enumeration
// completes. It is never a field on the (singleton) api client, so concurrent scans of different
// libraries cannot leak failures into each other's sweep decision.
var projectionFailures = new MediaServerProjectionFailureCounter();
return await ScanLibrary(
musicVideoRepository,
connectionParameters,
library,
libraryPath,
getLocalPath,
GetMusicVideoLibraryItems(connectionParameters, library, projectionFailures),
projectionFailures,
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,
MediaServerProjectionFailureCounter projectionFailures,
bool deepScan,
CancellationToken cancellationToken)
{
var incomingItemIds = new List<string>();
var incomingLocalPaths = 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);
incomingLocalPaths.Add(localPath);
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,
incomingLocalPaths,
existingMusicVideos,
projectionFailures,
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,
List<string> incomingLocalPaths,
ImmutableDictionary<string, TEtag> existingMusicVideos,
MediaServerProjectionFailureCounter projectionFailures,
CancellationToken cancellationToken)
{
// Legacy rows (no identity yet) are reconciled by local path. They are counted into the guard's existing
// set below, because on the FIRST scan after the identity landed they are the entire library — a guard
// that only saw the (empty) identity set would let a zero-item fetch sweep every one of them.
List<string> existingLegacyPaths = await musicVideoRepository.GetExistingLegacyMusicVideoPaths(library);
// 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.
// ersatztv#484: a swallowed projection failure is indistinguishable from a deletion here, so it must
// suppress the sweep. This gates the LEGACY path diff too — that one is if anything more exposed, since
// a legacy row has no etag to fall back on.
if (!MediaServerReconciliationGuard.ShouldFlagMissing(
_logger,
library.Name,
incomingItemIds.Count,
existingMusicVideos.Count + existingLegacyPaths.Count,
projectionFailures.Count))
{
return;
}
var fileNotFoundItemIds = existingMusicVideos.Keys.Except(incomingItemIds).ToList();
List<int> ids = await musicVideoRepository.FlagFileNotFound(library, fileNotFoundItemIds);
// A legacy row whose file IS still reported was adopted during this scan's loop, so it is no longer
// legacy; what remains here is only what the server has genuinely stopped reporting.
var fileNotFoundPaths = existingLegacyPaths.Except(incomingLocalPaths).ToList();
ids = ids.Concat(await musicVideoRepository.FlagFileNotFoundByPaths(library, fileNotFoundPaths)).ToList();
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,
localPath,
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);
// ersatztv#484: projectionFailures is the per-enumeration sink the api client reports swallowed
// projection exceptions into; its Count gates the sweep below.
protected abstract IAsyncEnumerable<Tuple<TMusicVideo, int>> GetMusicVideoLibraryItems(
TConnectionParameters connectionParameters,
TLibrary library,
MediaServerProjectionFailureCounter projectionFailures);
protected abstract Task<Either<BaseError, MediaItemScanResult<TMusicVideo>>> UpdateMetadata(
MediaItemScanResult<TMusicVideo> result,
TMusicVideo incoming,
CancellationToken cancellationToken);
}
+1
View File
@@ -241,6 +241,7 @@ public class Program
services.AddScoped<IJellyfinApiClient, JellyfinApiClient>();
services.AddScoped<IJellyfinCollectionRepository, JellyfinCollectionRepository>();
services.AddScoped<IJellyfinMovieRepository, JellyfinMovieRepository>();
services.AddScoped<IJellyfinMusicVideoRepository, JellyfinMusicVideoRepository>();
services.AddScoped<IJellyfinTelevisionRepository, JellyfinTelevisionRepository>();
services.AddScoped<IJellyfinPathReplacementService, JellyfinPathReplacementService>();
File diff suppressed because it is too large Load Diff
+1
View File
@@ -1127,6 +1127,7 @@ public class Startup
services.AddScoped<IJellyfinTelevisionRepository, JellyfinTelevisionRepository>();
services.AddScoped<IJellyfinCollectionRepository, JellyfinCollectionRepository>();
services.AddScoped<IJellyfinMovieRepository, JellyfinMovieRepository>();
services.AddScoped<IJellyfinMusicVideoRepository, JellyfinMusicVideoRepository>();
services.AddScoped<IEmbyApiClient, EmbyApiClient>();
services.AddScoped<IEmbyPathReplacementService, EmbyPathReplacementService>();
services.AddScoped<IEmbyTelevisionRepository, EmbyTelevisionRepository>();
+64 -41
View File
@@ -2530,47 +2530,6 @@ remote scanner tripped it, and the feature had never run in prod, CI, or locally
still reads `libraryPath.LibraryFolders` directly, but it is only reached on the local (eager-loaded) path, so
it is not affected; left as-is (out of #488 scope).
## 2026-07-20 — `JellyfinMusicVideoLibraryScanner` reconciles by library-scoped path diff + hard delete, not server itemId soft-trash (#494)
`key: scan.musicvideo-reconciliation` · `status: active` · `since: 2026-07-20` · `supersedes: none` · `superseded-by: none`
**Rule:** `JellyfinMusicVideoLibraryScanner` reconciles removed music videos by a library-scoped local-path diff plus hard delete (`TrashMissingMusicVideos`), not the server-itemId soft-trash pattern the other media-server scanners use, because music videos carry no server identity.
**Signals:** music-video trash sweep, path-based identity, cross-kind safety, path-keyed identity, empty-fetch guard reuse, remove-stale+add-new dedup · paths: `JellyfinMusicVideoLibraryScanner.TrashMissingMusicVideos`, `FindMusicVideoPaths`/`DeleteByPath`, `IMusicVideoRepository`, `MediaServerReconciliationGuard` · issues: #494, #477, #488, #496, #500
**Mechanics:** `ScanLibrary_Should_Not_CrossDelete_Movie_Or_Show_Sharing_The_LibraryPath`; `ScanLibrary_Should_Not_Sweep_When_Jellyfin_Returns_Zero_Items`; integration tests extending the #488 harness. #500 — when mirroring the remove-stale + add-new idiom, dedup the incoming set on **the same key its add filter compares** (the filter is materialized before the loop mutates `existing`, so duplicates both pass): `Name`, `Guid` for guids, and for Plex `Actors` an artwork-preferring dedup shared with the remove filter (whose key is `(Name, artwork-presence)`). Remaining un-deduped copies of the idiom: #600.
The Jellyfin music-video scanner did add/update only — a music video removed on the Jellyfin side lingered in
ErsatzTV forever and could still be scheduled. It now runs a trash sweep at the end of `ScanLibrary`
(`TrashMissingMusicVideos`), mirroring the `MediaServer{Movie,Television,OtherVideo}LibraryScanner` "gone
upstream ⇒ remove" pattern but with a deliberately different identity function, because music videos lack the
media-server identity those base scanners rely on.
- **Identity is (LibraryPathId, path), not server itemId.** The base scanners diff `GetExisting*` (keyed by
`MediaServerItemId`) against the incoming server item ids, then soft-trash via `FlagFileNotFound`. Music videos
have **no `JellyfinMusicVideo` entity and no `ItemId`/`Etag`** — the scanner is a standalone
`IJellyfinMusicVideoLibraryScanner` that injects the *local* `IMusicVideoRepository`, which offers no
itemId-keyed existing-set or flag seam. So the sweep diffs the **local path** set instead: existing =
`FindMusicVideoPaths(libraryPath)` `.Except` the incoming items' replaced local paths, then hard-deletes the
remainder with `DeleteByPath` + `IScannerProxy.RemoveMediaItems`, and cleans now-empty artists with
`IArtistRepository.DeleteEmptyArtists`. Hard delete (not soft `FileNotFound` trash) because there is no
per-item FileNotFound seam on this path and the issue's Done-when is "removed".
- **Cross-kind safety is a property of the queries, not the media kind.** `MediaItem` is TPT with `LibraryPathId`
on the abstract base, so a Movie, Show and MusicVideo can share one `LibraryPath` (a mixed Jellyfin library).
Both `FindMusicVideoPaths` and `DeleteByPath` filter `LibraryPathId` **and** join the concrete `MusicVideo`
table, so the sweep can only ever see/delete music videos — a Movie/Show under the same `LibraryPath` is
invisible to it. Pinned by `ScanLibrary_Should_Not_CrossDelete_Movie_Or_Show_Sharing_The_LibraryPath`.
- **Reuses the #477 empty-fetch guard.** The sweep is gated by `MediaServerReconciliationGuard.ShouldFlagMissing`
— a successful fetch that returns zero items (server mid-restore / transient) is indistinguishable from a real
emptying, so the whole-library wipe is refused and logged. Pinned as a negative control by
`ScanLibrary_Should_Not_Sweep_When_Jellyfin_Returns_Zero_Items` (removing the guard flips it red).
- **Known limitation (deferred to per-library identity).** `MusicVideoRepository.GetOrAdd` dedups a path
**globally** (no `LibraryPathId` predicate), so a file served by two libraries with overlapping local paths is
a single row owned by whichever library scanned it first. If that owner later stops reporting the file while
another library still serves it, this sweep removes the shared row. A proper fix needs per-library music-video
identity (a `JellyfinMusicVideo` etag entity + migration) — the issue's "option 2 / fold into the base
scanner" refactor — tracked as #496.
- **Tests.** Integration tests (real `ArtistRepository`/`MusicVideoRepository`/`LibraryRepository` over in-memory
SQLite, extending the #488 harness) pin removal, empty-artist cleanup, cross-kind safety, and the empty-fetch
guard. Proven non-vacuous: all four fail against the pre-fix scanner except the guard control, which only
earns its keep once the sweep exists.
## 2026-07-20 (#489) — Jellyfin mixed-content libraries map to one library holding many kinds
`key: scan.jellyfin-mixed-content-library` · `status: active` · `since: 2026-07-20` · `supersedes: none` · `superseded-by: none`
**Rule:** A Jellyfin library whose collection type is `mixed` (or absent) maps to one ErsatzTV library of `LibraryMediaKind.Mixed`, scanned by running the movie/television/music-video scanners in sequence against that single library — a library is a place, not a media kind.
@@ -3895,3 +3854,67 @@ This is a **second, independent** refusal on the same guard, plus a decision not
Sweep`, the two nested TV cases, and `JellyfinMusicVideoLibraryScannerTests
.MusicVideo_Sweep_Respects_Projection_Failures` all drive the real `ScanLibrary` entry point and record
the failure from *inside* the enumeration, so same-instance wiring is what makes them pass.
## 2026-07-25 — Music videos carry a per-library server identity; reconciliation is an itemId diff + soft trash (#496)
`key: scan.musicvideo-server-identity` · `status: active` · `since: 2026-07-25` · `supersedes: scan.musicvideo-reconciliation@2026-07-20` · `superseded-by: none`
**Rule:** Jellyfin music videos carry a per-library server identity (`JellyfinMusicVideo : MusicVideo` with `ItemId`/`Etag`, TPT table + ItemId index), so `JellyfinMusicVideoLibraryScanner` folds onto a shared `MediaServerMusicVideoLibraryScanner` base that diffs the **server item id** and soft-trashes (`FlagFileNotFound`) instead of diffing local paths and hard-deleting. Rows predating the identity are **adopted in place** — the identity row is inserted against the same `MediaItem` id, scoped to the scanned library's own `LibraryPath` — never deleted and re-added.
**Signals:** music-video server identity, JellyfinMusicVideo ItemId/Etag, itemId diff, soft FileNotFound trash, adoption of pre-identity rows, cross-library false-trash, path-replaced PathHash · paths: `JellyfinMusicVideo`, `JellyfinMusicVideoRepository`, `IMediaServerMusicVideoRepository`, `MediaServerMusicVideoLibraryScanner`, `JellyfinMusicVideoLibraryScanner` · issues: #496, #494, #477, #484, #488, #497, #500, #606
**Mechanics:** dual-provider migration `Add_JellyfinMusicVideo`; `GetOrAdd` takes `localPath` explicitly and never reads the projection's path; adoption probe joins `MediaFile.PathHash` + `NOT EXISTS (JellyfinMusicVideo)` filtered to the library's `LibraryPath`; `GetByItemId` filters `LibraryPath.LibraryId`; legacy (identity-less) rows reconciled by `GetExistingLegacyMusicVideoPaths` + `FlagFileNotFoundByPaths` and counted into the #477 guard; `ScanLibrary_Should_Adopt_Using_The_PathReplaced_Local_Path`, `ScanLibrary_Should_Adopt_PreExisting_MusicVideo_Preserving_Identity_And_Collections`, `ScanLibrary_Should_Not_Adopt_A_MusicVideo_Owned_By_Another_LibraryPath`, `ScanLibrary_Should_Not_Resolve_An_ItemId_Owned_By_Another_Library`, `ScanLibrary_Should_Flag_A_Legacy_Row_The_Server_No_Longer_Reports`
#494 gave music videos a trash sweep but had to key it on `(LibraryPathId, path)` and hard-delete, because
music videos carried no server identity. That left the known limitation this issue is named for: a file served
by two libraries with overlapping local paths is one row owned by whichever library scanned it first, and that
owner's sweep **destroyed** the row the other library still served. This is the deferred "option 2".
- **Identity is the server item id, per library.** `JellyfinMusicVideo` mirrors `JellyfinMovie` exactly (TPT
table, `ItemId`/`Etag` `varchar(36)`, index on `ItemId`). `GetExistingMusicVideos` and `FlagFileNotFound` both
join `LibraryPath.LibraryId`, so the existing-set and the flag set are scoped to the scanning library — one
library's sweep can no longer resolve, let alone remove, another library's row.
- **Soft trash replaces hard delete.** The sweep now flags `FileNotFound` (`State = 1`) like the
movie/TV/other-video base scanners. The row survives, so collection membership, playout references and
artwork survive with it, and removal is `EmptyTrash`-governed and reversible. The visible consequence is that
`DeleteEmptyArtists` no longer fires from a sweep — a trashed music video still belongs to its artist.
- **Pre-identity rows are ADOPTED, not re-added.** Every music video on an existing install has no
`JellyfinMusicVideo` row and so can never be found by item id. Deleting and re-adding would mint a new
`MediaItem` id and silently drop `CollectionItem` membership; `MediaItemRepository.MediaFileAlreadyExists`
would in fact block the re-add outright and the item would error on every scan forever. So `GetOrAdd` probes
for an identity-less `MusicVideo` at the same `PathHash` **within the scanned library's own `LibraryPath`**
and inserts the identity row against that same id. The scoping is the point: the local
`MusicVideoFolderScanner` writes the same `MusicVideo` table, and a local (or second-library) row must never
be hijacked into this library's identity.
- **The adopted row is written with an empty etag** so the ordinary "etag changed ⇒ refresh" path picks it up
once, rather than needing a second adoption-specific update path.
- **The path-replaced local path is the ONLY path this seam may match on or store.** `GetOrAdd` takes
`localPath` as an explicit argument; the `item` it also receives still carries the path the media server
reported, and on any install with path replacements configured those are different strings. An earlier cut of
this change let the repository derive the path from the projection, which hashed the wrong string, missed the
row adoption was meant to find, slipped past `MediaFileAlreadyExists` (which hashes that same wrong string),
and inserted a duplicate row under a server-side path while the original collection-linked row stayed
identity-less forever. The identity path-replacement stub in the test harness hid it; the regression test now
uses a mapping that genuinely replaces.
- **Identity lookup is scoped to the scanning library, not just the item id.** Two media sources can present the
same item id (a cloned Jellyfin database); a global match would let one library silently repoint another
library's row.
- **Legacy (identity-less) rows are reconciled by local path until adopted.** A row that predates the identity
AND is already gone from the server is never adopted — adoption only ever runs for an *incoming* item — and
carries no identity, so the itemId diff cannot see it either. Without a residual path diff it would sit
`Normal` and schedulable forever, strictly worse than the hard delete it replaced. Legacy rows are also
counted into the #477 empty-fetch guard: on the first scan after this ships they ARE the whole library, so a
guard that counted only identity rows would see "0 existing" and sweep all of them on a transient empty fetch.
- **Both sweeps sit behind #484's projection-failure refusal.** `ShouldFlagMissing`'s `projectionFailureCount`
is an OPTIONAL parameter defaulting to 0, so a scanner that never passes it compiles and silently opts out of
the protection — which is what this scanner did until the counter was threaded through
(`MediaServerProjectionFailureCounter`, one per enumeration, never a field on the singleton api client). The
single guard call covers the legacy path diff too, which is if anything more exposed: a legacy row has no etag
to fall back on. #484's own music-video test asserted the pre-#496 hard delete
(`FindMusicVideoPaths`/`DeleteByPath`) and could not survive this change; it is replaced by
`ScanLibrary_Should_Not_Sweep_When_The_Projection_Reported_Failures` (identity sweep) and
`..._Should_Not_Sweep_Legacy_Rows_When_The_Projection_Reported_Failures` (legacy sweep). Their substitute
records failures **during** enumeration, not when the enumerable is handed out — an eager substitute would keep
passing if a refactor snapshotted `Count` before the enumeration completed, which is precisely the regression
the guard exists to prevent.
- **Scope honesty: this is parity, not a total fix.** One file path is still one `MediaItem` row globally
(`MediaFileAlreadyExists` is a global path-hash guard), so a second library serving the same file still gets
no row of its own — exactly as for movies/TV. What changes is that the first library's sweep now *flags*
rather than *destroys* that shared row. The unrecoverable data loss is gone; the shared-row limitation is a
whole-app property, not a music-video one, and is tracked separately as **#606**. #496's Done-when was
amended to this parity wording rather than ticked as literally written.
+1 -1
View File
@@ -113,7 +113,7 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera
| `scan.collections-scan-status` | `GET /api/v1/media-sources/collections-scan-status` reports a family-global (not per-source), boolean-only active-scan set read from `IEntityLocker`; the SPA reconciles authoritatively against it (with a grace-tick helper) instead of a fixed client-side timeout. | 2026-07-12 | [link](../decisions.md#2026-07-12--external-collections-scans-get-an-authoritative-status-surface-271-the-spa-timeout-is-retired) |
| `scan.getoraddfolder-db-lookup` | `ILibraryRepository.GetOrAddFolder` resolves the existing folder via a DB query on `(LibraryPathId, Path)`, not the caller's `LibraryPath.LibraryFolders` in-memory navigation, since that navigation is only eager-loaded on the local scan path and is null on remote (Jellyfin) callers. | 2026-07-20 | [link](../decisions.md#2026-07-20--ilibraryrepositorygetoraddfolder-resolves-the-folder-from-the-db-not-the-callers-librarypathlibraryfolders-navigation-488) |
| `scan.jellyfin-mixed-content-library` | A Jellyfin library whose collection type is `mixed` (or absent) maps to one ErsatzTV library of `LibraryMediaKind.Mixed`, scanned by running the movie/television/music-video scanners in sequence against that single library — a library is a place, not a media kind. | 2026-07-20 | [link](../decisions.md#2026-07-20-489--jellyfin-mixed-content-libraries-map-to-one-library-holding-many-kinds) |
| `scan.musicvideo-reconciliation` | `JellyfinMusicVideoLibraryScanner` reconciles removed music videos by a library-scoped local-path diff plus hard delete (`TrashMissingMusicVideos`), not the server-itemId soft-trash pattern the other media-server scanners use, because music videos carry no server identity. | 2026-07-20 | [link](../decisions.md#2026-07-20--jellyfinmusicvideolibraryscanner-reconciles-by-library-scoped-path-diff--hard-delete-not-server-itemid-soft-trash-494) |
| `scan.musicvideo-server-identity` | Jellyfin music videos carry a per-library server identity (`JellyfinMusicVideo : MusicVideo` with `ItemId`/`Etag`, TPT table + ItemId index), so `JellyfinMusicVideoLibraryScanner` folds onto a shared `MediaServerMusicVideoLibraryScanner` base that diffs the **server item id** and soft-trashes (`FlagFileNotFound`) instead of diffing local paths and hard-deleting. Rows predating the identity are **adopted in place** — the identity row is inserted against the same `MediaItem` id, scoped to the scanned library's own `LibraryPath` — never deleted and re-added. | 2026-07-25 | [link](../decisions.md#2026-07-25--music-videos-carry-a-per-library-server-identity-reconciliation-is-an-itemid-diff--soft-trash-496) |
| `scan.projection-failure-sweep-guard` | `MediaServerReconciliationGuard` takes a per-enumeration projection-failure count and refuses the file-not-found sweep (logged loudly) whenever it is non-zero against a non-empty existing set — at all six sweeps, including the nested per-show season and per-season episode ones #477 left unguarded (via `ShouldFlagMissingDescendants`, which applies the failure refusal but not #477's empty-fetch branch). Deliberate guard-clause skips (STRM, virtual, unsupported type) are explicitly **not** failures and never suppress a sweep. The missing-fraction / ratio threshold floated by #477 is **rejected**, not deferred. | 2026-07-25 | [link](../decisions.md#2026-07-25--a-media-server-sweep-also-refuses-when-the-api-client-silently-dropped-items-whose-projection-threw-the-ratio-threshold-is-rejected-484) |
| `scan.zero-item-fetch-guard` | A media-server library sweep refuses to flag missing items when a successful fetch returns zero incoming items against a non-empty existing set (`MediaServerReconciliationGuard.ShouldFlagMissing`), rather than treating an ambiguous empty result as a full-library deletion. | 2026-07-19 | [link](../decisions.md#2026-07-19--a-media-server-library-sweep-refuses-to-flag-when-a-successful-fetch-returns-zero-items-rather-than-nuking-the-whole-library-477) |
| `sched.auto-tune-foundation` | Auto-tune preview enumeration uses EF distinct+count queries for exact counts, while each created channel is persisted as a live SmartCollection; coexistence with existing channels/numbers is additive-only, never mutating. | 2026-07-16 | [link](../decisions.md#2026-07-16--auto-tuning-enumerates-via-ef-persists-via-smartcollection-additive-coexistence-69) |
+49
View File
@@ -0,0 +1,49 @@
# Archive — library scanning / media-server reconciliation
Superseded/retired records for the media-server library scanners and their reconciliation
strategies. See `docs/decisions/archive/README.md` for the archive's general rules (rationale kept
verbatim, never in the active read-path). Active successor for music-video reconciliation:
`scan.musicvideo-server-identity` in `docs/decisions.md`.
---
## 2026-07-20 — `JellyfinMusicVideoLibraryScanner` reconciles by library-scoped path diff + hard delete, not server itemId soft-trash (#494)
`key: scan.musicvideo-reconciliation` · `status: superseded` · `since: 2026-07-20` · `supersedes: none` · `superseded-by: scan.musicvideo-server-identity@2026-07-25`
**Rule:** (superseded) `JellyfinMusicVideoLibraryScanner` reconciles removed music videos by a library-scoped local-path diff plus hard delete (`TrashMissingMusicVideos`), not the server-itemId soft-trash pattern the other media-server scanners use, because music videos carry no server identity.
**Signals:** music-video trash sweep, path-based identity, cross-kind safety, path-keyed identity, empty-fetch guard reuse, remove-stale+add-new dedup · paths: `JellyfinMusicVideoLibraryScanner.TrashMissingMusicVideos`, `FindMusicVideoPaths`/`DeleteByPath`, `IMusicVideoRepository`, `MediaServerReconciliationGuard` · issues: #494, #477, #488, #496, #500
**Mechanics:** `ScanLibrary_Should_Not_CrossDelete_Movie_Or_Show_Sharing_The_LibraryPath`; `ScanLibrary_Should_Not_Sweep_When_Jellyfin_Returns_Zero_Items`; integration tests extending the #488 harness. #500 — when mirroring the remove-stale + add-new idiom, dedup the incoming set on **the same key its add filter compares** (the filter is materialized before the loop mutates `existing`, so duplicates both pass): `Name`, `Guid` for guids, and for Plex `Actors` an artwork-preferring dedup shared with the remove filter (whose key is `(Name, artwork-presence)`). Remaining un-deduped copies of the idiom: #600. Superseded by `scan.musicvideo-server-identity` (ersatztv#496): music videos gained a `JellyfinMusicVideo` ItemId/Etag identity, so the path diff + hard delete became an itemId diff + soft `FileNotFound` trash.
The Jellyfin music-video scanner did add/update only — a music video removed on the Jellyfin side lingered in
ErsatzTV forever and could still be scheduled. It now runs a trash sweep at the end of `ScanLibrary`
(`TrashMissingMusicVideos`), mirroring the `MediaServer{Movie,Television,OtherVideo}LibraryScanner` "gone
upstream ⇒ remove" pattern but with a deliberately different identity function, because music videos lack the
media-server identity those base scanners rely on.
- **Identity is (LibraryPathId, path), not server itemId.** The base scanners diff `GetExisting*` (keyed by
`MediaServerItemId`) against the incoming server item ids, then soft-trash via `FlagFileNotFound`. Music videos
have **no `JellyfinMusicVideo` entity and no `ItemId`/`Etag`** — the scanner is a standalone
`IJellyfinMusicVideoLibraryScanner` that injects the *local* `IMusicVideoRepository`, which offers no
itemId-keyed existing-set or flag seam. So the sweep diffs the **local path** set instead: existing =
`FindMusicVideoPaths(libraryPath)` `.Except` the incoming items' replaced local paths, then hard-deletes the
remainder with `DeleteByPath` + `IScannerProxy.RemoveMediaItems`, and cleans now-empty artists with
`IArtistRepository.DeleteEmptyArtists`. Hard delete (not soft `FileNotFound` trash) because there is no
per-item FileNotFound seam on this path and the issue's Done-when is "removed".
- **Cross-kind safety is a property of the queries, not the media kind.** `MediaItem` is TPT with `LibraryPathId`
on the abstract base, so a Movie, Show and MusicVideo can share one `LibraryPath` (a mixed Jellyfin library).
Both `FindMusicVideoPaths` and `DeleteByPath` filter `LibraryPathId` **and** join the concrete `MusicVideo`
table, so the sweep can only ever see/delete music videos — a Movie/Show under the same `LibraryPath` is
invisible to it. Pinned by `ScanLibrary_Should_Not_CrossDelete_Movie_Or_Show_Sharing_The_LibraryPath`.
- **Reuses the #477 empty-fetch guard.** The sweep is gated by `MediaServerReconciliationGuard.ShouldFlagMissing`
— a successful fetch that returns zero items (server mid-restore / transient) is indistinguishable from a real
emptying, so the whole-library wipe is refused and logged. Pinned as a negative control by
`ScanLibrary_Should_Not_Sweep_When_Jellyfin_Returns_Zero_Items` (removing the guard flips it red).
- **Known limitation (deferred to per-library identity).** `MusicVideoRepository.GetOrAdd` dedups a path
**globally** (no `LibraryPathId` predicate), so a file served by two libraries with overlapping local paths is
a single row owned by whichever library scanned it first. If that owner later stops reporting the file while
another library still serves it, this sweep removes the shared row. A proper fix needs per-library music-video
identity (a `JellyfinMusicVideo` etag entity + migration) — the issue's "option 2 / fold into the base
scanner" refactor — tracked as #496.
- **Tests.** Integration tests (real `ArtistRepository`/`MusicVideoRepository`/`LibraryRepository` over in-memory
SQLite, extending the #488 harness) pin removal, empty-artist cleanup, cross-kind safety, and the empty-fetch
guard. Proven non-vacuous: all four fail against the pre-fix scanner except the guard control, which only
earns its keep once the sweep exists.