Browse Source

fix: clean up orphan actor and artwork records (#2885)

* fix: clean up orphan actor and artwork records

* fix next playout sync with channel logo watermarks
pull/2886/head
Jason Dove 3 weeks ago committed by GitHub
parent
commit
a1036430ee
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 7
      CHANGELOG.md
  2. 2
      ErsatzTV.Application/Maintenance/Commands/DeleteOrphanedArtwork.cs
  3. 32
      ErsatzTV.Application/Maintenance/Commands/DeleteOrphanedArtworkHandler.cs
  4. 1
      ErsatzTV.Application/Playouts/Commands/SyncNextPlayoutHandler.cs
  5. 4
      ErsatzTV.Core/Interfaces/Repositories/IArtworkRepository.cs
  6. 90
      ErsatzTV.Infrastructure/Data/Repositories/ArtworkRepository.cs
  7. 1
      ErsatzTV.Infrastructure/Data/Repositories/EmbyMovieRepository.cs
  8. 1
      ErsatzTV.Infrastructure/Data/Repositories/EmbyTelevisionRepository.cs
  9. 1
      ErsatzTV.Infrastructure/Data/Repositories/JellyfinMovieRepository.cs
  10. 1
      ErsatzTV.Infrastructure/Data/Repositories/JellyfinTelevisionRepository.cs
  11. 4
      ErsatzTV/Controllers/Api/MaintenanceController.cs
  12. 4
      ErsatzTV/Services/SchedulerService.cs

7
CHANGELOG.md

@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file. @@ -4,6 +4,13 @@ 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/).
## [Unreleased]
### Fixed
- Fix cause of unnecessarily large database when using Emby or Jellyfin libraries
- This was caused by orphaned actor and actor artwork records, which will be cleaned up hourly
- When you see logs like `No orphaned actors to delete` and `No orphaned artwork to delete`, you can then reclaim disk space by:
- Stopping ErsatzTV Legacy
- Running `sqlite3 ersatztv.sqlite3 'PRAGMA wal_checkpoint(TRUNCATE); VACUUM;'` in your config folder
- Fix Next Engine playout sync when channel is configured to use channel logo as watermark
## [26.5.0] - 2026-05-08
### Added

2
ErsatzTV.Application/Maintenance/Commands/DeleteOrphanedArtwork.cs

@ -2,4 +2,4 @@ @@ -2,4 +2,4 @@
namespace ErsatzTV.Application.Maintenance;
public record DeleteOrphanedArtwork : IRequest<Either<BaseError, Unit>>, IBackgroundServiceRequest;
public record DeleteOrphanedArtwork(int? MaxToDelete) : IRequest<Either<BaseError, Unit>>, IBackgroundServiceRequest;

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

