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
Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
370 lines
16 KiB
C#
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();
|
|
}
|