Browse Source

optimize check for orphaned artwork (#2760)

pull/2762/head
Jason Dove 7 months ago committed by GitHub
parent
commit
aa08ad5765
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 2
      CHANGELOG.md
  2. 12
      ErsatzTV.Application/Maintenance/Commands/DeleteOrphanedArtworkHandler.cs
  3. 1
      ErsatzTV.Core/Domain/Metadata/Artwork.cs
  4. 8
      ErsatzTV.Core/Interfaces/Repositories/IArtworkRepository.cs
  5. 7021
      ErsatzTV.Infrastructure.MySql/Migrations/20260107222304_Add_ArtworkIsMetadataOrphan.Designer.cs
  6. 39
      ErsatzTV.Infrastructure.MySql/Migrations/20260107222304_Add_ArtworkIsMetadataOrphan.cs
  7. 7
      ErsatzTV.Infrastructure.MySql/Migrations/TvContextModelSnapshot.cs
  8. 6848
      ErsatzTV.Infrastructure.Sqlite/Migrations/20260107222223_Add_ArtworkIsMetadataOrphan.Designer.cs
  9. 39
      ErsatzTV.Infrastructure.Sqlite/Migrations/20260107222223_Add_ArtworkIsMetadataOrphan.cs
  10. 7
      ErsatzTV.Infrastructure.Sqlite/Migrations/TvContextModelSnapshot.cs
  11. 28
      ErsatzTV.Infrastructure/Data/Configurations/Metadata/ArtworkConfiguration.cs
  12. 32
      ErsatzTV.Infrastructure/Data/Repositories/ArtworkRepository.cs
  13. 1
      ErsatzTV.Infrastructure/Data/TvContext.cs
  14. 1
      ErsatzTV/ErsatzTV.csproj
  15. 2
      ErsatzTV/Program.cs

2
CHANGELOG.md

@ -4,6 +4,8 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased] ## [Unreleased]
### Changed
- Optimize database check for orphaned artwork
## [26.1.0] - 2026-01-06 ## [26.1.0] - 2026-01-06
### Added ### Added

12
ErsatzTV.Application/Maintenance/Commands/DeleteOrphanedArtworkHandler.cs

@ -3,16 +3,12 @@ using ErsatzTV.Core.Interfaces.Repositories;
namespace ErsatzTV.Application.Maintenance; namespace ErsatzTV.Application.Maintenance;
public class DeleteOrphanedArtworkHandler : IRequestHandler<DeleteOrphanedArtwork, Either<BaseError, Unit>> public class DeleteOrphanedArtworkHandler(IArtworkRepository artworkRepository)
: IRequestHandler<DeleteOrphanedArtwork, Either<BaseError, Unit>>
{ {
private readonly IArtworkRepository _artworkRepository;
public DeleteOrphanedArtworkHandler(IArtworkRepository artworkRepository) =>
_artworkRepository = artworkRepository;
public Task<Either<BaseError, Unit>> public Task<Either<BaseError, Unit>>
Handle(DeleteOrphanedArtwork request, CancellationToken cancellationToken) => Handle(DeleteOrphanedArtwork request, CancellationToken cancellationToken) =>
_artworkRepository.GetOrphanedArtwork() artworkRepository.GetOrphanedArtworkIds()
.Bind(_artworkRepository.Delete) .Bind(artworkRepository.Delete)
.Map(_ => Right<BaseError, Unit>(Unit.Default)); .Map(_ => Right<BaseError, Unit>(Unit.Default));
} }

1
ErsatzTV.Core/Domain/Metadata/Artwork.cs

@ -12,6 +12,7 @@ public class Artwork
public ArtworkKind ArtworkKind { get; set; } public ArtworkKind ArtworkKind { get; set; }
public DateTime DateAdded { get; set; } public DateTime DateAdded { get; set; }
public DateTime DateUpdated { get; set; } public DateTime DateUpdated { get; set; }
public bool? IsMetadataOrphan { get; set; }
public bool IsExternalUrl() => IsExternalUrl(Path); public bool IsExternalUrl() => IsExternalUrl(Path);

