Files
ersatztv/ErsatzTV.Infrastructure/Streaming/Graphics/GraphicsElementSeeder.cs
T
timothyandtimothy ba6a4b08aa
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Skipped
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 9s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 25s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 8m40s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m18s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 6m12s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m13s
probe742/combined-newest SECOND
feat(732): On Now / Next gets a background box, and is on by default (#843)
Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
2026-08-26 19:28:25 +00:00

370 lines
16 KiB
C#

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);
}
/// <summary>
/// `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.
/// </summary>
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);
}
/// <summary>
/// #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.
/// </summary>
/// <remarks>
/// 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 <c>graphics.on-now-next-on-by-default</c> for why that trade is made this
/// way. Every channel created after the marker gets the element from
/// <c>ChannelGraphicsDefaults.Attach</c> instead, which BOTH create paths call.
/// </remarks>
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<int> 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<int> 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);
}
/// <summary>
/// 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.
/// </summary>
public static async Task<Option<int>> 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<int>.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();
}