fix(496): per-library server identity for music videos — itemId diff + soft trash #607
@@ -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);
|
||||
}
|
||||
Generated
+7337
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)
|
||||
|
||||
Generated
+7162
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);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user