8
ErsatzTV.Core/Interfaces/Repositories/IArtworkRepository.cs

@ -1,9 +1,7 @@
using ErsatzTV.Core.Domain; namespace ErsatzTV.Core.Interfaces.Repositories;
namespace ErsatzTV.Core.Interfaces.Repositories;
public interface IArtworkRepository public interface IArtworkRepository
{ {
Task<List<Artwork>> GetOrphanedArtwork(); Task<List<int>> GetOrphanedArtworkIds();
Task<Unit> Delete(List<Artwork> artwork); Task<Unit> Delete(List<int> artworkIds);
} }

7021
ErsatzTV.Infrastructure.MySql/Migrations/20260107222304_Add_ArtworkIsMetadataOrphan.Designer.cs generated

File diff suppressed because it is too large Load Diff

39
ErsatzTV.Infrastructure.MySql/Migrations/20260107222304_Add_ArtworkIsMetadataOrphan.cs

@ -0,0 +1,39 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.MySql.Migrations
{
/// <inheritdoc />
public partial class Add_ArtworkIsMetadataOrphan : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsMetadataOrphan",
table: "Artwork",
type: "tinyint(1)",
nullable: true,
computedColumnSql: "CASE WHEN COALESCE(ArtistMetadataId, ChannelId, EpisodeMetadataId, MovieMetadataId, MusicVideoMetadataId, OtherVideoMetadataId, SeasonMetadataId, ShowMetadataId, SongMetadataId, ImageMetadataId, RemoteStreamMetadataId) IS NULL THEN 1 ELSE NULL END",
stored: false);
migrationBuilder.CreateIndex(
name: "IX_Artwork_IsMetadataOrphan",
table: "Artwork",
column: "IsMetadataOrphan");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Artwork_IsMetadataOrphan",
table: "Artwork");
migrationBuilder.DropColumn(
name: "IsMetadataOrphan",
table: "Artwork");
}
}
}

7
ErsatzTV.Infrastructure.MySql/Migrations/TvContextModelSnapshot.cs

@ -192,6 +192,11 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.Property<int?>("ImageMetadataId") b.Property<int?>("ImageMetadataId")
.HasColumnType("int"); .HasColumnType("int");
b.Property<bool?>("IsMetadataOrphan")
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("tinyint(1)")
.HasComputedColumnSql("CASE WHEN COALESCE(ArtistMetadataId, ChannelId, EpisodeMetadataId, MovieMetadataId, MusicVideoMetadataId, OtherVideoMetadataId, SeasonMetadataId, ShowMetadataId, SongMetadataId, ImageMetadataId, RemoteStreamMetadataId) IS NULL THEN 1 ELSE NULL END", false);
b.Property<int?>("MovieMetadataId") b.Property<int?>("MovieMetadataId")
.HasColumnType("int"); .HasColumnType("int");
@ -232,6 +237,8 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.HasIndex("ImageMetadataId"); b.HasIndex("ImageMetadataId");
b.HasIndex("IsMetadataOrphan");
b.HasIndex("MovieMetadataId"); b.HasIndex("MovieMetadataId");
b.HasIndex("MusicVideoMetadataId"); b.HasIndex("MusicVideoMetadataId");

6848
ErsatzTV.Infrastructure.Sqlite/Migrations/20260107222223_Add_ArtworkIsMetadataOrphan.Designer.cs generated

File diff suppressed because it is too large Load Diff

39
ErsatzTV.Infrastructure.Sqlite/Migrations/20260107222223_Add_ArtworkIsMetadataOrphan.cs

