From b0c561325631da0c74d8870c57861bd7bf5c311b Mon Sep 17 00:00:00 2001 From: Jason Dove <1695733+jasongdove@users.noreply.github.com> Date: Fri, 16 Jan 2026 12:18:47 -0600 Subject: [PATCH] cleanup artwork cache folder --- CHANGELOG.md | 7 + .../Commands/DeleteOrphanedArtworkHandler.cs | 150 +++++++++++++++++- .../Controllers/Api/MaintenanceController.cs | 13 +- ErsatzTV/Services/SchedulerService.cs | 4 - 4 files changed, 162 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e9dd75c5..25754d485 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - `Software` - force software padding - This can be used to work around buggy GPU driver behavior where padding is green instead of black - This is most often seen with VAAPI acceleration (radeonsi or i965 drivers) +- Add API endpoint to clean artwork cache folder (on demand) + - POST `/api/maintenance/clean_artwork` + +### Changed +- Disable automatic artwork database cleanup + - This will be re-enabled at some point in the future (after more testing) + - For now, the API should be used to clean as needed ### Fixed - Use code signing on all Windows executables (`ErsatzTV-Windows.exe`, `ErsatzTV.exe`, `ErsatzTV.Scanner.exe`) diff --git a/ErsatzTV.Application/Maintenance/Commands/DeleteOrphanedArtworkHandler.cs b/ErsatzTV.Application/Maintenance/Commands/DeleteOrphanedArtworkHandler.cs index 64b58f565..32438f0ce 100644 --- a/ErsatzTV.Application/Maintenance/Commands/DeleteOrphanedArtworkHandler.cs +++ b/ErsatzTV.Application/Maintenance/Commands/DeleteOrphanedArtworkHandler.cs @@ -1,14 +1,150 @@ -using ErsatzTV.Core; +using System.IO.Abstractions; +using ErsatzTV.Core; using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; namespace ErsatzTV.Application.Maintenance; -public class DeleteOrphanedArtworkHandler(IArtworkRepository artworkRepository) +public class DeleteOrphanedArtworkHandler( + IDbContextFactory dbContextFactory, + IArtworkRepository artworkRepository, + IFileSystem fileSystem, + ILogger logger) : IRequestHandler> { - public Task> - Handle(DeleteOrphanedArtwork request, CancellationToken cancellationToken) => - artworkRepository.GetOrphanedArtworkIds() - .Bind(artworkRepository.Delete) - .Map(_ => Right(Unit.Default)); + public async Task> Handle( + DeleteOrphanedArtwork request, + CancellationToken cancellationToken) + { + try + { + await CleanUpDatabase(); + await CleanUpFileSystem(cancellationToken); + + return Unit.Default; + } + catch (Exception e) + { + return BaseError.New(e.Message); + } + } + + private async Task CleanUpDatabase() + { + List ids = await artworkRepository.GetOrphanedArtworkIds(); + if (ids.Count > 0) + { + await artworkRepository.Delete(ids); + } + } + + private async Task CleanUpFileSystem(CancellationToken cancellationToken) + { + await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); + + System.Collections.Generic.HashSet validFiles = []; + + var lastId = 0; + var hasMoreRows = true; + while (hasMoreRows) + { + List result = await dbContext.Artwork + .TagWithCallSite() + .AsNoTracking() + .Where(a => a.Id > lastId) + .OrderBy(a => a.Id) + .Take(1000) + .Select(a => new MinimalArtwork(a.Id, a.Path, a.BlurHash43, a.BlurHash54, a.BlurHash64)) + .ToListAsync(cancellationToken); + + int newLastId = lastId; + foreach (MinimalArtwork artwork in result) + { + newLastId = artwork.Id; + + if (!artwork.Path.Contains('/')) + { + validFiles.Add(artwork.Path); + } + + if (!string.IsNullOrWhiteSpace(artwork.BlurHash43)) + { + validFiles.Add(artwork.BlurHash43); + } + + if (!string.IsNullOrWhiteSpace(artwork.BlurHash54)) + { + validFiles.Add(artwork.BlurHash54); + } + + if (!string.IsNullOrWhiteSpace(artwork.BlurHash64)) + { + validFiles.Add(artwork.BlurHash64); + } + } + + if (lastId == newLastId) + { + hasMoreRows = false; + } + + lastId = newLastId; + } + + logger.LogDebug("Loaded {Count} artwork hashes (valid file names)", validFiles.Count); + + var deleted = 0; + foreach (string file in fileSystem.Directory.EnumerateFiles( + FileSystemLayout.ArtworkCacheFolder, + "*.*", + SearchOption.AllDirectories)) + { + string fileName = fileSystem.Path.GetFileName(file); + if (!validFiles.Contains(fileName)) + { + try + { + fileSystem.File.Delete(file); + deleted++; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Could not delete artwork file {File}", file); + } + } + } + + logger.LogDebug("Deleted {Count} unused artwork cache files", deleted); + + DeleteEmptySubfolders(FileSystemLayout.ArtworkCacheFolder); + } + + private void DeleteEmptySubfolders(string path) + { + if (!fileSystem.Directory.Exists(path)) + { + return; + } + + foreach (string sub in fileSystem.Directory.GetDirectories(path)) + { + DeleteEmptySubfolders(sub); + } + + if (!fileSystem.Directory.EnumerateFileSystemEntries(path).Any()) + { + try + { + fileSystem.Directory.Delete(path); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Could not delete empty cache folder {Folder}", path); + } + } + } + + private sealed record MinimalArtwork(int Id, string Path, string BlurHash43, string BlurHash54, string BlurHash64); } diff --git a/ErsatzTV/Controllers/Api/MaintenanceController.cs b/ErsatzTV/Controllers/Api/MaintenanceController.cs index 048e65728..b6009e32a 100644 --- a/ErsatzTV/Controllers/Api/MaintenanceController.cs +++ b/ErsatzTV/Controllers/Api/MaintenanceController.cs @@ -1,3 +1,5 @@ +using System.Threading.Channels; +using ErsatzTV.Application; using ErsatzTV.Application.Maintenance; using ErsatzTV.Core; using MediatR; @@ -7,7 +9,7 @@ namespace ErsatzTV.Controllers.Api; [ApiController] [EndpointGroupName("general")] -public class MaintenanceController(IMediator mediator) +public class MaintenanceController(IMediator mediator, ChannelWriter workerChannel) { [HttpGet("/api/maintenance/gc")] [Tags("Maintenance")] @@ -36,4 +38,13 @@ public class MaintenanceController(IMediator mediator) return new OkResult(); } + + [HttpPost("/api/maintenance/clean_artwork")] + [Tags("Maintenance")] + [EndpointSummary("Clean artwork cache")] + public async Task CleanArtwork(CancellationToken cancellationToken) + { + await workerChannel.WriteAsync(new DeleteOrphanedArtwork(), cancellationToken); + return new OkResult(); + } } diff --git a/ErsatzTV/Services/SchedulerService.cs b/ErsatzTV/Services/SchedulerService.cs index b278117fe..a6765c882 100644 --- a/ErsatzTV/Services/SchedulerService.cs +++ b/ErsatzTV/Services/SchedulerService.cs @@ -119,7 +119,6 @@ public class SchedulerService : BackgroundService { try { - await DeleteOrphanedArtwork(cancellationToken); await DeleteOrphanedSubtitles(cancellationToken); await RefreshMpegTsScripts(cancellationToken); await RefreshChannelGuideChannelList(cancellationToken); @@ -390,9 +389,6 @@ public class SchedulerService : BackgroundService private ValueTask RefreshGraphicsElements(CancellationToken cancellationToken) => _workerChannel.WriteAsync(new RefreshGraphicsElements(), cancellationToken); - private ValueTask DeleteOrphanedArtwork(CancellationToken cancellationToken) => - _workerChannel.WriteAsync(new DeleteOrphanedArtwork(), cancellationToken); - private ValueTask DeleteOrphanedSubtitles(CancellationToken cancellationToken) => _workerChannel.WriteAsync(new DeleteOrphanedSubtitles(), cancellationToken);