Files
ersatztv/ErsatzTV/Services/RunOnce/ExternalLogoMigratorService.cs
T
timothy 8930972a0a fix(525): address final-review findings (migration host-crash guard, multi-error 400s)
- Medium-1: wrap ExternalLogoMigratorService.ExecuteAsync in try/catch — a DB
  exception (e.g. a channel deleted mid-migration -> DbUpdateConcurrencyException)
  no longer trips BackgroundServiceExceptionBehavior.StopHost and kills the app;
  it logs and self-heals on the next boot. Caller-cancel path handled separately.
- Low-2: CreateChannelHandler/UpdateChannelHandler validation failure now returns
  errors.Join() (all accumulated errors) not errors.Head (first only), restoring
  the repo-wide convention; regression test added.
- Low-4: corrected the Startup registration comment (migrator self-awaits
  WaitForDatabase; order is not load-bearing).

Final whole-branch review: MERGEABLE @ 6d5f6b24 (fable). Carried Minors adjudicated
acceptable-defer.
2026-07-21 13:18:48 +02:00

91 lines
3.8 KiB
C#

using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Infrastructure.Data;
using LanguageExt;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Services.RunOnce;
/// <summary>
/// One-time startup migration that downloads existing external-URL channel logos into the image
/// cache. Before ersatztv#525 a channel logo could be stored as a raw http(s) URL in
/// <see cref="Artwork.Path" />; the render path used to fetch it live. Now that URLs are cached on
/// save, these legacy rows are converted here. A download failure leaves the row untouched and logs
/// a warning naming the URL — re-saving the channel fixes it. Idempotent: a converted row's Path is
/// a bare cache name, so a second run selects nothing.
/// </summary>
public class ExternalLogoMigratorService(
IServiceScopeFactory serviceScopeFactory,
ILogger<ExternalLogoMigratorService> logger,
SystemStartup systemStartup)
: BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await Task.Yield();
await systemStartup.WaitForDatabase(stoppingToken);
if (stoppingToken.IsCancellationRequested)
{
return;
}
logger.LogInformation("Migrating external URL channel logos to the image cache");
try
{
using IServiceScope scope = serviceScopeFactory.CreateScope();
await using TvContext dbContext = scope.ServiceProvider.GetRequiredService<TvContext>();
IRemoteLogoCacher cacher = scope.ServiceProvider.GetRequiredService<IRemoteLogoCacher>();
await MigrateAsync(dbContext, cacher, logger, stoppingToken);
logger.LogInformation("Done migrating external URL channel logos to the image cache");
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
// shutdown mid-migration — the single trailing SaveChangesAsync never ran, so no partial
// persist; the next boot retries idempotently.
}
catch (Exception ex)
{
// this is a run-once BackgroundService: an escaping exception trips the default
// StopHost behavior and kills the app. A logo migration must never do that — the fetch
// races (e.g. a channel deleted mid-run -> DbUpdateConcurrencyException) are transient
// and self-heal on the next boot. Log and let the host keep serving.
logger.LogError(ex, "Failed migrating external URL channel logos to the image cache; will retry next start");
}
}
internal static async Task MigrateAsync(
TvContext db,
IRemoteLogoCacher cacher,
ILogger logger,
CancellationToken cancellationToken)
{
// IsExternalUrl is a C# predicate EF cannot translate, so load logo artwork then filter in memory.
List<Artwork> logos = await db.Artwork
.Where(a => a.ArtworkKind == ArtworkKind.Logo)
.ToListAsync(cancellationToken);
foreach (Artwork artwork in logos.Where(a => a.IsExternalUrl()))
{
string oldUrl = artwork.Path;
Either<BaseError, string> result = await cacher.CacheFromUrl(new Uri(oldUrl), cancellationToken);
result.Match(
name =>
{
artwork.Path = name;
artwork.DateUpdated = DateTime.UtcNow;
},
error => logger.LogWarning(
"Could not download existing channel logo {Url}; leaving it. Re-save the channel to fix. ({Error})",
oldUrl,
error.Value));
}
await db.SaveChangesAsync(cancellationToken);
}
}