@ -0,0 +1,39 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.Sqlite.Migrations
{
/// <inheritdoc />
public partial class Add_ArtworkIsMetadataOrphan : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsMetadataOrphan",
table: "Artwork",
type: "INTEGER",
nullable: true,
computedColumnSql: "CASE WHEN COALESCE(ArtistMetadataId, ChannelId, EpisodeMetadataId, MovieMetadataId, MusicVideoMetadataId, OtherVideoMetadataId, SeasonMetadataId, ShowMetadataId, SongMetadataId, ImageMetadataId, RemoteStreamMetadataId) IS NULL THEN 1 ELSE NULL END",
stored: false);
migrationBuilder.CreateIndex(
name: "IX_Artwork_IsMetadataOrphan",
table: "Artwork",
column: "IsMetadataOrphan");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Artwork_IsMetadataOrphan",
table: "Artwork");
migrationBuilder.DropColumn(
name: "IsMetadataOrphan",
table: "Artwork");
}
}
}

7
ErsatzTV.Infrastructure.Sqlite/Migrations/TvContextModelSnapshot.cs

@ -181,6 +181,11 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.Property<int?>("ImageMetadataId") b.Property<int?>("ImageMetadataId")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<bool?>("IsMetadataOrphan")
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("INTEGER")
.HasComputedColumnSql("CASE WHEN COALESCE(ArtistMetadataId, ChannelId, EpisodeMetadataId, MovieMetadataId, MusicVideoMetadataId, OtherVideoMetadataId, SeasonMetadataId, ShowMetadataId, SongMetadataId, ImageMetadataId, RemoteStreamMetadataId) IS NULL THEN 1 ELSE NULL END", false);
b.Property<int?>("MovieMetadataId") b.Property<int?>("MovieMetadataId")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
@ -221,6 +226,8 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.HasIndex("ImageMetadataId"); b.HasIndex("ImageMetadataId");
b.HasIndex("IsMetadataOrphan");
b.HasIndex("MovieMetadataId"); b.HasIndex("MovieMetadataId");
b.HasIndex("MusicVideoMetadataId"); b.HasIndex("MusicVideoMetadataId");

28
ErsatzTV.Infrastructure/Data/Configurations/Metadata/ArtworkConfiguration.cs

@ -6,5 +6,31 @@ namespace ErsatzTV.Infrastructure.Data.Configurations;
public class ArtworkConfiguration : IEntityTypeConfiguration<Artwork> public class ArtworkConfiguration : IEntityTypeConfiguration<Artwork>
{ {
public void Configure(EntityTypeBuilder<Artwork> builder) => builder.ToTable("Artwork"); public void Configure(EntityTypeBuilder<Artwork> builder)
{
builder.ToTable("Artwork");
string[] fkColumns =
[
"ArtistMetadataId",
"ChannelId",
"EpisodeMetadataId",
"MovieMetadataId",
"MusicVideoMetadataId",
"OtherVideoMetadataId",
"SeasonMetadataId",
"ShowMetadataId",
"SongMetadataId",
"ImageMetadataId",
"RemoteStreamMetadataId"
];
var coalesceList = string.Join(", ", fkColumns);
var computedSql = $"CASE WHEN COALESCE({coalesceList}) IS NULL THEN 1 ELSE NULL END";
builder.Property(a => a.IsMetadataOrphan)
.HasComputedColumnSql(computedSql, stored: false);
builder.HasIndex(a => a.IsMetadataOrphan);
}
} }

32
ErsatzTV.Infrastructure/Data/Repositories/ArtworkRepository.cs

