- 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.
91 lines
3.8 KiB
C#
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);
|
|
}
|
|
}
|