@ -23,8 +23,10 @@ public class DeleteOrphanedArtworkHandler( @@ -23,8 +23,10 @@ public class DeleteOrphanedArtworkHandler(
{
try
{
await CleanUpDatabase();
await CleanUpFileSystem(cancellationToken);
await CleanUpDatabase(request, cancellationToken);
// temporarily disabled since this is now scheduled
//await CleanUpFileSystem(cancellationToken);
return Unit.Default;
}
@ -34,13 +36,31 @@ public class DeleteOrphanedArtworkHandler( @@ -34,13 +36,31 @@ public class DeleteOrphanedArtworkHandler(
}
}
private async Task CleanUpDatabase()
private async Task CleanUpDatabase(DeleteOrphanedArtwork request, CancellationToken cancellationToken)
{
// delete actors that no longer reference any metadata; otherwise the artwork
// they point to is shielded from the orphaned artwork cleanup below
int deletedActors = await artworkRepository.DeleteOrphanedActors(request.MaxToDelete, cancellationToken);
if (deletedActors > 0)
{
List<int> ids = await artworkRepository.GetOrphanedArtworkIds();
if (ids.Count > 0)
logger.LogInformation("Deleted {Count} orphaned actors", deletedActors);
}
else
{
await artworkRepository.Delete(ids);
logger.LogInformation("No orphaned actors to delete");
}
int deletedArtwork = await artworkRepository.DeleteOrphanedArtwork(request.MaxToDelete, cancellationToken);
if (deletedArtwork > 0)
{
logger.LogInformation("Deleted {Count} orphaned artwork", deletedArtwork);
}
else
{
logger.LogInformation("No orphaned artwork to delete");
}
logger.LogInformation("Done cleaning!");
}
private async Task CleanUpFileSystem(CancellationToken cancellationToken)

1
ErsatzTV.Application/Playouts/Commands/SyncNextPlayoutHandler.cs

@ -270,6 +270,7 @@ public partial class SyncNextPlayoutHandler( @@ -270,6 +270,7 @@ public partial class SyncNextPlayoutHandler(
maybeChannel = await dbContext.Channels
.AsNoTracking()
.Include(c => c.Watermark)
.Include(c => c.Artwork)
.SingleOrDefaultAsync(c => c.Number == channelNumber, cancellationToken);
foreach (Channel channel in maybeChannel)
{

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

@ -2,6 +2,6 @@ @@ -2,6 +2,6 @@
public interface IArtworkRepository
{
Task<List<int>> GetOrphanedArtworkIds();
Task<Unit> Delete(List<int> artworkIds);
Task<int> DeleteOrphanedActors(int? max, CancellationToken cancellationToken);
Task<int> DeleteOrphanedArtwork(int? max, CancellationToken cancellationToken);
}

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

@ -1,34 +1,98 @@ @@ -1,34 +1,98 @@
using Dapper;
using ErsatzTV.Core.Interfaces.Repositories;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Infrastructure.Data.Repositories;
public class ArtworkRepository(IDbContextFactory<TvContext> dbContextFactory) : IArtworkRepository
public class ArtworkRepository(IDbContextFactory<TvContext> dbContextFactory, ILogger<ArtworkRepository> logger) : IArtworkRepository
{
public async Task<List<int>> GetOrphanedArtworkIds()
public async Task<int> DeleteOrphanedActors(int? max, CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
return await dbContext.Artwork
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
const string ORPHANED_ACTOR_QUERY =
"""
SELECT Id FROM Actor
WHERE ArtistMetadataId IS NULL
AND EpisodeMetadataId IS NULL
AND ImageMetadataId IS NULL
AND MovieMetadataId IS NULL
AND MusicVideoMetadataId IS NULL
AND OtherVideoMetadataId IS NULL
AND RemoteStreamMetadataId IS NULL
AND SeasonMetadataId IS NULL
AND ShowMetadataId IS NULL
AND SongMetadataId IS NULL
LIMIT 5000
""";
var totalDeleted = 0;
while (true)
{
List<int> ids = (await dbContext.Connection.QueryAsync<int>(ORPHANED_ACTOR_QUERY)).ToList();
if (ids.Count == 0 || totalDeleted >= max)
{
break;
}
if (totalDeleted > 0)
{
logger.LogDebug("Deleted {Count} orphaned actors; still have more to delete...", totalDeleted);
}
foreach (List<int> chunk in Chunk(ids, 100))
{
await dbContext.Connection.ExecuteAsync(
new CommandDefinition("DELETE FROM Actor WHERE Id IN @Ids",
parameters: new { Ids = chunk },
cancellationToken: cancellationToken));
}
totalDeleted += ids.Count;
}
return totalDeleted;
}
public async Task<int> DeleteOrphanedArtwork(int? max, CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
var totalDeleted = 0;
while (true)
{
IQueryable<int> query = dbContext.Artwork
.TagWithCallSite()
.Where(a => a.IsMetadataOrphan == true)
.Where(a => !dbContext.Actors.Any(actor => actor.ArtworkId == a.Id))
.Select(a => a.Id)
.ToListAsync();
.OrderBy(a => a.Id)
.Take(5000)
.Select(a => a.Id);
List<int> ids = await query.ToListAsync(cancellationToken);
if (ids.Count == 0 || totalDeleted >= max)
{
break;
}
public async Task<Unit> Delete(List<int> artworkIds)
if (totalDeleted > 0)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
IEnumerable<List<int>> chunks = Chunk(artworkIds, 100);
foreach (List<int> chunk in chunks)
logger.LogDebug("Deleted {Count} orphaned artwork; still have more to delete...", totalDeleted);
}
foreach (List<int> chunk in Chunk(ids, 100))
{
await dbContext.Connection.ExecuteAsync(
"DELETE FROM Artwork WHERE Id IN @Ids",
new { Ids = chunk });
new CommandDefinition("DELETE FROM Artwork WHERE Id IN @Ids",
parameters: new { Ids = chunk },
cancellationToken: cancellationToken));
}
totalDeleted += ids.Count;
}
return Unit.Default;
return totalDeleted;
}
private static IEnumerable<List<T>> Chunk<T>(IEnumerable<T> collection, int size)

1
ErsatzTV.Infrastructure/Data/Repositories/EmbyMovieRepository.cs

@ -316,6 +316,7 @@ public class EmbyMovieRepository : IEmbyMovieRepository @@ -316,6 +316,7 @@ public class EmbyMovieRepository : IEmbyMovieRepository
.ToList())
{
metadata.Actors.Remove(actor);
dbContext.Actors.Remove(actor);
}
foreach (Actor actor in incomingMetadata.Actors

1
ErsatzTV.Infrastructure/Data/Repositories/EmbyTelevisionRepository.cs

@ -590,6 +590,7 @@ public class EmbyTelevisionRepository( @@ -590,6 +590,7 @@ public class EmbyTelevisionRepository(
.ToList())
{
metadata.Actors.Remove(actor);
dbContext.Actors.Remove(actor);
}
foreach (Actor actor in incomingMetadata.Actors

1
ErsatzTV.Infrastructure/Data/Repositories/JellyfinMovieRepository.cs

@ -278,6 +278,7 @@ public class JellyfinMovieRepository : IJellyfinMovieRepository @@ -278,6 +278,7 @@ public class JellyfinMovieRepository : IJellyfinMovieRepository
.ToList())
{
metadata.Actors.Remove(actor);
dbContext.Actors.Remove(actor);
}
foreach (Actor actor in incomingMetadata.Actors

1
ErsatzTV.Infrastructure/Data/Repositories/JellyfinTelevisionRepository.cs

@ -626,6 +626,7 @@ public class JellyfinTelevisionRepository : IJellyfinTelevisionRepository @@ -626,6 +626,7 @@ public class JellyfinTelevisionRepository : IJellyfinTelevisionRepository
.ToList())
{
metadata.Actors.Remove(actor);
dbContext.Actors.Remove(actor);
}
foreach (Actor actor in incomingMetadata.Actors

4
ErsatzTV/Controllers/Api/MaintenanceController.cs

@ -42,9 +42,9 @@ public class MaintenanceController(IMediator mediator, ChannelWriter<IBackground @@ -42,9 +42,9 @@ public class MaintenanceController(IMediator mediator, ChannelWriter<IBackground
[HttpPost("/api/maintenance/clean_artwork")]
[Tags("Maintenance")]
[EndpointSummary("Clean artwork cache")]
public async Task<IActionResult> CleanArtwork(CancellationToken cancellationToken)
public async Task<IActionResult> CleanArtwork(CancellationToken cancellationToken, [FromQuery] int limit = 100_000)
{
await workerChannel.WriteAsync(new DeleteOrphanedArtwork(), cancellationToken);
await workerChannel.WriteAsync(new DeleteOrphanedArtwork(limit), cancellationToken);
return new OkResult();
}
}

4
ErsatzTV/Services/SchedulerService.cs

@ -119,6 +119,7 @@ public class SchedulerService : BackgroundService @@ -119,6 +119,7 @@ public class SchedulerService : BackgroundService
try
{
await DeleteOrphanedSubtitles(cancellationToken);
await DeleteOrphanedArtwork(cancellationToken);
await RefreshMpegTsScripts(cancellationToken);
await RefreshChannelGuideChannelList(cancellationToken);
await BuildPlayouts(cancellationToken);
@ -365,6 +366,9 @@ public class SchedulerService : BackgroundService @@ -365,6 +366,9 @@ public class SchedulerService : BackgroundService
private ValueTask DeleteOrphanedSubtitles(CancellationToken cancellationToken) =>
_workerChannel.WriteAsync(new DeleteOrphanedSubtitles(), cancellationToken);
private ValueTask DeleteOrphanedArtwork(CancellationToken cancellationToken) =>
_workerChannel.WriteAsync(new DeleteOrphanedArtwork(100_000), cancellationToken);
private ValueTask ReleaseMemory(CancellationToken cancellationToken) =>
_workerChannel.WriteAsync(new ReleaseMemory(false), cancellationToken);

Loading…
Cancel
Save