@ -1,34 +1,26 @@
using Dapper; using Dapper;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Infrastructure.Data.Repositories; namespace ErsatzTV.Infrastructure.Data.Repositories;
public class ArtworkRepository : IArtworkRepository public class ArtworkRepository(IDbContextFactory<TvContext> dbContextFactory) : IArtworkRepository
{ {
private readonly IDbContextFactory<TvContext> _dbContextFactory; public async Task<List<int>> GetOrphanedArtworkIds()
public ArtworkRepository(IDbContextFactory<TvContext> dbContextFactory) => _dbContextFactory = dbContextFactory;
public async Task<List<Artwork>> GetOrphanedArtwork()
{ {
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.QueryAsync<Artwork>( return await dbContext.Artwork
@"SELECT A.Id, A.Path FROM Artwork A .TagWithCallSite()
WHERE A.ArtistMetadataId IS NULL AND A.EpisodeMetadataId IS NULL .Where(a => a.IsMetadataOrphan == true)
AND A.MovieMetadataId IS NULL AND A.MusicVideoMetadataId IS NULL .Where(a => !dbContext.Actors.Any(actor => actor.ArtworkId == a.Id))
AND A.SeasonMetadataId IS NULL AND A.ShowMetadataId IS NULL .Select(a => a.Id)
AND A.SongMetadataId IS NULL AND A.ChannelId IS NULL .ToListAsync();
AND A.OtherVideoMetadataId IS NULL AND A.RemoteStreamMetadataId IS NULL
AND NOT EXISTS (SELECT * FROM Actor WHERE Actor.ArtworkId = A.Id)")
.Map(result => result.ToList());
} }
public async Task<Unit> Delete(List<Artwork> artwork) public async Task<Unit> Delete(List<int> artworkIds)
{ {
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
IEnumerable<List<int>> chunks = Chunk(artwork.Map(a => a.Id), 100); IEnumerable<List<int>> chunks = Chunk(artworkIds, 100);
foreach (List<int> chunk in chunks) foreach (List<int> chunk in chunks)
{ {
await dbContext.Connection.ExecuteAsync( await dbContext.Connection.ExecuteAsync(

1
ErsatzTV.Infrastructure/Data/TvContext.cs

@ -55,6 +55,7 @@ public class TvContext : DbContext
public DbSet<MovieMetadata> MovieMetadata { get; set; } public DbSet<MovieMetadata> MovieMetadata { get; set; }
public DbSet<Artwork> Artwork { get; set; } public DbSet<Artwork> Artwork { get; set; }
public DbSet<Artist> Artists { get; set; } public DbSet<Artist> Artists { get; set; }
public DbSet<Actor> Actors { get; set; }
public DbSet<ArtistMetadata> ArtistMetadata { get; set; } public DbSet<ArtistMetadata> ArtistMetadata { get; set; }
public DbSet<MusicVideo> MusicVideos { get; set; } public DbSet<MusicVideo> MusicVideos { get; set; }
public DbSet<MusicVideoMetadata> MusicVideoMetadata { get; set; } public DbSet<MusicVideoMetadata> MusicVideoMetadata { get; set; }

1
ErsatzTV/ErsatzTV.csproj

@ -28,6 +28,7 @@
</Target> </Target>
<ItemGroup> <ItemGroup>
<!-- <PackageReference Include="EntityFrameworkProfiler.Appender" Version="6.0.6049" /> -->
<PackageReference Include="Blazored.FluentValidation" Version="2.2.0" /> <PackageReference Include="Blazored.FluentValidation" Version="2.2.0" />
<PackageReference Include="BlazorSortable" Version="5.2.1" /> <PackageReference Include="BlazorSortable" Version="5.2.1" />
<PackageReference Include="Bugsnag.AspNet.Core" Version="4.1.0" /> <PackageReference Include="Bugsnag.AspNet.Core" Version="4.1.0" />

2
ErsatzTV/Program.cs

@ -130,6 +130,8 @@ public class Program
IHost host = CreateHostBuilder(args).Build(); IHost host = CreateHostBuilder(args).Build();
//HibernatingRhinos.Profiler.Appender.EntityFramework.EntityFrameworkProfiler.Initialize();
// run environment validation and exit on failure // run environment validation and exit on failure
var validator = host.Services.GetRequiredService<IEnvironmentValidator>(); var validator = host.Services.GetRequiredService<IEnvironmentValidator>();
if (!await validator.Validate()) if (!await validator.Validate())

Loading…
Cancel
Save