Browse Source

cleanup artwork cache folder (#2779)

* cleanup artwork cache folder

* fixes

* ignore watermarks that no longer exist on the file system
pull/2780/head
Jason Dove 7 months ago committed by GitHub
parent
commit
35d24ffea6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 7
      CHANGELOG.md
  2. 147
      ErsatzTV.Application/Maintenance/Commands/DeleteOrphanedArtworkHandler.cs
  3. 13
      ErsatzTV.Core.Tests/FFmpeg/WatermarkSelectorTests.cs
  4. 95
      ErsatzTV.Core/FFmpeg/WatermarkSelector.cs
  5. 9
      ErsatzTV.Infrastructure/Images/ImageCache.cs
  6. 5
      ErsatzTV.Scanner.Tests/Core/FFmpeg/TranscodingTests.cs
  7. 13
      ErsatzTV/Controllers/Api/MaintenanceController.cs
  8. 4
      ErsatzTV/Services/SchedulerService.cs

7
CHANGELOG.md

@ -15,6 +15,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). @@ -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`)

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

@ -1,14 +1,147 @@ @@ -1,14 +1,147 @@
using ErsatzTV.Core;
using System.IO.Abstractions;
using ErsatzTV.Core;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Images;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Application.Maintenance;
public class DeleteOrphanedArtworkHandler(IArtworkRepository artworkRepository)
public class DeleteOrphanedArtworkHandler(
IDbContextFactory<TvContext> dbContextFactory,
IArtworkRepository artworkRepository,
IFileSystem fileSystem,
ILogger<DeleteOrphanedArtworkHandler> logger)
: IRequestHandler<DeleteOrphanedArtwork, Either<BaseError, Unit>>
{
public Task<Either<BaseError, Unit>>
Handle(DeleteOrphanedArtwork request, CancellationToken cancellationToken) =>
artworkRepository.GetOrphanedArtworkIds()
.Bind(artworkRepository.Delete)
.Map(_ => Right<BaseError, Unit>(Unit.Default));
public async Task<Either<BaseError, Unit>> 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<int> 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<string> validFiles = [];
var lastId = 0;
while (true)
{
List<MinimalArtwork> 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);
if (result.Count == 0)
{
break;
}
foreach (MinimalArtwork artwork in result)
{
if (!string.IsNullOrWhiteSpace(artwork.Path) && !artwork.Path.Contains('/'))
{
validFiles.Add(artwork.Path);
}
if (!string.IsNullOrWhiteSpace(artwork.BlurHash43))
{
validFiles.Add(ImageCache.GetBlurHashFileName(artwork.BlurHash43));
}
if (!string.IsNullOrWhiteSpace(artwork.BlurHash54))
{
validFiles.Add(ImageCache.GetBlurHashFileName(artwork.BlurHash54));
}
if (!string.IsNullOrWhiteSpace(artwork.BlurHash64))
{
validFiles.Add(ImageCache.GetBlurHashFileName(artwork.BlurHash64));
}
}
lastId = result.Last().Id;
}
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);
}

13
ErsatzTV.Core.Tests/FFmpeg/WatermarkSelectorTests.cs

@ -8,6 +8,7 @@ using NSubstitute; @@ -8,6 +8,7 @@ using NSubstitute;
using NUnit.Framework;
using Serilog;
using Shouldly;
using Testably.Abstractions.Testing;
namespace ErsatzTV.Core.Tests.FFmpeg;
@ -65,8 +66,18 @@ public class WatermarkSelectorTests @@ -65,8 +66,18 @@ public class WatermarkSelectorTests
var loggerFactory = new LoggerFactory().AddSerilog(Log.Logger);
// watermarks should always exist; effectively ignoring filesystem checks for now
var mockFileSystem = new MockFileSystem();
mockFileSystem.Initialize()
.WithFile("/tmp/watermark");
var fakeImageCache = Substitute.For<IImageCache>();
fakeImageCache.GetPathForImage(Arg.Any<string>(), Arg.Is(ArtworkKind.Watermark), Arg.Any<Option<int>>())
.Returns(_ => "/tmp/watermark");
WatermarkSelector = new WatermarkSelector(
Substitute.For<IImageCache>(),
mockFileSystem,
fakeImageCache,
new DecoSelector(loggerFactory.CreateLogger<DecoSelector>()),
loggerFactory.CreateLogger<WatermarkSelector>());

95
ErsatzTV.Core/FFmpeg/WatermarkSelector.cs

@ -1,3 +1,4 @@ @@ -1,3 +1,4 @@
using System.IO.Abstractions;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Domain.Scheduling;
@ -8,7 +9,11 @@ using Microsoft.Extensions.Logging; @@ -8,7 +9,11 @@ using Microsoft.Extensions.Logging;
namespace ErsatzTV.Core.FFmpeg;
public class WatermarkSelector(IImageCache imageCache, IDecoSelector decoSelector, ILogger<WatermarkSelector> logger)
public class WatermarkSelector(
IFileSystem fileSystem,
IImageCache imageCache,
IDecoSelector decoSelector,
ILogger<WatermarkSelector> logger)
: IWatermarkSelector
{
public List<WatermarkOptions> SelectWatermarks(
@ -170,10 +175,18 @@ public class WatermarkSelector(IImageCache imageCache, IDecoSelector decoSelecto @@ -170,10 +175,18 @@ public class WatermarkSelector(IImageCache imageCache, IDecoSelector decoSelecto
{
// used for song progress overlay
case ChannelWatermarkImageSource.Resource:
return new WatermarkOptions(
watermark,
Path.Combine(FileSystemLayout.ResourcesCacheFolder, watermark.Image),
Option<int>.None);
string resourcePath = fileSystem.Path.Combine(
FileSystemLayout.ResourcesCacheFolder,
watermark.Image);
if (fileSystem.File.Exists(resourcePath))
{
return new WatermarkOptions(watermark, resourcePath, Option<int>.None);
}
logger.LogWarning(
"Watermark resource no longer exists at {Path} and will be ignored",
resourcePath);
return None;
case ChannelWatermarkImageSource.Custom:
// bad form validation makes this possible
if (string.IsNullOrWhiteSpace(watermark.Image))
@ -190,10 +203,16 @@ public class WatermarkSelector(IImageCache imageCache, IDecoSelector decoSelecto @@ -190,10 +203,16 @@ public class WatermarkSelector(IImageCache imageCache, IDecoSelector decoSelecto
watermark.Image,
ArtworkKind.Watermark,
Option<int>.None);
return new WatermarkOptions(
watermark,
customPath,
None);
if (fileSystem.File.Exists(customPath))
{
return new WatermarkOptions(watermark, customPath, None);
}
logger.LogWarning(
"Custom watermark no longer exists at {Path} and will be ignored",
customPath);
return None;
case ChannelWatermarkImageSource.ChannelLogo:
logger.LogDebug("Watermark will come from playout item (channel logo)");
@ -207,7 +226,15 @@ public class WatermarkSelector(IImageCache imageCache, IDecoSelector decoSelecto @@ -207,7 +226,15 @@ public class WatermarkSelector(IImageCache imageCache, IDecoSelector decoSelecto
: imageCache.GetPathForImage(logoArtwork.Path, ArtworkKind.Logo, Option<int>.None);
}
return new WatermarkOptions(watermark, channelPath, None);
if (fileSystem.File.Exists(channelPath))
{
return new WatermarkOptions(watermark, channelPath, None);
}
logger.LogWarning(
"Channel logo no longer exists at {Path} and will be ignored",
channelPath);
return None;
default:
throw new NotSupportedException("Unsupported watermark image source");
}
@ -225,10 +252,16 @@ public class WatermarkSelector(IImageCache imageCache, IDecoSelector decoSelecto @@ -225,10 +252,16 @@ public class WatermarkSelector(IImageCache imageCache, IDecoSelector decoSelecto
channel.Watermark.Image,
ArtworkKind.Watermark,
Option<int>.None);
return new WatermarkOptions(
channel.Watermark,
customPath,
None);
if (fileSystem.File.Exists(customPath))
{
return new WatermarkOptions(channel.Watermark, customPath, None);
}
logger.LogWarning(
"Custom watermark no longer exists at {Path} and will be ignored",
customPath);
return None;
case ChannelWatermarkImageSource.ChannelLogo:
logger.LogDebug("Watermark will come from channel (channel logo)");
@ -242,7 +275,15 @@ public class WatermarkSelector(IImageCache imageCache, IDecoSelector decoSelecto @@ -242,7 +275,15 @@ public class WatermarkSelector(IImageCache imageCache, IDecoSelector decoSelecto
: imageCache.GetPathForImage(logoArtwork.Path, ArtworkKind.Logo, Option<int>.None);
}
return new WatermarkOptions(channel.Watermark, channelPath, None);
if (fileSystem.File.Exists(channelPath))
{
return new WatermarkOptions(channel.Watermark, channelPath, None);
}
logger.LogWarning(
"Channel logo no longer exists at {Path} and will be ignored",
channelPath);
return None;
default:
throw new NotSupportedException("Unsupported watermark image source");
}
@ -260,10 +301,16 @@ public class WatermarkSelector(IImageCache imageCache, IDecoSelector decoSelecto @@ -260,10 +301,16 @@ public class WatermarkSelector(IImageCache imageCache, IDecoSelector decoSelecto
watermark.Image,
ArtworkKind.Watermark,
Option<int>.None);
return new WatermarkOptions(
watermark,
customPath,
None);
if (fileSystem.File.Exists(customPath))
{
return new WatermarkOptions(watermark, customPath, None);
}
logger.LogWarning(
"Custom watermark no longer exists at {Path} and will be ignored",
customPath);
return None;
case ChannelWatermarkImageSource.ChannelLogo:
logger.LogDebug("Watermark will come from global (channel logo)");
@ -277,7 +324,15 @@ public class WatermarkSelector(IImageCache imageCache, IDecoSelector decoSelecto @@ -277,7 +324,15 @@ public class WatermarkSelector(IImageCache imageCache, IDecoSelector decoSelecto
: imageCache.GetPathForImage(logoArtwork.Path, ArtworkKind.Logo, Option<int>.None);
}
return new WatermarkOptions(watermark, channelPath, None);
if (fileSystem.File.Exists(channelPath))
{
return new WatermarkOptions(watermark, channelPath, None);
}
logger.LogWarning(
"Channel logo no longer exists at {Path} and will be ignored",
channelPath);
return None;
default:
throw new NotSupportedException("Unsupported watermark image source");
}

9
ErsatzTV.Infrastructure/Images/ImageCache.cs

@ -22,6 +22,12 @@ public class ImageCache(IFileSystem fileSystem, ILocalFileSystem localFileSystem @@ -22,6 +22,12 @@ public class ImageCache(IFileSystem fileSystem, ILocalFileSystem localFileSystem
static ImageCache() => Crypto = SHA1.Create();
public static string GetBlurHashFileName(string blurHash)
{
byte[] bytes = Encoding.UTF8.GetBytes(blurHash);
return Convert.ToBase64String(bytes).Replace("+", "_").Replace("/", "-").Replace("=", "");
}
public async Task<Either<BaseError, string>> SaveArtworkToCache(Stream stream, ArtworkKind artworkKind)
{
try
@ -149,8 +155,7 @@ public class ImageCache(IFileSystem fileSystem, ILocalFileSystem localFileSystem @@ -149,8 +155,7 @@ public class ImageCache(IFileSystem fileSystem, ILocalFileSystem localFileSystem
public Task<string> WriteBlurHash(string blurHash, IDisplaySize targetSize)
{
byte[] bytes = Encoding.UTF8.GetBytes(blurHash);
string base64 = Convert.ToBase64String(bytes).Replace("+", "_").Replace("/", "-").Replace("=", "");
string base64 = GetBlurHashFileName(blurHash);
string targetFile = GetPathForImage(base64, ArtworkKind.Poster, targetSize.Height) ?? string.Empty;
if (!fileSystem.File.Exists(targetFile))
{

5
ErsatzTV.Scanner.Tests/Core/FFmpeg/TranscodingTests.cs

@ -34,6 +34,7 @@ using NSubstitute; @@ -34,6 +34,7 @@ using NSubstitute;
using NUnit.Framework;
using Serilog;
using Shouldly;
using Testably.Abstractions;
using Testably.Abstractions.Testing;
using MediaStream = ErsatzTV.Core.Domain.MediaStream;
@ -236,7 +237,7 @@ public class TranscodingTests @@ -236,7 +237,7 @@ public class TranscodingTests
StreamingMode streamingMode)
{
var localFileSystem = new LocalFileSystem(
new MockFileSystem(),
new RealFileSystem(),
Substitute.For<IClient>(),
LoggerFactory.CreateLogger<LocalFileSystem>());
var fileSystem = new MockFileSystem();
@ -367,6 +368,7 @@ public class TranscodingTests @@ -367,6 +368,7 @@ public class TranscodingTests
DateTimeOffset now = DateTimeOffset.Now;
WatermarkSelector watermarkSelector = new WatermarkSelector(
new MockFileSystem(),
mockImageCache,
new DecoSelector(LoggerFactory.CreateLogger<DecoSelector>()),
LoggerFactory.CreateLogger<WatermarkSelector>());
@ -696,6 +698,7 @@ public class TranscodingTests @@ -696,6 +698,7 @@ public class TranscodingTests
.Returns(Path.Combine(TestContext.CurrentContext.TestDirectory, "Resources", "ErsatzTV.png"));
WatermarkSelector watermarkSelector = new WatermarkSelector(
new RealFileSystem(),
mockImageCache,
new DecoSelector(LoggerFactory.CreateLogger<DecoSelector>()),
LoggerFactory.CreateLogger<WatermarkSelector>());

13
ErsatzTV/Controllers/Api/MaintenanceController.cs

@ -1,3 +1,5 @@ @@ -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; @@ -7,7 +9,7 @@ namespace ErsatzTV.Controllers.Api;
[ApiController]
[EndpointGroupName("general")]
public class MaintenanceController(IMediator mediator)
public class MaintenanceController(IMediator mediator, ChannelWriter<IBackgroundServiceRequest> workerChannel)
{
[HttpGet("/api/maintenance/gc")]
[Tags("Maintenance")]
@ -36,4 +38,13 @@ public class MaintenanceController(IMediator mediator) @@ -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<IActionResult> CleanArtwork(CancellationToken cancellationToken)
{
await workerChannel.WriteAsync(new DeleteOrphanedArtwork(), cancellationToken);
return new OkResult();
}
}

4
ErsatzTV/Services/SchedulerService.cs

@ -119,7 +119,6 @@ public class SchedulerService : BackgroundService @@ -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 @@ -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);

Loading…
Cancel
Save