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. 98
      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.
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]
### 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 ## [26.5.0] - 2026-05-08
### Added ### Added

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

@ -2,4 +2,4 @@
namespace ErsatzTV.Application.Maintenance; 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(
{ {
try try
{ {
await CleanUpDatabase(); await CleanUpDatabase(request, cancellationToken);
await CleanUpFileSystem(cancellationToken);
// temporarily disabled since this is now scheduled
//await CleanUpFileSystem(cancellationToken);
return Unit.Default; return Unit.Default;
} }
@ -34,13 +36,31 @@ public class DeleteOrphanedArtworkHandler(
} }
} }
private async Task CleanUpDatabase() private async Task CleanUpDatabase(DeleteOrphanedArtwork request, CancellationToken cancellationToken)
{ {
List<int> ids = await artworkRepository.GetOrphanedArtworkIds(); // delete actors that no longer reference any metadata; otherwise the artwork
if (ids.Count > 0) // they point to is shielded from the orphaned artwork cleanup below
int deletedActors = await artworkRepository.DeleteOrphanedActors(request.MaxToDelete, cancellationToken);
if (deletedActors > 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) private async Task CleanUpFileSystem(CancellationToken cancellationToken)

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

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

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

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

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

@ -1,34 +1,98 @@
using Dapper; using Dapper;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Infrastructure.Data.Repositories; 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(); await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
return await dbContext.Artwork
.TagWithCallSite() const string ORPHANED_ACTOR_QUERY =
.Where(a => a.IsMetadataOrphan == true) """
.Where(a => !dbContext.Actors.Any(actor => actor.ArtworkId == a.Id)) SELECT Id FROM Actor
.Select(a => a.Id) WHERE ArtistMetadataId IS NULL
.ToListAsync(); 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<Unit> Delete(List<int> artworkIds) public async Task<int> DeleteOrphanedArtwork(int? max, CancellationToken cancellationToken)
{ {
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(); await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
IEnumerable<List<int>> chunks = Chunk(artworkIds, 100);
foreach (List<int> chunk in chunks) var totalDeleted = 0;
while (true)
{ {
await dbContext.Connection.ExecuteAsync( IQueryable<int> query = dbContext.Artwork
"DELETE FROM Artwork WHERE Id IN @Ids", .TagWithCallSite()
new { Ids = chunk }); .Where(a => a.IsMetadataOrphan == true)
.Where(a => !dbContext.Actors.Any(actor => actor.ArtworkId == a.Id))
.OrderBy(a => a.Id)
.Take(5000)
.Select(a => a.Id);
List<int> ids = await query.ToListAsync(cancellationToken);
if (ids.Count == 0 || totalDeleted >= max)
{
break;
}
if (totalDeleted > 0)
{
logger.LogDebug("Deleted {Count} orphaned artwork; still have more to delete...", totalDeleted);
}
foreach (List<int> chunk in Chunk(ids, 100))
{
await dbContext.Connection.ExecuteAsync(
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) 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
.ToList()) .ToList())
{ {
metadata.Actors.Remove(actor); metadata.Actors.Remove(actor);
dbContext.Actors.Remove(actor);
} }
foreach (Actor actor in incomingMetadata.Actors foreach (Actor actor in incomingMetadata.Actors

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

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

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

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

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

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

4
ErsatzTV/Controllers/Api/MaintenanceController.cs

@ -42,9 +42,9 @@ public class MaintenanceController(IMediator mediator, ChannelWriter<IBackground
[HttpPost("/api/maintenance/clean_artwork")] [HttpPost("/api/maintenance/clean_artwork")]
[Tags("Maintenance")] [Tags("Maintenance")]
[EndpointSummary("Clean artwork cache")] [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(); return new OkResult();
} }
} }

4
ErsatzTV/Services/SchedulerService.cs

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

Loading…
Cancel
Save