using System.IO.Abstractions; using ErsatzTV.Core; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Graphics; using ErsatzTV.Infrastructure.Data; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; namespace ErsatzTV.Infrastructure.Streaming.Graphics; public static class GraphicsElementSeeder { // The pre-#732 default, kept verbatim so an installation still carrying it byte-for-byte can // be recognised as unmodified and upgraded. Never edit an entry here -- it is a fingerprint of // what we shipped, not a template. Add a new entry when the current default changes again. private const string OnNowNextYamlV1 = """ name: On Now / Next epg_entries: 2 location: BottomLeft horizontal_margin_percent: 4 vertical_margin_percent: 8 width_percent: 42 text_fit: Wrap text_align: Left z_index: 100 # transparent until 4s in, fade in 1s, hold 6s, fade out 1s opacity_expression: "LinearFadeDuration(content_seconds, 4, 1, 6)" base_style: now styles: - name: now font_family: "Noto Sans" font_size: 30 font_weight: 700 text_color: "#FFFFFF" halo_color: "#000000" halo_width: 2 - name: sub font_family: "Noto Sans" font_size: 22 font_weight: 400 text_color: "#DDDDDD" halo_color: "#000000" halo_width: 2 - name: next font_family: "Noto Sans" font_size: 22 font_weight: 400 text_color: "#DDDDDD" halo_color: "#000000" halo_width: 2 text: | [now]NOW {{ Epg[0].Title }}[/now] {{ if Epg[0].SubTitle }}[sub]{{ Epg[0].SubTitle }}[/sub]{{ end }} {{ if (array.size Epg) > 1 }}[next]NEXT {{ Epg[1].Title }}[/next]{{ end }} """; private const string OnNowNextYaml = """ name: On Now / Next epg_entries: 2 location: BottomLeft horizontal_margin_percent: 4 vertical_margin_percent: 8 width_percent: 42 text_fit: Wrap text_align: Left z_index: 100 # transparent until 4s in, fade in 1s, hold 6s, fade out 1s opacity_expression: "LinearFadeDuration(content_seconds, 4, 1, 6)" # #732: a translucent box carries legibility over both bright and dark content. The halo is # cut from 2 to 1 rather than dropped -- the box is translucent, so bright content still # shows through behind the glyphs, but 2px of halo ON TOP of a box over-darkens the text. background_color: "#000000" background_opacity_percent: 65 background_padding: 14 background_corner_radius: 8 # A translucent black box vanishes into dark content, so the box needs an edge of its own. # Low-alpha white reads as a hairline on dark frames without becoming a hard line on bright ones. border_color: "#59FFFFFF" border_width: 1 base_style: now styles: - name: now font_family: "Noto Sans" font_size: 30 font_weight: 700 text_color: "#FFFFFF" halo_color: "#000000" halo_width: 1 - name: sub font_family: "Noto Sans" font_size: 22 font_weight: 400 text_color: "#DDDDDD" halo_color: "#000000" halo_width: 1 - name: next font_family: "Noto Sans" font_size: 22 font_weight: 400 text_color: "#DDDDDD" halo_color: "#000000" halo_width: 1 text: | [now]NOW {{ Epg[0].Title }}[/now] {{ if Epg[0].SubTitle }}[sub]{{ Epg[0].SubTitle }}[/sub]{{ end }} {{ if (array.size Epg) > 1 }}[next]NEXT {{ Epg[1].Title }}[/next]{{ end }} """; // Every default we have ever shipped, most recent first. A file matching one of these was // written by us and never touched, so replacing it is an upgrade rather than a clobber. private static readonly string[] SupersededDefaults = [OnNowNextYamlV1]; public static async Task SeedOnNowNext( TvContext context, IFileSystem fileSystem, ILogger logger, CancellationToken cancellationToken) { string folder = FileSystemLayout.GraphicsElementsTextTemplatesFolder; string target = fileSystem.Path.Combine(folder, GraphicsElementDefaults.OnNowNextFileName); string seededKey = ConfigElementKey.GraphicsOnNowNextSeeded.Key; bool alreadySeeded = await context.ConfigElements.AnyAsync(c => c.Key == seededKey, cancellationToken); if (alreadySeeded) { // Already-seeded installations never revisit the file, so a change to the default would // otherwise reach new databases only. Upgrade the ones still carrying an untouched // earlier default; anything an operator edited no longer matches and is left alone. // // Deliberately no CreateDirectory on this branch: before #732 it touched the filesystem // not at all, so an already-seeded install stays bootable on a read-only /config. await UpgradeUnmodifiedTemplate(fileSystem, target, logger, cancellationToken); await EnsureBuiltInElementRow(context, fileSystem, target, cancellationToken); return; } if (!fileSystem.Directory.Exists(folder)) { fileSystem.Directory.CreateDirectory(folder); } // Adopt an operator's existing file untouched; only write when absent. if (!fileSystem.File.Exists(target)) { await fileSystem.File.WriteAllTextAsync(target, OnNowNextYaml, cancellationToken); } await context.ConfigElements.AddAsync( new ConfigElement { Key = seededKey, Value = "true" }, cancellationToken); await context.SaveChangesAsync(cancellationToken); await EnsureBuiltInElementRow(context, fileSystem, target, cancellationToken); } /// /// `RefreshGraphicsElements` is what normally turns a template file into a `GraphicsElement` row, /// but it runs on the scheduler/stream-start path -- long after startup. Creating the row here /// removes that ordering dependency, so `AttachOnNowNextByDefault` below can never mark itself /// done against an element that simply had not been discovered yet. /// private static async Task EnsureBuiltInElementRow( TvContext context, IFileSystem fileSystem, string target, CancellationToken cancellationToken) { if (!fileSystem.File.Exists(target)) { return; } bool exists = await context.GraphicsElements.AnyAsync(e => e.Path == target, cancellationToken); if (exists) { return; } // Name is display-only (identity is the filename), but leaving it null sorts the built-in // element into the unnamed bucket at the bottom of the SPA list until the first refresh. await context.GraphicsElements.AddAsync( new Core.Domain.GraphicsElement { Path = target, Kind = GraphicsElementKind.Text, Name = GraphicsElementDefaults.OnNowNextName }, cancellationToken); await context.SaveChangesAsync(cancellationToken); } /// /// #732: the On Now / Next overlay is a default, not an opt-in. Existing channels predate that /// decision, so attach the built-in element to them once. /// /// /// The marker is written only once the built-in element RESOLVES, so an install whose row does /// not exist yet is retried on the next startup rather than stranded permanently. Once written, /// no channel is ever re-attached. While still armed the backfill cannot tell a deliberately /// cleared channel from an untouched one -- a single global flag cannot express both /// properties; see graphics.on-now-next-on-by-default for why that trade is made this /// way. Every channel created after the marker gets the element from /// ChannelGraphicsDefaults.Attach instead, which BOTH create paths call. /// public static async Task AttachOnNowNextByDefault(TvContext context, CancellationToken cancellationToken) { string key = ConfigElementKey.GraphicsOnNowNextDefaultAttached.Key; if (await context.ConfigElements.AnyAsync(c => c.Key == key, cancellationToken)) { return; } Option maybeElementId = await GetBuiltInElementId(context, cancellationToken); if (maybeElementId.IsNone) { // Nothing to attach TO. Writing the marker here would strand every channel permanently // on the one population this exists for, so stay armed and try again next startup. return; } foreach (int elementId in maybeElementId) { // HLS Direct has no frame pipeline to draw into, so an attachment there would be inert // while still reading as "on" in the editor. List channelIds = await context.Channels .Where(c => c.StreamingMode != StreamingMode.HttpLiveStreamingDirect) .Where(c => c.ChannelGraphicsElements.All(cge => cge.GraphicsElementId != elementId)) .Select(c => c.Id) .ToListAsync(cancellationToken); foreach (int channelId in channelIds) { await context.AddAsync( new ChannelGraphicsElement { ChannelId = channelId, GraphicsElementId = elementId }, cancellationToken); } } await context.ConfigElements.AddAsync( new ConfigElement { Key = key, Value = "true" }, cancellationToken); await context.SaveChangesAsync(cancellationToken); } /// /// Identity is the filename, never the user-editable Name (the #67 lesson carried into #74). /// The Kind is part of it: the five template folders are separate namespaces, so an unrelated /// image/motion/subtitle/script element may legitimately be named `on-now-next.yml` too, and /// filename alone would hand back whichever row the unordered query happened to return first. /// public static async Task> GetBuiltInElementId( TvContext context, CancellationToken cancellationToken) { List<(int Id, string Path)> candidates = await context.GraphicsElements .Where(e => e.Kind == GraphicsElementKind.Text) .Select(e => new { e.Id, e.Path }) .ToListAsync(cancellationToken) .Map(rows => rows.Select(r => (r.Id, r.Path)).ToList()); var matches = candidates .Where(c => System.IO.Path.GetFileName(c.Path) == GraphicsElementDefaults.OnNowNextFileName) .OrderBy(c => c.Id) .ToList(); // Lowest id wins if two text templates somehow share the filename, so the choice is stable // across restarts rather than dependent on query order. return matches.Count == 0 ? Option.None : matches[0].Id; } private static async Task UpgradeUnmodifiedTemplate( IFileSystem fileSystem, string target, ILogger logger, CancellationToken cancellationToken) { // This runs inside the blocking database-startup path, ahead of DatabaseIsReady(). Before // #732 the already-seeded branch never touched the filesystem at all, so an unreadable or // read-only template is a state that used to boot fine -- it must not become a failure to // start. Cosmetic upgrade, best effort. try { if (!fileSystem.File.Exists(target)) { return; } string existing = await fileSystem.File.ReadAllTextAsync(target, cancellationToken); if (!SupersededDefaults.Any(d => IsSameTemplate(existing, d))) { return; } // Write-then-move, never write in place. WriteAllTextAsync truncates first, so an // interrupted write (disk full, IO fault, cancellation) would leave a partial file that // matches no fingerprint and is therefore never repaired on a later boot -- the overlay // would just be gone, permanently, on every channel carrying it. // // The temp name is random per call. A fixed one is shared by two containers on the same // config volume, where one can truncate it while the other is mid-write and then rename // the partial file over the live template. The process id is NOT good enough here: the // image uses an exec-form ENTRYPOINT, so every container's PID namespace makes this // process 1 and every container computes the same name. A random name also means a temp // left by a crashed earlier boot is never reused. Only ever delete the path this call // created. string temp = $"{target}.{fileSystem.Path.GetRandomFileName()}.upgrade.tmp"; try { await fileSystem.File.WriteAllTextAsync(temp, OnNowNextYaml, cancellationToken); // Deliberately NO in-place fallback when this throws. rename(2) onto a mountpoint // is EBUSY, so a single-file bind mount of this template will not be upgraded -- // accepted, because reaching that case needs a pinned file that is ALSO byte-identical // to a shipped default, and the alternative is reintroducing the truncation this // whole dance exists to prevent, on every IO fault rather than just that one. fileSystem.File.Move(temp, target, true); } finally { // Cleanup must never REPLACE the exception that brought us here. Without this inner // catch, a delete that throws while unwinding a cancellation swaps the // OperationCanceledException for an IOException, which the outer filter then // swallows -- so a real shutdown would be silently downgraded to a warning. try { if (fileSystem.File.Exists(temp)) { fileSystem.File.Delete(temp); } } catch (Exception cleanupEx) { logger.LogDebug(cleanupEx, "Could not remove the temporary upgrade file {Path}", temp); } } } // Recoverable filesystem faults only. Catching everything would swallow genuinely fatal // runtime failures (OutOfMemory and friends) and continue booting a compromised process; // letting IO escape would turn a file permission into a restart loop. Cancellation // propagates so shutdown is not swallowed. // OperationCanceledException is deliberately absent from this list so a real shutdown // propagates -- but only a real one: an OCE raised while the token is NOT cancelled is just // another faulty read, and letting it escape is the restart loop this catch exists to stop. catch (Exception ex) when ((ex is IOException or UnauthorizedAccessException or NotSupportedException or System.Security.SecurityException) || (ex is OperationCanceledException && !cancellationToken.IsCancellationRequested)) { logger.LogWarning( ex, "Could not upgrade the built-in graphics template at {Path}; leaving it as-is", target); } } // Compare on content, ignoring the line endings and trailing whitespace an editor or a volume // mount may rewrite. This is a fingerprint check, not a parse: anything that is not one of our // own shipped defaults must fall through untouched. private static bool IsSameTemplate(string left, string right) => string.Equals(Normalize(left), Normalize(right), StringComparison.Ordinal); private static string Normalize(string value) => value.Replace("\r\n", "\n", StringComparison.Ordinal).TrimEnd(); }