feat(74): per-channel On Now/Next transient overlay #569
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Application.Artworks;
|
||||
using ErsatzTV.Application.Artworks;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
@@ -32,4 +32,5 @@ public record UpdateChannel(
|
||||
ChannelTranscodeMode TranscodeMode,
|
||||
ChannelIdleBehavior IdleBehavior,
|
||||
bool IsEnabled,
|
||||
bool ShowInEpg) : IRequest<Either<BaseError, ChannelViewModel>>;
|
||||
bool ShowInEpg,
|
||||
List<int> GraphicsElementIds) : IRequest<Either<BaseError, ChannelViewModel>>;
|
||||
|
||||
@@ -34,6 +34,7 @@ public class UpdateChannelHandler(
|
||||
.Include(c => c.Artwork)
|
||||
.Include(c => c.Watermark)
|
||||
.Include(c => c.Playouts)
|
||||
.Include(c => c.ChannelGraphicsElements)
|
||||
.SelectOneAsync(c => c.Id, c => c.Id == request.ChannelId, cancellationToken);
|
||||
|
||||
return await maybeChannel.Match(
|
||||
@@ -173,6 +174,14 @@ public class UpdateChannelHandler(
|
||||
c.WatermarkId = update.WatermarkId;
|
||||
c.FallbackFillerId = update.FallbackFillerId;
|
||||
|
||||
c.ChannelGraphicsElements ??= [];
|
||||
var desired = update.GraphicsElementIds?.Distinct().ToList() ?? [];
|
||||
c.ChannelGraphicsElements.RemoveAll(cge => !desired.Contains(cge.GraphicsElementId));
|
||||
foreach (int id in desired.Where(id => c.ChannelGraphicsElements.All(cge => cge.GraphicsElementId != id)))
|
||||
{
|
||||
c.ChannelGraphicsElements.Add(new ChannelGraphicsElement { ChannelId = c.Id, GraphicsElementId = id });
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
searchTargets.SearchTargetsChanged();
|
||||
|
||||
@@ -93,7 +93,8 @@ internal static class Mapper
|
||||
channel.TranscodeMode,
|
||||
channel.IdleBehavior,
|
||||
channel.IsEnabled,
|
||||
channel.ShowInEpg);
|
||||
channel.ShowInEpg,
|
||||
channel.ChannelGraphicsElements?.Map(x => x.GraphicsElementId).ToArray() ?? []);
|
||||
}
|
||||
|
||||
internal static ChannelResponseModel ProjectToResponseModel(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using ErsatzTV.Core.Api.Graphics;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Graphics;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using static ErsatzTV.Application.Graphics.Mapper;
|
||||
@@ -18,10 +19,14 @@ public class GetAllGraphicsElementsForApiHandler(IDbContextFactory<TvContext> db
|
||||
.AsNoTracking()
|
||||
.ToListAsync(cancellationToken);
|
||||
return graphicsElements
|
||||
.Map(ProjectToViewModel)
|
||||
.OrderBy(e => e.Name == e.FileName)
|
||||
.ThenBy(e => e.Name)
|
||||
.Select(vm => new GraphicsElementResponseModel(vm.Id, vm.Name))
|
||||
.Select(e => new
|
||||
{
|
||||
Vm = ProjectToViewModel(e),
|
||||
BuiltIn = Path.GetFileName(e.Path) == GraphicsElementDefaults.OnNowNextFileName
|
||||
})
|
||||
.OrderBy(x => x.Vm.Name == x.Vm.FileName)
|
||||
.ThenBy(x => x.Vm.Name)
|
||||
.Select(x => new GraphicsElementResponseModel(x.Vm.Id, x.Vm.Name, x.BuiltIn))
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
@@ -60,6 +60,8 @@ public abstract class FFmpegProcessHandler<T> : IRequestHandler<T, Either<BaseEr
|
||||
.ThenInclude(p => p.Resolution)
|
||||
.Include(c => c.Artwork)
|
||||
.Include(c => c.Watermark)
|
||||
.Include(c => c.ChannelGraphicsElements)
|
||||
.ThenInclude(x => x.GraphicsElement)
|
||||
.SelectOneAsync(c => c.Number, c => c.Number == request.ChannelNumber, cancellationToken);
|
||||
|
||||
foreach (var channel in maybeChannel)
|
||||
|
||||
@@ -38,7 +38,8 @@ public record ChannelDetailResponseModel(
|
||||
ChannelTranscodeMode TranscodeMode,
|
||||
ChannelIdleBehavior IdleBehavior,
|
||||
bool IsEnabled,
|
||||
bool ShowInEpg);
|
||||
bool ShowInEpg,
|
||||
int[] GraphicsElementIds);
|
||||
|
||||
// Wire-compatible mirror of the Application-layer ArtworkContentTypeModel (which lives in
|
||||
// ErsatzTV.Application and therefore can't be referenced from Core). Same serialized shape the
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#nullable enable
|
||||
namespace ErsatzTV.Core.Api.Graphics;
|
||||
|
||||
public record GraphicsElementResponseModel(int Id, string Name);
|
||||
public record GraphicsElementResponseModel(int Id, string Name, bool BuiltIn);
|
||||
|
||||
@@ -25,6 +25,8 @@ public class Channel
|
||||
public StreamingMode StreamingMode { get; set; }
|
||||
public List<Playout> Playouts { get; set; }
|
||||
public List<Artwork> Artwork { get; set; }
|
||||
public List<GraphicsElement> GraphicsElements { get; set; }
|
||||
public List<ChannelGraphicsElement> ChannelGraphicsElements { get; set; }
|
||||
public ChannelStreamSelectorMode StreamSelectorMode { get; set; }
|
||||
public string StreamSelector { get; set; }
|
||||
public string PreferredAudioLanguageCode { get; set; }
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace ErsatzTV.Core.Domain;
|
||||
|
||||
public class ChannelGraphicsElement
|
||||
{
|
||||
public int ChannelId { get; set; }
|
||||
public Channel Channel { get; set; }
|
||||
public int GraphicsElementId { get; set; }
|
||||
public GraphicsElement GraphicsElement { get; set; }
|
||||
}
|
||||
@@ -25,6 +25,7 @@ public class ConfigElementKey
|
||||
public static ConfigElementKey FFmpegGlobalFallbackFillerId => new("ffmpeg.global_fallback_filler_id");
|
||||
public static ConfigElementKey ChannelTemplatesDefaultTemplateId => new("channel_templates.default_template_id");
|
||||
public static ConfigElementKey WatermarkChannelBugSeeded => new("watermark.channel_bug_seeded");
|
||||
public static ConfigElementKey GraphicsOnNowNextSeeded => new("graphics.on_now_next_seeded");
|
||||
public static ConfigElementKey FFmpegSegmenterTimeout => new("ffmpeg.segmenter.timeout_seconds");
|
||||
public static ConfigElementKey FFmpegWorkAheadSegmenters => new("ffmpeg.segmenter.work_ahead_limit");
|
||||
public static ConfigElementKey FFmpegInitialSegmentCount => new("ffmpeg.segmenter.initial_segment_count");
|
||||
|
||||
@@ -16,6 +16,8 @@ public class GraphicsElement
|
||||
public List<BlockItemGraphicsElement> BlockItemGraphicsElements { get; set; }
|
||||
public List<Deco> Decos { get; set; }
|
||||
public List<DecoGraphicsElement> DecoGraphicsElements { get; set; }
|
||||
public List<Channel> Channels { get; set; }
|
||||
public List<ChannelGraphicsElement> ChannelGraphicsElements { get; set; }
|
||||
|
||||
// for unit testing
|
||||
public override string ToString() => Path;
|
||||
|
||||
@@ -135,6 +135,15 @@ public class GraphicsElementSelector(IDecoSelector decoSelector, ILogger<Graphic
|
||||
|
||||
result.AddRange(playoutItem.PlayoutItemGraphicsElements);
|
||||
|
||||
// channel-level overlays are a base layer: merged with playout-item / Merge-deco elements,
|
||||
// but suppressed by a deco in Override/Disable mode (which returns before reaching here).
|
||||
if (channel.ChannelGraphicsElements is not null)
|
||||
{
|
||||
result.AddRange(
|
||||
channel.ChannelGraphicsElements.Map(cge =>
|
||||
new PlayoutItemGraphicsElement { PlayoutItem = playoutItem, GraphicsElement = cge.GraphicsElement }));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace ErsatzTV.Core.Graphics;
|
||||
|
||||
public static class GraphicsElementDefaults
|
||||
{
|
||||
// Built-in "On Now / Next" text element; identity is by filename, never by user-editable Name.
|
||||
public const string OnNowNextFileName = "on-now-next.yml";
|
||||
}
|
||||
Generated
+7303
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,51 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ErsatzTV.Infrastructure.MySql.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class Add_ChannelGraphicsElement : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ChannelGraphicsElement",
|
||||
columns: table => new
|
||||
{
|
||||
ChannelId = table.Column<int>(type: "int", nullable: false),
|
||||
GraphicsElementId = table.Column<int>(type: "int", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ChannelGraphicsElement", x => new { x.ChannelId, x.GraphicsElementId });
|
||||
table.ForeignKey(
|
||||
name: "FK_ChannelGraphicsElement_Channel_ChannelId",
|
||||
column: x => x.ChannelId,
|
||||
principalTable: "Channel",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_ChannelGraphicsElement_GraphicsElement_GraphicsElementId",
|
||||
column: x => x.GraphicsElementId,
|
||||
principalTable: "GraphicsElement",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
})
|
||||
.Annotation("MySql:CharSet", "utf8mb4");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ChannelGraphicsElement_GraphicsElementId",
|
||||
table: "ChannelGraphicsElement",
|
||||
column: "GraphicsElementId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "ChannelGraphicsElement");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -403,6 +403,21 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
|
||||
b.ToTable("Channel", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ErsatzTV.Core.Domain.ChannelGraphicsElement", b =>
|
||||
{
|
||||
b.Property<int>("ChannelId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int>("GraphicsElementId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.HasKey("ChannelId", "GraphicsElementId");
|
||||
|
||||
b.HasIndex("GraphicsElementId");
|
||||
|
||||
b.ToTable("ChannelGraphicsElement");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ErsatzTV.Core.Domain.ChannelTemplate", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@@ -4733,6 +4748,25 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
|
||||
b.Navigation("Watermark");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ErsatzTV.Core.Domain.ChannelGraphicsElement", b =>
|
||||
{
|
||||
b.HasOne("ErsatzTV.Core.Domain.Channel", "Channel")
|
||||
.WithMany("ChannelGraphicsElements")
|
||||
.HasForeignKey("ChannelId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("ErsatzTV.Core.Domain.GraphicsElement", "GraphicsElement")
|
||||
.WithMany("ChannelGraphicsElements")
|
||||
.HasForeignKey("GraphicsElementId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Channel");
|
||||
|
||||
b.Navigation("GraphicsElement");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ErsatzTV.Core.Domain.ChannelTemplate", b =>
|
||||
{
|
||||
b.HasOne("ErsatzTV.Core.Domain.FFmpegProfile", "FFmpegProfile")
|
||||
@@ -6778,6 +6812,8 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
|
||||
{
|
||||
b.Navigation("Artwork");
|
||||
|
||||
b.Navigation("ChannelGraphicsElements");
|
||||
|
||||
b.Navigation("Playouts");
|
||||
});
|
||||
|
||||
@@ -6824,6 +6860,8 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
|
||||
{
|
||||
b.Navigation("BlockItemGraphicsElements");
|
||||
|
||||
b.Navigation("ChannelGraphicsElements");
|
||||
|
||||
b.Navigation("DecoGraphicsElements");
|
||||
|
||||
b.Navigation("PlayoutItemGraphicsElements");
|
||||
|
||||
Generated
+7128
File diff suppressed because it is too large
Load Diff
+50
@@ -0,0 +1,50 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Sqlite.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class Add_ChannelGraphicsElement : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ChannelGraphicsElement",
|
||||
columns: table => new
|
||||
{
|
||||
ChannelId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
GraphicsElementId = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ChannelGraphicsElement", x => new { x.ChannelId, x.GraphicsElementId });
|
||||
table.ForeignKey(
|
||||
name: "FK_ChannelGraphicsElement_Channel_ChannelId",
|
||||
column: x => x.ChannelId,
|
||||
principalTable: "Channel",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_ChannelGraphicsElement_GraphicsElement_GraphicsElementId",
|
||||
column: x => x.GraphicsElementId,
|
||||
principalTable: "GraphicsElement",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ChannelGraphicsElement_GraphicsElementId",
|
||||
table: "ChannelGraphicsElement",
|
||||
column: "GraphicsElementId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "ChannelGraphicsElement");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -390,6 +390,21 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
|
||||
b.ToTable("Channel", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ErsatzTV.Core.Domain.ChannelGraphicsElement", b =>
|
||||
{
|
||||
b.Property<int>("ChannelId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("GraphicsElementId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("ChannelId", "GraphicsElementId");
|
||||
|
||||
b.HasIndex("GraphicsElementId");
|
||||
|
||||
b.ToTable("ChannelGraphicsElement");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ErsatzTV.Core.Domain.ChannelTemplate", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@@ -4558,6 +4573,25 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
|
||||
b.Navigation("Watermark");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ErsatzTV.Core.Domain.ChannelGraphicsElement", b =>
|
||||
{
|
||||
b.HasOne("ErsatzTV.Core.Domain.Channel", "Channel")
|
||||
.WithMany("ChannelGraphicsElements")
|
||||
.HasForeignKey("ChannelId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("ErsatzTV.Core.Domain.GraphicsElement", "GraphicsElement")
|
||||
.WithMany("ChannelGraphicsElements")
|
||||
.HasForeignKey("GraphicsElementId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Channel");
|
||||
|
||||
b.Navigation("GraphicsElement");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ErsatzTV.Core.Domain.ChannelTemplate", b =>
|
||||
{
|
||||
b.HasOne("ErsatzTV.Core.Domain.FFmpegProfile", "FFmpegProfile")
|
||||
@@ -6603,6 +6637,8 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
|
||||
{
|
||||
b.Navigation("Artwork");
|
||||
|
||||
b.Navigation("ChannelGraphicsElements");
|
||||
|
||||
b.Navigation("Playouts");
|
||||
});
|
||||
|
||||
@@ -6649,6 +6685,8 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
|
||||
{
|
||||
b.Navigation("BlockItemGraphicsElements");
|
||||
|
||||
b.Navigation("ChannelGraphicsElements");
|
||||
|
||||
b.Navigation("DecoGraphicsElements");
|
||||
|
||||
b.Navigation("PlayoutItemGraphicsElements");
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
@@ -53,5 +53,18 @@ public class ChannelConfiguration : IEntityTypeConfiguration<Channel>
|
||||
.HasForeignKey(i => i.MirrorSourceChannelId)
|
||||
.OnDelete(DeleteBehavior.SetNull)
|
||||
.IsRequired(false);
|
||||
|
||||
builder.HasMany(c => c.GraphicsElements)
|
||||
.WithMany(m => m.Channels)
|
||||
.UsingEntity<ChannelGraphicsElement>(
|
||||
j => j.HasOne(ci => ci.GraphicsElement)
|
||||
.WithMany(mi => mi.ChannelGraphicsElements)
|
||||
.HasForeignKey(ci => ci.GraphicsElementId)
|
||||
.OnDelete(DeleteBehavior.Cascade),
|
||||
j => j.HasOne(ci => ci.Channel)
|
||||
.WithMany(c => c.ChannelGraphicsElements)
|
||||
.HasForeignKey(ci => ci.ChannelId)
|
||||
.OnDelete(DeleteBehavior.Cascade),
|
||||
j => j.HasKey(ci => new { ci.ChannelId, ci.GraphicsElementId }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ public class ChannelRepository(IDbContextFactory<TvContext> dbContextFactory) :
|
||||
.Include(c => c.Playouts)
|
||||
.Include(c => c.MirrorSourceChannel)
|
||||
.ThenInclude(mc => mc.Playouts)
|
||||
.Include(c => c.ChannelGraphicsElements)
|
||||
.ThenInclude(x => x.GraphicsElement)
|
||||
.OrderBy(c => c.Id)
|
||||
.SingleOrDefaultAsync(c => c.Id == id)
|
||||
.Map(Optional);
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
using System.IO.Abstractions;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Graphics;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Streaming.Graphics;
|
||||
|
||||
public static class GraphicsElementSeeder
|
||||
{
|
||||
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)"
|
||||
base_style: now
|
||||
styles:
|
||||
- name: now
|
||||
font_size: 30
|
||||
font_weight: 700
|
||||
text_color: "#FFFFFF"
|
||||
halo_color: "#000000"
|
||||
halo_width: 2
|
||||
- name: sub
|
||||
font_size: 22
|
||||
font_weight: 400
|
||||
text_color: "#DDDDDD"
|
||||
halo_color: "#000000"
|
||||
halo_width: 2
|
||||
- name: next
|
||||
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 Epg.size > 1 }}[next]NEXT {{ Epg[1].Title }} · {{ format_datetime (convert_timezone Epg[1].Start) "h:mm tt" }}[/next]{{ end }}
|
||||
""";
|
||||
|
||||
public static async Task SeedOnNowNext(TvContext context, IFileSystem fileSystem, CancellationToken cancellationToken)
|
||||
{
|
||||
string seededKey = ConfigElementKey.GraphicsOnNowNextSeeded.Key;
|
||||
bool alreadySeeded = await context.ConfigElements.AnyAsync(c => c.Key == seededKey, cancellationToken);
|
||||
if (alreadySeeded)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string folder = FileSystemLayout.GraphicsElementsTextTemplatesFolder;
|
||||
string target = fileSystem.Path.Combine(folder, GraphicsElementDefaults.OnNowNextFileName);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using LanguageExt;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Application.Channels;
|
||||
|
||||
[TestFixture]
|
||||
public class UpdateChannelGraphicsElementsTests : ChannelHandlerTestBase
|
||||
{
|
||||
private UpdateChannelHandler MakeHandler() => new(Worker, Db.Factory, SearchTargets, RemoteLogoCacher);
|
||||
|
||||
private async Task<(int ElementAId, int ElementBId)> SeedGraphicsElements()
|
||||
{
|
||||
await using TvContext context = Db.CreateContext();
|
||||
var elementA = new GraphicsElement { Path = "element-a.yml" };
|
||||
var elementB = new GraphicsElement { Path = "element-b.yml" };
|
||||
context.GraphicsElements.AddRange(elementA, elementB);
|
||||
await context.SaveChangesAsync();
|
||||
return (elementA.Id, elementB.Id);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Reconcile_GraphicsElement_Join_Add_Then_Remove()
|
||||
{
|
||||
await SeedFFmpegProfile();
|
||||
Channel channel = await SeedChannel(1, "5");
|
||||
(int elementAId, int elementBId) = await SeedGraphicsElements();
|
||||
|
||||
Either<BaseError, ChannelViewModel> addResult = await MakeHandler().Handle(
|
||||
MakeUpdate(channel.Id, number: "5", graphicsElementIds: [elementAId, elementBId]),
|
||||
CancellationToken.None);
|
||||
|
||||
addResult.IsRight.ShouldBeTrue();
|
||||
|
||||
await using (TvContext context = Db.CreateContext())
|
||||
{
|
||||
Channel reloaded = await context.Channels.Include(c => c.ChannelGraphicsElements)
|
||||
.SingleAsync(c => c.Id == channel.Id);
|
||||
reloaded.ChannelGraphicsElements.Select(x => x.GraphicsElementId)
|
||||
.OrderBy(id => id)
|
||||
.ShouldBe(new[] { elementAId, elementBId }.OrderBy(id => id));
|
||||
}
|
||||
|
||||
Either<BaseError, ChannelViewModel> removeResult = await MakeHandler().Handle(
|
||||
MakeUpdate(channel.Id, number: "5", graphicsElementIds: [elementAId]),
|
||||
CancellationToken.None);
|
||||
|
||||
removeResult.IsRight.ShouldBeTrue();
|
||||
|
||||
await using (TvContext context = Db.CreateContext())
|
||||
{
|
||||
Channel reloaded = await context.Channels.Include(c => c.ChannelGraphicsElements)
|
||||
.SingleAsync(c => c.Id == channel.Id);
|
||||
reloaded.ChannelGraphicsElements.Select(x => x.GraphicsElementId).ShouldBe(new[] { elementAId });
|
||||
}
|
||||
|
||||
Either<BaseError, ChannelViewModel> clearResult = await MakeHandler().Handle(
|
||||
MakeUpdate(channel.Id, number: "5", graphicsElementIds: []),
|
||||
CancellationToken.None);
|
||||
|
||||
clearResult.IsRight.ShouldBeTrue();
|
||||
|
||||
await using (TvContext context = Db.CreateContext())
|
||||
{
|
||||
Channel reloaded = await context.Channels.Include(c => c.ChannelGraphicsElements)
|
||||
.SingleAsync(c => c.Id == channel.Id);
|
||||
reloaded.ChannelGraphicsElements.ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -561,7 +561,8 @@ public class ChannelControllerTests
|
||||
ChannelTranscodeMode.OnDemand,
|
||||
ChannelIdleBehavior.StopOnDisconnect,
|
||||
true,
|
||||
false);
|
||||
false,
|
||||
[]);
|
||||
|
||||
private static ChannelViewModel MakeVm(int id) =>
|
||||
new(
|
||||
@@ -677,5 +678,6 @@ public class ChannelControllerTests
|
||||
ChannelTranscodeMode.OnDemand,
|
||||
ChannelIdleBehavior.StopOnDisconnect,
|
||||
true,
|
||||
false);
|
||||
false,
|
||||
[]);
|
||||
}
|
||||
|
||||
@@ -51,8 +51,8 @@ public class GraphicsElementControllerTests
|
||||
{
|
||||
List<GraphicsElementResponseModel> models =
|
||||
[
|
||||
new GraphicsElementResponseModel(1, "Lower Third (lower-third.png)"),
|
||||
new GraphicsElementResponseModel(2, "bug.png")
|
||||
new GraphicsElementResponseModel(1, "Lower Third (lower-third.png)", false),
|
||||
new GraphicsElementResponseModel(2, "bug.png", false)
|
||||
];
|
||||
_mediator.Send(Arg.Any<GetAllGraphicsElementsForApi>(), Arg.Any<CancellationToken>())
|
||||
.Returns(models);
|
||||
|
||||
@@ -104,7 +104,8 @@ public class OpenApiSerializerContractTests
|
||||
default,
|
||||
default,
|
||||
true,
|
||||
true);
|
||||
true,
|
||||
[1]);
|
||||
|
||||
private static FFmpegSettingsResponseModel FullyPopulatedFFmpegSettings() => new(
|
||||
"/usr/bin/ffmpeg",
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.Domain.Scheduling;
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using LanguageExt;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Core.FFmpeg;
|
||||
|
||||
[TestFixture]
|
||||
public class GraphicsElementSelectorTests
|
||||
{
|
||||
private static (GraphicsElementSelector sel, IDecoSelector deco) Build(DecoEntries entries)
|
||||
{
|
||||
var deco = Substitute.For<IDecoSelector>();
|
||||
deco.GetDecoEntries(Arg.Any<Playout>(), Arg.Any<DateTimeOffset>()).Returns(entries);
|
||||
return (new GraphicsElementSelector(deco, NullLogger<GraphicsElementSelector>.Instance), deco);
|
||||
}
|
||||
|
||||
private static Channel ChannelWithElement(int elementId, StreamingMode mode = StreamingMode.HttpLiveStreamingSegmenter)
|
||||
{
|
||||
var element = new GraphicsElement { Id = elementId, Path = "/x/on-now-next.yml", Kind = GraphicsElementKind.Text };
|
||||
return new Channel(Guid.NewGuid())
|
||||
{
|
||||
StreamingMode = mode,
|
||||
ChannelGraphicsElements = [new ChannelGraphicsElement { GraphicsElementId = elementId, GraphicsElement = element }]
|
||||
};
|
||||
}
|
||||
|
||||
private static PlayoutItem Item() => new()
|
||||
{
|
||||
Playout = new Playout(),
|
||||
FillerKind = FillerKind.None,
|
||||
PlayoutItemGraphicsElements = []
|
||||
};
|
||||
|
||||
[Test]
|
||||
public void Channel_Element_Is_Emitted_When_No_Deco()
|
||||
{
|
||||
(GraphicsElementSelector sel, _) = Build(new DecoEntries(Option<Deco>.None, Option<Deco>.None));
|
||||
|
||||
List<PlayoutItemGraphicsElement> result = sel.SelectGraphicsElements(ChannelWithElement(7), Item(), DateTimeOffset.Now);
|
||||
|
||||
result.Count.ShouldBe(1);
|
||||
result[0].GraphicsElement.Id.ShouldBe(7);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Channel_Element_Is_Suppressed_On_HlsDirect()
|
||||
{
|
||||
(GraphicsElementSelector sel, _) = Build(new DecoEntries(Option<Deco>.None, Option<Deco>.None));
|
||||
|
||||
List<PlayoutItemGraphicsElement> result = sel.SelectGraphicsElements(
|
||||
ChannelWithElement(7, StreamingMode.HttpLiveStreamingDirect), Item(), DateTimeOffset.Now);
|
||||
|
||||
result.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Channel_Element_Is_Suppressed_By_Disable_Deco()
|
||||
{
|
||||
var deco = new Deco { GraphicsElementsMode = DecoMode.Disable, DecoGraphicsElements = [] };
|
||||
(GraphicsElementSelector sel, _) = Build(new DecoEntries(deco, Option<Deco>.None));
|
||||
|
||||
List<PlayoutItemGraphicsElement> result = sel.SelectGraphicsElements(ChannelWithElement(7), Item(), DateTimeOffset.Now);
|
||||
|
||||
result.ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Infrastructure;
|
||||
|
||||
[TestFixture]
|
||||
public class ChannelGraphicsElementPersistenceTests
|
||||
{
|
||||
private InMemoryTvContext _db = null!;
|
||||
|
||||
[SetUp]
|
||||
public async Task SetUp() => _db = await InMemoryTvContext.CreateAsync();
|
||||
|
||||
[TearDown]
|
||||
public async Task TearDown() => await _db.DisposeAsync();
|
||||
|
||||
[Test]
|
||||
public async Task Channel_Can_Attach_And_Read_Back_A_Graphics_Element()
|
||||
{
|
||||
int channelId;
|
||||
int elementId;
|
||||
|
||||
await using (TvContext context = _db.CreateContext())
|
||||
{
|
||||
var profile = new FFmpegProfile { Name = "p" };
|
||||
await context.FFmpegProfiles.AddAsync(profile);
|
||||
var element = new GraphicsElement { Path = "/x/on-now-next.yml", Name = "On Now / Next", Kind = GraphicsElementKind.Text };
|
||||
await context.GraphicsElements.AddAsync(element);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var channel = new Channel(Guid.NewGuid())
|
||||
{
|
||||
Number = "1", Name = "c", Group = "g", FFmpegProfileId = profile.Id,
|
||||
ChannelGraphicsElements = [new ChannelGraphicsElement { GraphicsElementId = element.Id }]
|
||||
};
|
||||
await context.Channels.AddAsync(channel);
|
||||
await context.SaveChangesAsync();
|
||||
channelId = channel.Id;
|
||||
elementId = element.Id;
|
||||
}
|
||||
|
||||
await using (TvContext context = _db.CreateContext())
|
||||
{
|
||||
Channel loaded = await context.Channels
|
||||
.Include(c => c.ChannelGraphicsElements)
|
||||
.ThenInclude(x => x.GraphicsElement)
|
||||
.SingleAsync(c => c.Id == channelId);
|
||||
|
||||
loaded.ChannelGraphicsElements.Count.ShouldBe(1);
|
||||
loaded.ChannelGraphicsElements[0].GraphicsElementId.ShouldBe(elementId);
|
||||
loaded.ChannelGraphicsElements[0].GraphicsElement.Name.ShouldBe("On Now / Next");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using System.IO.Abstractions;
|
||||
using Testably.Abstractions.Testing;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Streaming.Graphics;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Infrastructure;
|
||||
|
||||
[TestFixture]
|
||||
public class GraphicsElementSeederTests
|
||||
{
|
||||
private InMemoryTvContext _db = null!;
|
||||
private string _seededPath = null!;
|
||||
|
||||
[SetUp]
|
||||
public async Task SetUp()
|
||||
{
|
||||
_db = await InMemoryTvContext.CreateAsync();
|
||||
_seededPath = Path.Combine(FileSystemLayout.GraphicsElementsTextTemplatesFolder, "on-now-next.yml");
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public async Task TearDown() => await _db.DisposeAsync();
|
||||
|
||||
[Test]
|
||||
public async Task Seeds_File_And_Marker_When_Absent()
|
||||
{
|
||||
var fs = new MockFileSystem();
|
||||
await using TvContext context = _db.CreateContext();
|
||||
|
||||
await GraphicsElementSeeder.SeedOnNowNext(context, fs, CancellationToken.None);
|
||||
|
||||
fs.File.Exists(_seededPath).ShouldBeTrue();
|
||||
fs.File.ReadAllText(_seededPath).ShouldContain("epg_entries: 2");
|
||||
(await context.ConfigElements.AnyAsync(c => c.Key == ConfigElementKey.GraphicsOnNowNextSeeded.Key)).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Adopts_Existing_File_Untouched()
|
||||
{
|
||||
var fs = new MockFileSystem();
|
||||
fs.Directory.CreateDirectory(FileSystemLayout.GraphicsElementsTextTemplatesFolder);
|
||||
await fs.File.WriteAllTextAsync(_seededPath, "name: Operator Custom\nepg_entries: 2\n");
|
||||
await using TvContext context = _db.CreateContext();
|
||||
|
||||
await GraphicsElementSeeder.SeedOnNowNext(context, fs, CancellationToken.None);
|
||||
|
||||
fs.File.ReadAllText(_seededPath).ShouldContain("Operator Custom");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Does_Not_Resurrect_After_Delete()
|
||||
{
|
||||
var fs = new MockFileSystem();
|
||||
await using TvContext context = _db.CreateContext();
|
||||
|
||||
await GraphicsElementSeeder.SeedOnNowNext(context, fs, CancellationToken.None);
|
||||
fs.File.Delete(_seededPath);
|
||||
await GraphicsElementSeeder.SeedOnNowNext(context, fs, CancellationToken.None);
|
||||
|
||||
fs.File.Exists(_seededPath).ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
@@ -126,7 +126,8 @@ public abstract class ChannelHandlerTestBase
|
||||
bool showInEpg = false,
|
||||
string logoPath = "",
|
||||
string name = "Test",
|
||||
string group = "ErsatzTV") =>
|
||||
string group = "ErsatzTV",
|
||||
List<int> graphicsElementIds = null) =>
|
||||
new(
|
||||
channelId,
|
||||
name,
|
||||
@@ -155,5 +156,6 @@ public abstract class ChannelHandlerTestBase
|
||||
ChannelTranscodeMode.OnDemand,
|
||||
ChannelIdleBehavior.StopOnDisconnect,
|
||||
isEnabled,
|
||||
showInEpg);
|
||||
showInEpg,
|
||||
graphicsElementIds ?? []);
|
||||
}
|
||||
|
||||
@@ -35,7 +35,8 @@ public record UpdateChannelRequest(
|
||||
ChannelTranscodeMode TranscodeMode,
|
||||
ChannelIdleBehavior IdleBehavior,
|
||||
bool IsEnabled,
|
||||
bool ShowInEpg)
|
||||
bool ShowInEpg,
|
||||
List<int> GraphicsElementIds)
|
||||
{
|
||||
public UpdateChannel ToCommand(int channelId) =>
|
||||
new(
|
||||
@@ -66,5 +67,6 @@ public record UpdateChannelRequest(
|
||||
TranscodeMode,
|
||||
IdleBehavior,
|
||||
IsEnabled,
|
||||
ShowInEpg);
|
||||
ShowInEpg,
|
||||
GraphicsElementIds ?? []);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
using System.Reflection;
|
||||
using System.Reflection;
|
||||
using Dapper;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Streaming.Graphics;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
@@ -88,6 +89,9 @@ public class DatabaseMigratorService : BackgroundService
|
||||
_logger.LogInformation("Initializing database");
|
||||
await DbInitializer.Initialize(dbContext, stoppingToken);
|
||||
|
||||
var fileSystem = scope.ServiceProvider.GetRequiredService<System.IO.Abstractions.IFileSystem>();
|
||||
await GraphicsElementSeeder.SeedOnNowNext(dbContext, fileSystem, stoppingToken);
|
||||
|
||||
_systemStartup.DatabaseIsReady();
|
||||
|
||||
_logger.LogInformation("Done applying database migrations");
|
||||
|
||||
@@ -23611,7 +23611,8 @@
|
||||
"transcodeMode",
|
||||
"idleBehavior",
|
||||
"isEnabled",
|
||||
"showInEpg"
|
||||
"showInEpg",
|
||||
"graphicsElementIds"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -23724,6 +23725,13 @@
|
||||
},
|
||||
"showInEpg": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"graphicsElementIds": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -26710,7 +26718,8 @@
|
||||
"GraphicsElementResponseModel": {
|
||||
"required": [
|
||||
"id",
|
||||
"name"
|
||||
"name",
|
||||
"builtIn"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -26720,6 +26729,9 @@
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"builtIn": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -31378,7 +31390,8 @@
|
||||
"transcodeMode",
|
||||
"idleBehavior",
|
||||
"isEnabled",
|
||||
"showInEpg"
|
||||
"showInEpg",
|
||||
"graphicsElementIds"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -31510,6 +31523,16 @@
|
||||
},
|
||||
"showInEpg": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"graphicsElementIds": {
|
||||
"type": [
|
||||
"null",
|
||||
"array"
|
||||
],
|
||||
"items": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -728,6 +728,12 @@ for items that have no group, so the SPA can render an "ungrouped" bucket
|
||||
concept elsewhere, this is the established pattern to follow — but be aware it means `Id` is not a
|
||||
reliable real-entity id for those synthetic rows.
|
||||
|
||||
**Channel graphics (issue #74)**: `ChannelDetailResponseModel`/`UpdateChannelRequest` carry
|
||||
`graphicsElementIds` (the channel's attached `GraphicsElement` ids, reconciled add/remove on PUT via
|
||||
`Channel.ChannelGraphicsElements`), and `GraphicsElementResponseModel` exposes a server-derived
|
||||
`builtIn` (`Path.GetFileName(element.Path) == GraphicsElementDefaults.OnNowNextFileName`) — never
|
||||
client-settable.
|
||||
|
||||
## 9. Authentication — session-or-key posture (fail-closed)
|
||||
|
||||
The whole `/api` surface is gated by the global `ApiAuthorizationFilter` (renamed from
|
||||
|
||||
@@ -203,3 +203,41 @@ bounded render-time fetch that preceded caching) is in `docs/decisions.md` under
|
||||
|
||||
Note: `ChannelLogoGenerator.GenerateChannelLogoUrl()` hardcodes `localhost` for watermark logo
|
||||
fetching — see issue #1 for details.
|
||||
|
||||
## On Now / Next overlay (#74)
|
||||
|
||||
A transient "On Now / Next" text bug — the current program (title + episode/subtitle) and the
|
||||
next program (title + start time) — burned onto a channel's transcoded stream for a few seconds
|
||||
at each program transition, for a channel-surf feel. Toggled per channel at the channel editor's
|
||||
**Branding** tab, "Show On Now / Next overlay" switch, beside the logo-bug toggle.
|
||||
|
||||
This is a **graphics element** (`GraphicsElement`, `Text` kind), not a watermark — the existing
|
||||
watermark system is image-only and a channel already uses its one `WatermarkId` for the #67 logo
|
||||
bug, so multi-line EPG text needed the separate graphics-element system, which previously had no
|
||||
channel-level attachment. #74 added one: `ChannelGraphicsElement`, a join table structurally
|
||||
identical to the existing `PlayoutItemGraphicsElement`/`ProgramScheduleItemGraphicsElement`/
|
||||
`BlockItemGraphicsElement`/`DecoGraphicsElement` joins. See `docs/domain-model.md` → "Graphics
|
||||
element" and `docs/decisions.md` → `graphics.channel-level-attachment`.
|
||||
|
||||
- **Seeded, built-in element.** A text graphics element YAML (`on-now-next.yml`) is written to the
|
||||
graphics-elements templates folder and its `GraphicsElement` row created once per database
|
||||
(`GraphicsElementSeeder.SeedOnNowNext`, guarded by the `graphics.on_now_next_seeded`
|
||||
`ConfigElement` marker) — mirroring the #67 watermark-preset seed's adopt-not-clobber semantics.
|
||||
The API marks it with `GraphicsElementResponseModel.builtIn = true` so the SPA finds it reliably
|
||||
rather than matching on name.
|
||||
- **Toggle mechanics.** Turning the switch on adds the built-in element's id to the channel's
|
||||
`graphicsElementIds` (`ChannelDetailResponseModel`/update DTO); turning it off removes it. This
|
||||
follows the same pattern as the existing "Use logo as on-screen bug" watermark toggle.
|
||||
- **EPG source is the same cached guide the channel serves** — the overlay reads the cached XMLTV
|
||||
fragment (`ChannelGuideCacheFolder/{number}.xml`), same as the guide grid, so it is only as fresh
|
||||
as that cache. On-demand/time-shifted channels can lag until the guide is refreshed on thaw (see
|
||||
On-demand resume, above); a normal continuous channel stays in sync.
|
||||
- **Does not apply to HLS-Direct.** `StreamingMode.HttpLiveStreamingDirect` streams the source
|
||||
file(s) without transcoding, so there is no frame pipeline to burn text into — the selector
|
||||
always returns no graphics elements in that mode, and the editor's toggle is disabled with an
|
||||
explanatory caption when the channel is in HLS-Direct.
|
||||
- **A deco can suppress it.** A `Deco` in `Override` or `Disable` mode for its graphics-elements
|
||||
section takes precedence over the channel-level overlay (channel elements are a base layer, not
|
||||
the final word). Additionally, on a **filler** item a deco whose graphics-elements section is not
|
||||
set to run during filler (`UseGraphicsElementsDuringFiller` false) also clears the overlay, for
|
||||
`Merge` and `Override` alike — see `docs/domain-model.md` → "Graphics element".
|
||||
|
||||
@@ -2713,6 +2713,53 @@ shared row already delivers the user-visible behavior with no schema change.
|
||||
|
||||
**Accepted trade-off:** every channel on the shared preset shares one geometry; per-channel tweaks mean
|
||||
creating a second preset on the Watermarks screen.
|
||||
|
||||
## 2026-07-22 — Channel-level graphics-element attachment + seeded On Now/Next text element (#74)
|
||||
`key: graphics.channel-level-attachment` · `status: active` · `since: 2026-07-22` · `supersedes: none` · `superseded-by: none`
|
||||
**Rule:** A channel can attach `GraphicsElement`s directly via a new `ChannelGraphicsElement` join table (a base layer under deco/playout-item elements), and a built-in text element (`on-now-next.yml`) is seeded once per database so the On Now/Next overlay works out of the box.
|
||||
**Signals:** ChannelGraphicsElement, Channel graphics attachment, GraphicsElementSelector base layer, on-now-next seeded element, GraphicsElementDefaults.OnNowNextFileName, builtIn discriminator · paths: `ErsatzTV.Core/Domain/ChannelGraphicsElement.cs`, `ErsatzTV.Core/FFmpeg/GraphicsElementSelector.cs`, `ErsatzTV.Infrastructure/Streaming/Graphics/GraphicsElementSeeder.cs`, `ConfigElementKey.GraphicsOnNowNextSeeded`, `GraphicsElementResponseModel.BuiltIn` · issues: #74
|
||||
|
||||
#74 asked for a transient "On Now / Next" text bug burned onto the transcoded stream at each
|
||||
program transition. The rendering and EPG-template-data infrastructure already existed (upstream
|
||||
graphics engine + our #502/#511 remote-image/graphics-engine work); the gap was that graphics
|
||||
elements had **no channel-level attachment** — only `PlayoutItem`/`ProgramScheduleItem`/`BlockItem`/
|
||||
`Deco` joins existed — and there was **no seeded/built-in graphics element**, unlike watermarks.
|
||||
|
||||
**Decision: add a 5th join table, `ChannelGraphicsElement`, rather than reuse the watermark FK.**
|
||||
Watermarks and graphics elements are separate parallel systems; a channel already has exactly one
|
||||
`WatermarkId`, already spent on the #67 logo bug, and multi-line EPG text is a poor fit for the
|
||||
single-image watermark model. `ChannelGraphicsElement` is structurally identical to the existing
|
||||
four joins (composite key `{ChannelId, GraphicsElementId}`), added via a dual-provider migration
|
||||
(`scripts/add-migration.sh Add_ChannelGraphicsElement`).
|
||||
|
||||
- `GraphicsElementSelector.SelectGraphicsElements` appends channel-level elements at the **final
|
||||
fall-through**, alongside `playoutItem.PlayoutItemGraphicsElements` — a **base layer**. A deco in
|
||||
`Merge` mode composes with it; a deco in `Override`/`Disable` mode returns earlier and so
|
||||
suppresses it (decos are allowed to override channel defaults, a deliberate rule). One more
|
||||
suppression path: on a **filler** item, a deco whose graphics-elements section is not set to run
|
||||
during filler (`UseGraphicsElementsDuringFiller` false) clears the result and returns for `Merge`
|
||||
and `Override` alike, so the channel base layer is dropped there too.
|
||||
`HttpLiveStreamingDirect` continues to return empty (ErsatzTV isn't transcoding, so there is no
|
||||
frame pipeline to draw into).
|
||||
- **Seeded built-in element**, mirroring the `iptv.logo-drives-bug-preset` (#67) pattern:
|
||||
`GraphicsElementSeeder.SeedOnNowNext` writes `on-now-next.yml` into the graphics-elements
|
||||
templates folder (only if the file is absent — operator edits are never clobbered) and ensures a
|
||||
`GraphicsElement` row exists for it, guarded by the `graphics.on_now_next_seeded` `ConfigElement`
|
||||
marker so it runs once per database, not once per file-absence (the same reasoning as #67: the
|
||||
seeder runs at every startup, so a name/file-only guard would resurrect a deliberately deleted
|
||||
preset).
|
||||
- The API needed a way for the SPA to find the built-in element without a fragile name-match — the
|
||||
direct #67 lesson (`WatermarkResponseModel.imageSource`). `GraphicsElementResponseModel` gained a
|
||||
server-derived `BuiltIn` bool, computed by comparing the row's `Path` filename to
|
||||
`GraphicsElementDefaults.OnNowNextFileName` rather than trusting the element's editable `Name`.
|
||||
- The channel editor's Branding-tab "Show On Now / Next overlay" switch follows the exact pattern
|
||||
of the existing logo-bug toggle: on adds the built-in element's id to `graphicsElementIds`, off
|
||||
removes it; disabled (with an explanatory caption) when the channel is HLS-Direct.
|
||||
|
||||
**Accepted trade-off:** all channels that enable the toggle share one seeded element's geometry/
|
||||
content; per-channel customization means editing the shared YAML or attaching a different element
|
||||
(the join is general, not restricted to the seeded one).
|
||||
|
||||
## 2026-07-20 (#498) — QSV decode is split from QSV encode via a single `QsvPreferNativeDecoder` bool
|
||||
`key: ffmpeg.qsv-decode-encode-split` · `status: active` · `since: 2026-07-20` · `supersedes: none` · `superseded-by: none`
|
||||
**Rule:** QSV decode is decoupled from QSV encode via a single `FFmpegProfile.QsvPreferNativeDecoder` bool (default ON, Linux-only), so a QSV encode profile can decode with the more tolerant native VA-API decoder instead of the QSV decoder, mirroring Jellyfin's hybrid decode/encode toggle instead of a general decode-family enum.
|
||||
|
||||
@@ -67,6 +67,7 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera
|
||||
| `ffmpeg.remote-image-fetcher-bounded` | remote graphics-engine images are fetched through `IRemoteImageFetcher` with a pooled `HttpClientFactory` client, a body-covering deadline, a wire-transfer size cap, and a decoder-enforced `DecoderOptions.MaxFrames` bound re-verified post-decode — never cached, re-fetched per element init. | 2026-07-20 | [link](../decisions.md#2026-07-20--remote-graphics-engine-images-are-fetched-through-a-bounded-pooled-iremoteimagefetcher-re-fetched-per-element-init-not-cached-511) |
|
||||
| `ffmpeg.work-ahead-slot-atomic` | `workAheadSegmenterLimit` is enforced by a single compare-exchange claim on a shared `WorkAheadSlots` pool taken by the *caller* of `Transcode`, which then passes ownership in and gets the release in `Transcode`'s `finally` — never a `Volatile.Read` compare in one place and an `Interlocked.Increment` in another. | 2026-07-21 | [link](../decisions.md#2026-07-21--work-ahead-slots-are-claimed-atomically-by-the-caller-released-by-the-transcode-it-hands-them-to-536) |
|
||||
| `ffmpeg.work-ahead-slot-release-never-negative` | `Release()` reads the count and compare-exchanges `current - 1` only when `current > 0`; a release against an empty pool records an unbalanced release and returns `false` **without ever writing a negative value**. It never decrements first and clamps afterward. The single caller (`HlsSessionWorker.Transcode`'s `finally`) logs a warning on the `false` return. | 2026-07-21 | [link](../decisions.md#2026-07-21--workaheadslotsrelease-clamps-before-decrementing-and-reports-unbalance-in-band-539) |
|
||||
| `graphics.channel-level-attachment` | A channel can attach `GraphicsElement`s directly via a new `ChannelGraphicsElement` join table (a base layer under deco/playout-item elements), and a built-in text element (`on-now-next.yml`) is seeded once per database so the On Now/Next overlay works out of the box. | 2026-07-22 | [link](../decisions.md#2026-07-22--channel-level-graphics-element-attachment--seeded-on-nownext-text-element-74) |
|
||||
| `graphics.channel-logo-caching` | An external `http(s)` channel-logo URL is fetched, decode-budget-validated, and stored in the image cache under a content-hash name at SAVE time — becoming byte-identical to an uploaded logo — so the render path never fetches a logo over HTTP; a bad URL fails the save with a 422 (BaseError → ValidationProblemDetails). | 2026-07-21 | [link](../decisions.md#2026-07-21--external-channel-logo-urls-are-downloaded-and-cached-at-save-time-the-render-path-never-fetches-a-logo-525) |
|
||||
| `iptv.base-url` | An optional advertised base URL (`iptv.base_url`) is resolved centrally via a pure Core helper (`AdvertisedBaseUrl`) inside the two IPTV generation handlers (M3U + XMLTV); unset/malformed values fall back byte-identical to the request-derived host, and it's a new `iptv` settings group distinct from `ETV_BASE_URL` and out of scope for HDHomeRun. | 2026-07-16 | [link](../decisions.md#2026-07-16--optional-advertised-iptv-base-url-iptvbase_url-resolved-centrally-in-the-two-generators-340) |
|
||||
| `iptv.logo-drives-bug-preset` | One uploaded channel logo drives both the listing logo and the on-screen bug via a shared, seeded `ChannelLogo`-sourced watermark preset (`Channel Bug`), not new per-channel schema. | 2026-07-20 | [link](../decisions.md#2026-07-20--one-logo-drives-the-bug-via-a-shared-channellogo-preset-not-new-schema-67) |
|
||||
|
||||
@@ -68,6 +68,7 @@ Channel (1) ──< Playout (0..N per channel; ChannelPlayoutSource distinguishe
|
||||
| **Seasonal / date-conditional scheduling** (#73) | Holiday/seasonal channels are **not a separate feature** — they are the existing date predicate on `IAlternateScheduleItem`, implemented by `ProgramScheduleAlternate` (Classic) and `PlayoutTemplate` (Block), evaluated by `AlternateScheduleSelector.GetScheduleForDate` (first match by `Index`, catch-all last). **Leaving `StartYear`/`EndYear` empty makes the range repeat every year** — the "set once, works every December" switch; explicit years (required in pairs) mean a one-off window and disable wrap-around detection. Wrap-around (Nov→Feb) and invalid/leap dates (Feb 31) are handled. No *soft* prioritization primitive exists (binary first-match-wins); that ask belongs to #70's weighting work. See `channels.md` → "Recipe: seasonal / holiday programming" and `decisions.md` 2026-07-17. | `IAlternateScheduleItem`, `ProgramScheduleAlternate`, `PlayoutTemplate` | `/app/playouts/{id}/alternate-schedules`, `/app/playouts/{id}/templates` |
|
||||
| **Playback order** | How a schedule item's source(s) are sequenced (`PlaybackOrder`). Note three that are easily confused: **`Shuffle`** is Fisher–Yates over the flattened items, so airtime is implicitly proportional to collection size (a 200-episode show swamps a 20-episode one). **`ShuffleInOrder`** is a balanced shuffle (keyj) that pads sources to equal length with non-emitting spacers — it plays every item exactly once per cycle, so it prevents *clumping* but leaves airtime proportional to size; it is **not** fair-share. **`WeightedShuffle`** (#70) picks a *source* by smooth weighted round-robin then takes its next item, so each source's `Weight` is its share of airtime — equal weights (the default) mean equal airtime regardless of library size, with small sources looping. Classic engine only; rejected at the write path for playlist/block items. See `decisions.md` 2026-07-17. | `PlaybackOrder`, `MultiCollectionItem.Weight`, `MultiCollectionSmartItem.Weight` | `WeightedShuffle` is offered as a Playback Order **only** on classic schedule items whose source is a MultiCollection (`web/src/schedules/itemRules.ts`, #404); the per-source weights themselves are edited at `/app/multi-collections` |
|
||||
| **Watermark** | `ChannelWatermark` image overlay; attached at channel, schedule-item, block-item, deco, or playout-item level with position/size/opacity. | `ChannelWatermark`, `DecoWatermark`, `BlockItemWatermark`, `ProgramScheduleItemWatermark` | `/app/watermarks` |
|
||||
| **Graphics element** | YAML-authored (`Text`/`Image`/`Subtitle`/`Motion`/`Script`) render-engine overlay, distinct from the image-only `ChannelWatermark` system. Attaches via 5 parallel join tables: `PlayoutItemGraphicsElement`, `ProgramScheduleItemGraphicsElement`, `BlockItemGraphicsElement`, `DecoGraphicsElement`, and (#74) **`ChannelGraphicsElement`** — a direct `Channel`-level attachment that did not exist before #74. `GraphicsElementSelector.SelectGraphicsElements` treats channel-level elements as the final fall-through **base layer**: they merge with `Merge`-mode deco elements and per-playout-item elements, but a deco in `Override`/`Disable` mode returns before that fall-through and so **suppresses** the channel overlay (and on a **filler** item, a deco whose graphics-elements section is not set to run during filler — `UseGraphicsElementsDuringFiller` false — clears it too, for `Merge` and `Override` alike); `HttpLiveStreamingDirect` always returns empty (ErsatzTV isn't transcoding, so nothing can be burned in). A built-in seeded text element, `on-now-next.yml` (`GraphicsElementDefaults.OnNowNextFileName`), is written once (`GraphicsElementSeeder.SeedOnNowNext`, guarded by the `graphics.on_now_next_seeded` ConfigElement marker, adopt-not-clobber like the #67 watermark seed) and identified to API clients via a server-derived `GraphicsElementResponseModel.builtIn` flag (path-name comparison, not name matching). Edited per-channel at Channel editor → Branding → "Show On Now / Next overlay". See `decisions.md` → `graphics.channel-level-attachment` (#74). | `GraphicsElement`, `ChannelGraphicsElement` | `/app/edit-channel/{id}` (Branding tab); YAML files under `GraphicsElementsTextTemplatesFolder` etc. are not directly SPA-edited |
|
||||
| **Collection** | Manual list of media items (`CollectionItem`). | `Collection` | `/app/collections` |
|
||||
| **SmartCollection** | Saved search — a `Query` string, no static item list. | `SmartCollection` | `/app/collections` |
|
||||
| **MultiCollection** | Combines multiple `Collection`s and/or `SmartCollection`s (with grouping via `MultiCollectionItem`/`MultiCollectionSmartItem`). Both join entities carry a per-source `Weight` (default 1) used by `PlaybackOrder.WeightedShuffle` (#70) and ignored by every other order — the two are mirrors, so a change to one belongs on the other. The editor exposes a per-source weight input (1..1000, mirroring the API validator) with a computed % share and a "Reset to fair share" action; it round-trips `weight` from the GET because the PUT replaces the item list (#404). | `MultiCollection` | `/app/multi-collections` (#151, weight UI #404) |
|
||||
|
||||
@@ -0,0 +1,899 @@
|
||||
# On Now / Next Overlay (#74) Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add a per-channel toggle that burns a transient "On Now / Next" EPG text bug onto the transcoded stream at each program transition, reusing the existing SkiaSharp graphics engine.
|
||||
|
||||
**Architecture:** The graphics engine already renders dynamic EPG text per playout item. We add (1) a `ChannelGraphicsElement` join so a channel can carry graphics elements (the missing channel-level hook), (2) a seeded built-in `on-now-next.yml` text element, (3) a render-path change so the selector emits channel-level elements, (4) a channel REST field + a `builtIn` discriminator on graphics elements, and (5) a SPA Branding-tab toggle mirroring the existing logo-bug switch.
|
||||
|
||||
**Tech Stack:** C# / .NET 10, EF Core (dual-provider Sqlite + MySql), MediatR CQRS, LanguageExt, SkiaSharp + RichTextKit graphics engine, Scriban + NCalc templating, React + TypeScript SPA, NUnit + Shouldly + NSubstitute, generated OpenAPI (`v1.json` / `v1.d.ts`).
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Work only in the worktree `.claude/worktrees/issue-74-on-now-next-overlay` (branch `feat/74-on-now-next-overlay`, off `origin/main`). Never commit in `/Users/timothy/ersatztv`.
|
||||
- Any `TvContext` model change requires a dual-provider migration via `scripts/add-migration.sh <Name>` (generates BOTH Sqlite and MySql). Never hand-edit migrations. Never set `ETV_UPDATE_GOLDENS`.
|
||||
- Tests: NUnit + Shouldly + NSubstitute only (never xUnit). C# test DB harness is `InMemoryTvContext` (real SQLite `:memory:`, FKs OFF), constructed `_db = await InMemoryTvContext.CreateAsync();` then `await using TvContext context = _db.CreateContext();`.
|
||||
- Any `/api/*` change: build the app project FIRST, then `./scripts/update-openapi.sh`, then `cd web && npm run generate:api`. Commit regenerated `ErsatzTV/wwwroot/api/v1.json`, `web/src/api/generated/v1.d.ts`, and `docs/endpoint-index.md` in the same task.
|
||||
- Before any push touching `.cs`: BOM-check the touched set (`git diff --cached --name-only -- '*.cs' | while read f; do xxd -p -l3 "$f" | grep -q '^efbbbf' && echo "BOM: $f"; done`) and run the format gate under `bash -c`: `bash -c 'dotnet format whitespace ErsatzTV.sln --folder --include $(git diff --name-only origin/main -- "*.cs" | tr "\n" " ")'`.
|
||||
- Central Package Management: never add `Version=` to a `<PackageReference>`.
|
||||
- Text-element inline style markup uses SQUARE brackets with a backreferenced close: `[styleName]text[/styleName]`. `base_style` MUST name a style present in `styles:` or the renderer throws.
|
||||
- EPG template variable is `Epg` (a list); members are PascalCase: `Epg[0].Title`, `Epg[0].SubTitle`, `Epg[1].Title`, `Epg[1].Start` (`DateTimeOffset`). Now = `Epg[0]`, Next = `Epg[1]`.
|
||||
- `opacity_expression` is NCalc; time params (seconds, doubles): `content_seconds`, `content_total_seconds`, `channel_seconds`, `time_of_day_seconds`. Helper functions: `LinearFadeDuration(time, start, fadeSeconds, peakSeconds)` and `LinearFadePoints(time, start, peakStart, peakEnd, end)`. When `opacity_expression` is set, `opacity_percent` is ignored.
|
||||
- Built-in element identity is by filename constant `on-now-next.yml` (see Task 3 `GraphicsElementDefaults`), never by user-editable Name (the #67 lesson).
|
||||
|
||||
---
|
||||
|
||||
### Task 1: `ChannelGraphicsElement` join entity + EF config + dual-provider migration
|
||||
|
||||
**Files:**
|
||||
- Create: `ErsatzTV.Core/Domain/ChannelGraphicsElement.cs`
|
||||
- Modify: `ErsatzTV.Core/Domain/Channel.cs` (add navs)
|
||||
- Modify: `ErsatzTV.Core/Domain/GraphicsElement.cs` (add navs)
|
||||
- Modify: `ErsatzTV.Infrastructure/Data/Configurations/ChannelConfiguration.cs` (add M2M)
|
||||
- Create (generated): `ErsatzTV.Infrastructure.Sqlite/Migrations/*_Add_ChannelGraphicsElement.cs` + `ErsatzTV.Infrastructure.MySql/Migrations/*_Add_ChannelGraphicsElement.cs`
|
||||
- Test: `ErsatzTV.Tests/Infrastructure/ChannelGraphicsElementPersistenceTests.cs`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `ChannelGraphicsElement { int ChannelId; Channel Channel; int GraphicsElementId; GraphicsElement GraphicsElement }`; `Channel.ChannelGraphicsElements : List<ChannelGraphicsElement>`; `Channel.GraphicsElements : List<GraphicsElement>`; `GraphicsElement.ChannelGraphicsElements : List<ChannelGraphicsElement>`; `GraphicsElement.Channels : List<Channel>`.
|
||||
|
||||
- [ ] **Step 1: Write the failing persistence test**
|
||||
|
||||
Create `ErsatzTV.Tests/Infrastructure/ChannelGraphicsElementPersistenceTests.cs`:
|
||||
|
||||
```csharp
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Infrastructure;
|
||||
|
||||
[TestFixture]
|
||||
public class ChannelGraphicsElementPersistenceTests
|
||||
{
|
||||
private InMemoryTvContext _db = null!;
|
||||
|
||||
[SetUp]
|
||||
public async Task SetUp() => _db = await InMemoryTvContext.CreateAsync();
|
||||
|
||||
[TearDown]
|
||||
public async Task TearDown() => await _db.DisposeAsync();
|
||||
|
||||
[Test]
|
||||
public async Task Channel_Can_Attach_And_Read_Back_A_Graphics_Element()
|
||||
{
|
||||
int channelId;
|
||||
int elementId;
|
||||
|
||||
await using (TvContext context = _db.CreateContext())
|
||||
{
|
||||
var profile = new FFmpegProfile { Name = "p" };
|
||||
await context.FFmpegProfiles.AddAsync(profile);
|
||||
var element = new GraphicsElement { Path = "/x/on-now-next.yml", Name = "On Now / Next", Kind = GraphicsElementKind.Text };
|
||||
await context.GraphicsElements.AddAsync(element);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var channel = new Channel(Guid.NewGuid())
|
||||
{
|
||||
Number = "1", Name = "c", Group = "g", FFmpegProfileId = profile.Id,
|
||||
ChannelGraphicsElements = [new ChannelGraphicsElement { GraphicsElementId = element.Id }]
|
||||
};
|
||||
await context.Channels.AddAsync(channel);
|
||||
await context.SaveChangesAsync();
|
||||
channelId = channel.Id;
|
||||
elementId = element.Id;
|
||||
}
|
||||
|
||||
await using (TvContext context = _db.CreateContext())
|
||||
{
|
||||
Channel loaded = await context.Channels
|
||||
.Include(c => c.ChannelGraphicsElements)
|
||||
.ThenInclude(x => x.GraphicsElement)
|
||||
.SingleAsync(c => c.Id == channelId);
|
||||
|
||||
loaded.ChannelGraphicsElements.Count.ShouldBe(1);
|
||||
loaded.ChannelGraphicsElements[0].GraphicsElementId.ShouldBe(elementId);
|
||||
loaded.ChannelGraphicsElements[0].GraphicsElement.Name.ShouldBe("On Now / Next");
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run it to verify it fails to compile**
|
||||
|
||||
Run: `dotnet build ErsatzTV.Tests`
|
||||
Expected: FAIL — `ChannelGraphicsElement` / `Channel.ChannelGraphicsElements` do not exist.
|
||||
|
||||
- [ ] **Step 3: Create the join entity**
|
||||
|
||||
`ErsatzTV.Core/Domain/ChannelGraphicsElement.cs`:
|
||||
|
||||
```csharp
|
||||
namespace ErsatzTV.Core.Domain;
|
||||
|
||||
public class ChannelGraphicsElement
|
||||
{
|
||||
public int ChannelId { get; set; }
|
||||
public Channel Channel { get; set; }
|
||||
public int GraphicsElementId { get; set; }
|
||||
public GraphicsElement GraphicsElement { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add navs to `Channel` and `GraphicsElement`**
|
||||
|
||||
In `ErsatzTV.Core/Domain/Channel.cs`, alongside `public List<Artwork> Artwork { get; set; }`:
|
||||
|
||||
```csharp
|
||||
public List<GraphicsElement> GraphicsElements { get; set; }
|
||||
public List<ChannelGraphicsElement> ChannelGraphicsElements { get; set; }
|
||||
```
|
||||
|
||||
In `ErsatzTV.Core/Domain/GraphicsElement.cs`, alongside the `Decos` / `DecoGraphicsElements` navs:
|
||||
|
||||
```csharp
|
||||
public List<Channel> Channels { get; set; }
|
||||
public List<ChannelGraphicsElement> ChannelGraphicsElements { get; set; }
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Declare the M2M in `ChannelConfiguration`**
|
||||
|
||||
In `ErsatzTV.Infrastructure/Data/Configurations/ChannelConfiguration.cs`, inside `Configure`, after the `MirrorSourceChannel` block, add (mirrors `DecoConfiguration`'s `.UsingEntity<DecoGraphicsElement>`):
|
||||
|
||||
```csharp
|
||||
builder.HasMany(c => c.GraphicsElements)
|
||||
.WithMany(m => m.Channels)
|
||||
.UsingEntity<ChannelGraphicsElement>(
|
||||
j => j.HasOne(ci => ci.GraphicsElement)
|
||||
.WithMany(mi => mi.ChannelGraphicsElements)
|
||||
.HasForeignKey(ci => ci.GraphicsElementId)
|
||||
.OnDelete(DeleteBehavior.Cascade),
|
||||
j => j.HasOne(ci => ci.Channel)
|
||||
.WithMany(c => c.ChannelGraphicsElements)
|
||||
.HasForeignKey(ci => ci.ChannelId)
|
||||
.OnDelete(DeleteBehavior.Cascade),
|
||||
j => j.HasKey(ci => new { ci.ChannelId, ci.GraphicsElementId }));
|
||||
```
|
||||
|
||||
Note: `ChannelConfiguration.cs` starts with a UTF-8 BOM already; preserve it (do not strip), and BOM-check per Global Constraints before pushing.
|
||||
|
||||
- [ ] **Step 6: Generate the dual-provider migration**
|
||||
|
||||
Run: `scripts/add-migration.sh Add_ChannelGraphicsElement`
|
||||
Expected: two new migration files (Sqlite + MySql) creating a `ChannelGraphicsElement` table with composite PK `(ChannelId, GraphicsElementId)` and two cascade FKs. Inspect both to confirm the table + FKs; no other model drift.
|
||||
|
||||
- [ ] **Step 7: Run the test to verify it passes**
|
||||
|
||||
Run: `dotnet test ErsatzTV.Tests --filter FullyQualifiedName~ChannelGraphicsElementPersistenceTests`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add ErsatzTV.Core/Domain/ChannelGraphicsElement.cs ErsatzTV.Core/Domain/Channel.cs ErsatzTV.Core/Domain/GraphicsElement.cs \
|
||||
ErsatzTV.Infrastructure/Data/Configurations/ChannelConfiguration.cs \
|
||||
ErsatzTV.Infrastructure.Sqlite/Migrations ErsatzTV.Infrastructure.MySql/Migrations \
|
||||
ErsatzTV.Tests/Infrastructure/ChannelGraphicsElementPersistenceTests.cs
|
||||
git -c core.hooksPath=/dev/null commit -m "feat(74): add ChannelGraphicsElement join + dual-provider migration"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Render-path — selector emits channel-level graphics elements
|
||||
|
||||
**Files:**
|
||||
- Modify: `ErsatzTV.Core/FFmpeg/GraphicsElementSelector.cs` (append channel elements at the fall-through)
|
||||
- Modify: `ErsatzTV.Application/Streaming/Queries/FFmpegProcessHandler.cs` (eager-load the join on the streaming channel)
|
||||
- Test: `ErsatzTV.Tests/Core/FFmpeg/GraphicsElementSelectorTests.cs`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `Channel.ChannelGraphicsElements` (Task 1); `IGraphicsElementSelector.SelectGraphicsElements(Channel, PlayoutItem, DateTimeOffset)`.
|
||||
- Produces: channel-level `PlayoutItemGraphicsElement`s appended as a base layer (below deco/playout precedence).
|
||||
|
||||
- [ ] **Step 1: Write the failing selector tests**
|
||||
|
||||
Create `ErsatzTV.Tests/Core/FFmpeg/GraphicsElementSelectorTests.cs`. `DecoEntries` is `record(Option<Deco> TemplateDeco, Option<Deco> PlayoutDeco)`; the selector iterates each Option. Use `NSubstitute` for `IDecoSelector` and `NullLogger`.
|
||||
|
||||
```csharp
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.Domain.Scheduling;
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using LanguageExt;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Core.FFmpeg;
|
||||
|
||||
[TestFixture]
|
||||
public class GraphicsElementSelectorTests
|
||||
{
|
||||
private static (GraphicsElementSelector sel, IDecoSelector deco) Build(DecoEntries entries)
|
||||
{
|
||||
var deco = Substitute.For<IDecoSelector>();
|
||||
deco.GetDecoEntries(Arg.Any<Playout>(), Arg.Any<DateTimeOffset>()).Returns(entries);
|
||||
return (new GraphicsElementSelector(deco, NullLogger<GraphicsElementSelector>.Instance), deco);
|
||||
}
|
||||
|
||||
private static Channel ChannelWithElement(int elementId, StreamingMode mode = StreamingMode.HttpLiveStreaming)
|
||||
{
|
||||
var element = new GraphicsElement { Id = elementId, Path = "/x/on-now-next.yml", Kind = GraphicsElementKind.Text };
|
||||
return new Channel(Guid.NewGuid())
|
||||
{
|
||||
StreamingMode = mode,
|
||||
ChannelGraphicsElements = [new ChannelGraphicsElement { GraphicsElementId = elementId, GraphicsElement = element }]
|
||||
};
|
||||
}
|
||||
|
||||
private static PlayoutItem Item() => new()
|
||||
{
|
||||
Playout = new Playout(),
|
||||
FillerKind = FillerKind.None,
|
||||
PlayoutItemGraphicsElements = []
|
||||
};
|
||||
|
||||
[Test]
|
||||
public void Channel_Element_Is_Emitted_When_No_Deco()
|
||||
{
|
||||
(GraphicsElementSelector sel, _) = Build(new DecoEntries(Option<Deco>.None, Option<Deco>.None));
|
||||
|
||||
List<PlayoutItemGraphicsElement> result = sel.SelectGraphicsElements(ChannelWithElement(7), Item(), DateTimeOffset.Now);
|
||||
|
||||
result.Count.ShouldBe(1);
|
||||
result[0].GraphicsElement.Id.ShouldBe(7);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Channel_Element_Is_Suppressed_On_HlsDirect()
|
||||
{
|
||||
(GraphicsElementSelector sel, _) = Build(new DecoEntries(Option<Deco>.None, Option<Deco>.None));
|
||||
|
||||
List<PlayoutItemGraphicsElement> result = sel.SelectGraphicsElements(
|
||||
ChannelWithElement(7, StreamingMode.HttpLiveStreamingDirect), Item(), DateTimeOffset.Now);
|
||||
|
||||
result.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Channel_Element_Is_Suppressed_By_Disable_Deco()
|
||||
{
|
||||
var deco = new Deco { GraphicsElementsMode = DecoMode.Disable, DecoGraphicsElements = [] };
|
||||
(GraphicsElementSelector sel, _) = Build(new DecoEntries(deco, Option<Deco>.None));
|
||||
|
||||
List<PlayoutItemGraphicsElement> result = sel.SelectGraphicsElements(ChannelWithElement(7), Item(), DateTimeOffset.Now);
|
||||
|
||||
result.ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify it fails**
|
||||
|
||||
Run: `dotnet test ErsatzTV.Tests --filter FullyQualifiedName~GraphicsElementSelectorTests`
|
||||
Expected: FAIL — `Channel_Element_Is_Emitted_When_No_Deco` returns 0 (selector never reads `channel`).
|
||||
|
||||
- [ ] **Step 3: Append channel elements in the selector**
|
||||
|
||||
In `ErsatzTV.Core/FFmpeg/GraphicsElementSelector.cs`, replace the final two lines:
|
||||
|
||||
```csharp
|
||||
result.AddRange(playoutItem.PlayoutItemGraphicsElements);
|
||||
|
||||
return result;
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```csharp
|
||||
result.AddRange(playoutItem.PlayoutItemGraphicsElements);
|
||||
|
||||
// channel-level overlays are a base layer: merged with playout-item / Merge-deco elements,
|
||||
// but suppressed by a deco in Override/Disable mode (which returns before reaching here).
|
||||
if (channel.ChannelGraphicsElements is not null)
|
||||
{
|
||||
result.AddRange(
|
||||
channel.ChannelGraphicsElements.Map(cge =>
|
||||
new PlayoutItemGraphicsElement { PlayoutItem = playoutItem, GraphicsElement = cge.GraphicsElement }));
|
||||
}
|
||||
|
||||
return result;
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run to verify it passes**
|
||||
|
||||
Run: `dotnet test ErsatzTV.Tests --filter FullyQualifiedName~GraphicsElementSelectorTests`
|
||||
Expected: PASS (all three).
|
||||
|
||||
- [ ] **Step 5: Eager-load the join on the streaming channel path**
|
||||
|
||||
In `ErsatzTV.Application/Streaming/Queries/FFmpegProcessHandler.cs`, in `ChannelMustExist`, add after `.Include(c => c.Watermark)`:
|
||||
|
||||
```csharp
|
||||
.Include(c => c.ChannelGraphicsElements)
|
||||
.ThenInclude(x => x.GraphicsElement)
|
||||
```
|
||||
|
||||
(No other Channel-load site needs it: `GetHlsPlaylistByChannelNumberHandler` doesn't build the graphics graph, and troubleshooting playback re-enters through `FFmpegProcessHandler`.)
|
||||
|
||||
- [ ] **Step 6: Build to confirm the include compiles**
|
||||
|
||||
Run: `dotnet build ErsatzTV.Application`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add ErsatzTV.Core/FFmpeg/GraphicsElementSelector.cs \
|
||||
ErsatzTV.Application/Streaming/Queries/FFmpegProcessHandler.cs \
|
||||
ErsatzTV.Tests/Core/FFmpeg/GraphicsElementSelectorTests.cs
|
||||
git -c core.hooksPath=/dev/null commit -m "feat(74): selector emits channel-level graphics elements as a base layer"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Seed the built-in `on-now-next.yml` template (file + marker)
|
||||
|
||||
**Files:**
|
||||
- Create: `ErsatzTV.Core/Graphics/GraphicsElementDefaults.cs` (shared filename const)
|
||||
- Modify: `ErsatzTV.Core/Domain/ConfigElementKey.cs` (new marker key)
|
||||
- Create: `ErsatzTV.Infrastructure/Streaming/Graphics/GraphicsElementSeeder.cs` (the YAML const + seed logic, `IFileSystem`-based)
|
||||
- Modify: `ErsatzTV/Services/RunOnce/DatabaseMigratorService.cs` (invoke the seeder before `DatabaseIsReady`)
|
||||
- Test: `ErsatzTV.Tests/Infrastructure/GraphicsElementSeederTests.cs`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `GraphicsElementDefaults.OnNowNextFileName = "on-now-next.yml"`; `ConfigElementKey.GraphicsOnNowNextSeeded` (`"graphics.on_now_next_seeded"`); `GraphicsElementSeeder.SeedOnNowNext(TvContext, IFileSystem, CancellationToken)`.
|
||||
- Row creation is intentionally NOT done here — the existing `RefreshGraphicsElementsHandler` (already tested, single owner of `GraphicsElement` rows) creates the row from the on-disk file at startup. The seeder only materializes the file + marker (marker-gated, adopt-not-clobber, no resurrection after delete), following #67.
|
||||
|
||||
- [ ] **Step 1: Write the failing seeder tests**
|
||||
|
||||
Create `ErsatzTV.Tests/Infrastructure/GraphicsElementSeederTests.cs`. Uses the repo's `Testably.Abstractions.Testing.MockFileSystem` (implements `System.IO.Abstractions.IFileSystem` — standard `fs.File.*` / `fs.Directory.*` surface; NOT the `AddFile`/`MockFileData`/`FileExists` API of `System.IO.Abstractions.TestingHelpers`) and `InMemoryTvContext`. The seeded path is `FileSystemLayout.GraphicsElementsTextTemplatesFolder + "/on-now-next.yml"`.
|
||||
|
||||
```csharp
|
||||
using System.IO.Abstractions;
|
||||
using Testably.Abstractions.Testing;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Streaming.Graphics;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Infrastructure;
|
||||
|
||||
[TestFixture]
|
||||
public class GraphicsElementSeederTests
|
||||
{
|
||||
private InMemoryTvContext _db = null!;
|
||||
private string _seededPath = null!;
|
||||
|
||||
[SetUp]
|
||||
public async Task SetUp()
|
||||
{
|
||||
_db = await InMemoryTvContext.CreateAsync();
|
||||
_seededPath = Path.Combine(FileSystemLayout.GraphicsElementsTextTemplatesFolder, "on-now-next.yml");
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public async Task TearDown() => await _db.DisposeAsync();
|
||||
|
||||
[Test]
|
||||
public async Task Seeds_File_And_Marker_When_Absent()
|
||||
{
|
||||
var fs = new MockFileSystem();
|
||||
await using TvContext context = _db.CreateContext();
|
||||
|
||||
await GraphicsElementSeeder.SeedOnNowNext(context, fs, CancellationToken.None);
|
||||
|
||||
fs.File.Exists(_seededPath).ShouldBeTrue();
|
||||
fs.File.ReadAllText(_seededPath).ShouldContain("epg_entries: 2");
|
||||
(await context.ConfigElements.AnyAsync(c => c.Key == ConfigElementKey.GraphicsOnNowNextSeeded.Key)).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Adopts_Existing_File_Untouched()
|
||||
{
|
||||
var fs = new MockFileSystem();
|
||||
fs.Directory.CreateDirectory(FileSystemLayout.GraphicsElementsTextTemplatesFolder);
|
||||
await fs.File.WriteAllTextAsync(_seededPath, "name: Operator Custom\nepg_entries: 2\n");
|
||||
await using TvContext context = _db.CreateContext();
|
||||
|
||||
await GraphicsElementSeeder.SeedOnNowNext(context, fs, CancellationToken.None);
|
||||
|
||||
fs.File.ReadAllText(_seededPath).ShouldContain("Operator Custom");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Does_Not_Resurrect_After_Delete()
|
||||
{
|
||||
var fs = new MockFileSystem();
|
||||
await using TvContext context = _db.CreateContext();
|
||||
|
||||
await GraphicsElementSeeder.SeedOnNowNext(context, fs, CancellationToken.None);
|
||||
fs.File.Delete(_seededPath);
|
||||
await GraphicsElementSeeder.SeedOnNowNext(context, fs, CancellationToken.None);
|
||||
|
||||
fs.File.Exists(_seededPath).ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify it fails to compile**
|
||||
|
||||
Run: `dotnet build ErsatzTV.Tests`
|
||||
Expected: FAIL — `GraphicsElementSeeder` / `ConfigElementKey.GraphicsOnNowNextSeeded` do not exist.
|
||||
|
||||
- [ ] **Step 3: Add the shared filename const**
|
||||
|
||||
`ErsatzTV.Core/Graphics/GraphicsElementDefaults.cs`:
|
||||
|
||||
```csharp
|
||||
namespace ErsatzTV.Core.Graphics;
|
||||
|
||||
public static class GraphicsElementDefaults
|
||||
{
|
||||
// Built-in "On Now / Next" text element; identity is by filename, never by user-editable Name.
|
||||
public const string OnNowNextFileName = "on-now-next.yml";
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add the ConfigElement marker key**
|
||||
|
||||
In `ErsatzTV.Core/Domain/ConfigElementKey.cs`, next to `WatermarkChannelBugSeeded`:
|
||||
|
||||
```csharp
|
||||
public static ConfigElementKey GraphicsOnNowNextSeeded => new("graphics.on_now_next_seeded");
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Create the seeder (YAML const + logic)**
|
||||
|
||||
`ErsatzTV.Infrastructure/Streaming/Graphics/GraphicsElementSeeder.cs`. The YAML is authored from the field grammar (Global Constraints): square-bracket style spans, `Epg[0]`/`Epg[1]`, `LinearFadeDuration`, and a Scriban guard so a missing NEXT entry renders nothing. `base_style: now` names a real style.
|
||||
|
||||
```csharp
|
||||
using System.IO.Abstractions;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Graphics;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Streaming.Graphics;
|
||||
|
||||
public static class GraphicsElementSeeder
|
||||
{
|
||||
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)"
|
||||
base_style: now
|
||||
styles:
|
||||
- name: now
|
||||
font_size: 30
|
||||
font_weight: 700
|
||||
text_color: "#FFFFFF"
|
||||
halo_color: "#000000"
|
||||
halo_width: 2
|
||||
- name: sub
|
||||
font_size: 22
|
||||
font_weight: 400
|
||||
text_color: "#DDDDDD"
|
||||
halo_color: "#000000"
|
||||
halo_width: 2
|
||||
- name: next
|
||||
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 Epg.size > 1 }}[next]NEXT {{ Epg[1].Title }} · {{ format_datetime (convert_timezone Epg[1].Start) "h:mm tt" }}[/next]{{ end }}
|
||||
""";
|
||||
|
||||
public static async Task SeedOnNowNext(TvContext context, IFileSystem fileSystem, CancellationToken cancellationToken)
|
||||
{
|
||||
string seededKey = ConfigElementKey.GraphicsOnNowNextSeeded.Key;
|
||||
bool alreadySeeded = await context.ConfigElements.AnyAsync(c => c.Key == seededKey, cancellationToken);
|
||||
if (alreadySeeded)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string folder = FileSystemLayout.GraphicsElementsTextTemplatesFolder;
|
||||
string target = fileSystem.Path.Combine(folder, GraphicsElementDefaults.OnNowNextFileName);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Run the seeder tests to verify they pass**
|
||||
|
||||
Run: `dotnet test ErsatzTV.Tests --filter FullyQualifiedName~GraphicsElementSeederTests`
|
||||
Expected: PASS (all three).
|
||||
|
||||
- [ ] **Step 7: Invoke the seeder at startup**
|
||||
|
||||
In `ErsatzTV/Services/RunOnce/DatabaseMigratorService.cs`, resolve `IFileSystem` from the scope and call the seeder between `DbInitializer.Initialize` and `DatabaseIsReady`:
|
||||
|
||||
```csharp
|
||||
_logger.LogInformation("Initializing database");
|
||||
await DbInitializer.Initialize(dbContext, stoppingToken);
|
||||
|
||||
var fileSystem = scope.ServiceProvider.GetRequiredService<System.IO.Abstractions.IFileSystem>();
|
||||
await GraphicsElementSeeder.SeedOnNowNext(dbContext, fileSystem, stoppingToken);
|
||||
|
||||
_systemStartup.DatabaseIsReady();
|
||||
```
|
||||
|
||||
Add `using ErsatzTV.Infrastructure.Streaming.Graphics;` at the top. (`IFileSystem` is already DI-registered — `RefreshGraphicsElementsHandler` consumes it. The seeder runs before `DatabaseIsReady`, hence before `SchedulerService` queues `RefreshGraphicsElements`, so the file is present when the refresh creates the row.)
|
||||
|
||||
- [ ] **Step 8: Build to confirm startup wiring compiles**
|
||||
|
||||
Run: `dotnet build ErsatzTV`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 9: Commit**
|
||||
|
||||
```bash
|
||||
git add ErsatzTV.Core/Graphics/GraphicsElementDefaults.cs ErsatzTV.Core/Domain/ConfigElementKey.cs \
|
||||
ErsatzTV.Infrastructure/Streaming/Graphics/GraphicsElementSeeder.cs \
|
||||
ErsatzTV/Services/RunOnce/DatabaseMigratorService.cs \
|
||||
ErsatzTV.Tests/Infrastructure/GraphicsElementSeederTests.cs
|
||||
git -c core.hooksPath=/dev/null commit -m "feat(74): seed built-in on-now-next.yml text element (file + marker)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: API — channel `graphicsElementIds` + graphics `builtIn` + OpenAPI regen
|
||||
|
||||
**Files:**
|
||||
- Modify: `ErsatzTV.Core/Api/Channels/ChannelDetailResponseModel.cs` (add `int[] GraphicsElementIds`)
|
||||
- Modify: `ErsatzTV/Controllers/Api/Requests/UpdateChannelRequest.cs` (add `List<int> GraphicsElementIds` + thread through `ToCommand`)
|
||||
- Modify: `ErsatzTV.Application/Channels/Commands/UpdateChannel.cs` (add `List<int> GraphicsElementIds`)
|
||||
- Modify: `ErsatzTV.Application/Channels/Commands/UpdateChannelHandler.cs` (include + reconcile)
|
||||
- Modify: `ErsatzTV.Infrastructure/Data/Repositories/ChannelRepository.cs` (include on `GetChannel`)
|
||||
- Modify: `ErsatzTV.Application/Channels/Mapper.cs` (project ids)
|
||||
- Modify: `ErsatzTV.Core/Api/Graphics/GraphicsElementResponseModel.cs` (add `bool BuiltIn`)
|
||||
- Modify: `ErsatzTV.Application/Graphics/Queries/GetAllGraphicsElementsForApiHandler.cs` (derive `builtIn`)
|
||||
- Generated: `ErsatzTV/wwwroot/api/v1.json`, `web/src/api/generated/v1.d.ts`, `docs/endpoint-index.md`
|
||||
- Test: `ErsatzTV.Tests/Application/Channels/UpdateChannelGraphicsElementsTests.cs`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `Channel.ChannelGraphicsElements` (Task 1); `GraphicsElementDefaults.OnNowNextFileName` (Task 3).
|
||||
- Produces: DTO field `GraphicsElementIds` on channel detail + update; `BuiltIn` on `GraphicsElementResponseModel`.
|
||||
|
||||
- [ ] **Step 1: Write the failing reconcile test**
|
||||
|
||||
Create `ErsatzTV.Tests/Application/Channels/UpdateChannelGraphicsElementsTests.cs`. Drive `UpdateChannelHandler.Handle` against `InMemoryTvContext` with a channel + two graphics elements; assert the join set is reconciled (add then remove). Construct the handler with the same collaborators the existing `UpdateChannelHandler` tests use — read `ErsatzTV.Tests/Application/Channels/` for the existing setup (an `IDbContextFactory<TvContext>` over `_db`, `ISearchTargets` substitute, channel-data refresh substitutes). Assertion core:
|
||||
|
||||
```csharp
|
||||
// after sending UpdateChannel with GraphicsElementIds = [elementA.Id]
|
||||
Channel reloaded = await context.Channels.Include(c => c.ChannelGraphicsElements)
|
||||
.SingleAsync(c => c.Id == channelId);
|
||||
reloaded.ChannelGraphicsElements.Select(x => x.GraphicsElementId).ShouldBe(new[] { elementAId });
|
||||
|
||||
// after sending UpdateChannel with GraphicsElementIds = [] (remove)
|
||||
reloaded.ChannelGraphicsElements.ShouldBeEmpty();
|
||||
```
|
||||
|
||||
If no `UpdateChannelHandler` test exists to copy the harness from, construct the factory as `new PooledDbContextFactory<TvContext>(_db.Options)`-equivalent used elsewhere in `ErsatzTV.Tests/Application/` (grep for `IDbContextFactory<TvContext>` test usages) and substitute the other ctor deps with `Substitute.For<...>()`.
|
||||
|
||||
- [ ] **Step 2: Run to verify it fails**
|
||||
|
||||
Run: `dotnet build ErsatzTV.Tests`
|
||||
Expected: FAIL — `UpdateChannel.GraphicsElementIds` does not exist.
|
||||
|
||||
- [ ] **Step 3: Add `GraphicsElementIds` to the command + request DTO**
|
||||
|
||||
In `ErsatzTV.Application/Channels/Commands/UpdateChannel.cs`, add a final positional parameter:
|
||||
|
||||
```csharp
|
||||
bool ShowInEpg,
|
||||
List<int> GraphicsElementIds) : IRequest<Either<BaseError, ChannelViewModel>>;
|
||||
```
|
||||
|
||||
In `ErsatzTV/Controllers/Api/Requests/UpdateChannelRequest.cs`, add the matching final record parameter `List<int> GraphicsElementIds` and pass it as the final `ToCommand` argument (`GraphicsElementIds ?? []`).
|
||||
|
||||
- [ ] **Step 4: Reconcile the join in `UpdateChannelHandler`**
|
||||
|
||||
In `ErsatzTV.Application/Channels/Commands/UpdateChannelHandler.cs`, add to the channel load in `Handle`:
|
||||
|
||||
```csharp
|
||||
.Include(c => c.ChannelGraphicsElements)
|
||||
```
|
||||
|
||||
In `ApplyUpdateRequest`, immediately before `await dbContext.SaveChangesAsync(cancellationToken);`, reconcile (mirrors the artwork add/remove block):
|
||||
|
||||
```csharp
|
||||
c.ChannelGraphicsElements ??= [];
|
||||
var desired = update.GraphicsElementIds?.Distinct().ToList() ?? [];
|
||||
c.ChannelGraphicsElements.RemoveAll(cge => !desired.Contains(cge.GraphicsElementId));
|
||||
foreach (int id in desired.Where(id => c.ChannelGraphicsElements.All(cge => cge.GraphicsElementId != id)))
|
||||
{
|
||||
c.ChannelGraphicsElements.Add(new ChannelGraphicsElement { ChannelId = c.Id, GraphicsElementId = id });
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Include the join on the detail read + project ids**
|
||||
|
||||
In `ErsatzTV.Infrastructure/Data/Repositories/ChannelRepository.cs` `GetChannel`, add before `.OrderBy(c => c.Id)`:
|
||||
|
||||
```csharp
|
||||
.Include(c => c.ChannelGraphicsElements)
|
||||
.ThenInclude(x => x.GraphicsElement)
|
||||
```
|
||||
|
||||
In `ErsatzTV.Application/Channels/Mapper.cs` `ProjectToDetailResponseModel`, add the final constructor argument:
|
||||
|
||||
```csharp
|
||||
channel.ShowInEpg,
|
||||
channel.ChannelGraphicsElements?.Map(x => x.GraphicsElementId).ToArray() ?? []);
|
||||
```
|
||||
|
||||
In `ErsatzTV.Core/Api/Channels/ChannelDetailResponseModel.cs`, add the final record parameter:
|
||||
|
||||
```csharp
|
||||
bool ShowInEpg,
|
||||
int[] GraphicsElementIds);
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Add `BuiltIn` to the graphics-element DTO + handler**
|
||||
|
||||
In `ErsatzTV.Core/Api/Graphics/GraphicsElementResponseModel.cs`:
|
||||
|
||||
```csharp
|
||||
public record GraphicsElementResponseModel(int Id, string Name, bool BuiltIn);
|
||||
```
|
||||
|
||||
In `ErsatzTV.Application/Graphics/Queries/GetAllGraphicsElementsForApiHandler.cs`, derive `builtIn` from the entity's filename (keep the existing VM ordering; carry the entity alongside the VM):
|
||||
|
||||
```csharp
|
||||
return graphicsElements
|
||||
.Select(e => new
|
||||
{
|
||||
Vm = ProjectToViewModel(e),
|
||||
BuiltIn = Path.GetFileName(e.Path) == GraphicsElementDefaults.OnNowNextFileName
|
||||
})
|
||||
.OrderBy(x => x.Vm.Name == x.Vm.FileName)
|
||||
.ThenBy(x => x.Vm.Name)
|
||||
.Select(x => new GraphicsElementResponseModel(x.Vm.Id, x.Vm.Name, x.BuiltIn))
|
||||
.ToList();
|
||||
```
|
||||
|
||||
Add `using ErsatzTV.Core.Graphics;`.
|
||||
|
||||
- [ ] **Step 7: Run the reconcile test to verify it passes**
|
||||
|
||||
Run: `dotnet test ErsatzTV.Tests --filter FullyQualifiedName~UpdateChannelGraphicsElementsTests`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 8: Regenerate OpenAPI artifacts**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
dotnet build ErsatzTV
|
||||
./scripts/update-openapi.sh
|
||||
cd web && npm run generate:api && cd ..
|
||||
```
|
||||
Expected: `v1.json`, `web/src/api/generated/v1.d.ts`, `docs/endpoint-index.md` now show `graphicsElementIds` on the channel models and `builtIn` on `GraphicsElementResponseModel`. Confirm `git diff --stat` lists exactly those generated files.
|
||||
|
||||
- [ ] **Step 9: Update `docs/api-conventions.md` checklist note**
|
||||
|
||||
Add a one-line entry noting the channel now carries `graphicsElementIds` (attached graphics elements) and graphics elements expose `builtIn`. Keep it consistent with the existing checklist style.
|
||||
|
||||
- [ ] **Step 10: Commit**
|
||||
|
||||
```bash
|
||||
git add ErsatzTV.Core/Api ErsatzTV/Controllers/Api/Requests/UpdateChannelRequest.cs \
|
||||
ErsatzTV.Application/Channels ErsatzTV.Application/Graphics \
|
||||
ErsatzTV.Infrastructure/Data/Repositories/ChannelRepository.cs \
|
||||
ErsatzTV/wwwroot/api/v1.json web/src/api/generated/v1.d.ts docs/endpoint-index.md docs/api-conventions.md \
|
||||
ErsatzTV.Tests/Application/Channels/UpdateChannelGraphicsElementsTests.cs
|
||||
git -c core.hooksPath=/dev/null commit -m "feat(74): channel graphicsElementIds + graphics builtIn; regen OpenAPI"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: SPA — Branding-tab "Show On Now / Next overlay" toggle
|
||||
|
||||
**Files:**
|
||||
- Create: `web/src/api/graphicsElements.ts` (`findBuiltInOnNowNext` helper)
|
||||
- Modify: `web/src/screens/ChannelEditScreen.tsx` (load elements into ReferenceData; draft field; the Switch)
|
||||
- Test: `web/src/screens/ChannelEditScreen.test.tsx` (or a focused component test if the harness has one; else document manual verification)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: generated types now carry `graphicsElementIds` and `GraphicsElementResponseModel.builtIn` (Task 4); `getGraphicsElements()` in `web/src/api/pickers.ts`.
|
||||
|
||||
- [ ] **Step 1: Add the built-in finder helper**
|
||||
|
||||
`web/src/api/graphicsElements.ts` (mirrors `findLogoBugWatermark`):
|
||||
|
||||
```ts
|
||||
import type { components } from './generated/v1';
|
||||
|
||||
export type GraphicsElement = components['schemas']['GraphicsElementResponseModel'];
|
||||
|
||||
// Prefer the server-declared built-in flag; never match on the user-editable name.
|
||||
export function findBuiltInOnNowNext(elements: GraphicsElement[]): GraphicsElement | null {
|
||||
return elements.find((e) => e.builtIn) ?? null;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Load graphics elements into `ReferenceData`**
|
||||
|
||||
In `web/src/screens/ChannelEditScreen.tsx`: add `getGraphicsElements` (from `../api/pickers`) to the reference-data `Promise.all`, add `graphicsElements` to the `ReferenceData` type, and pass it into `BrandingPane` via the existing `data` prop.
|
||||
|
||||
- [ ] **Step 3: Thread the draft field**
|
||||
|
||||
In `draftFromChannel`, add `graphicsElementIds: channel.graphicsElementIds ?? []`. (The generated `UpdateChannelRequest`/`Channel` types now include the field.) `dirty` tracking (JSON.stringify) picks it up automatically.
|
||||
|
||||
- [ ] **Step 4: Add the toggle in `BrandingPane`**
|
||||
|
||||
After the logo-bug `Row`, add (uses `findBuiltInOnNowNext`; no live preview, static caption):
|
||||
|
||||
```tsx
|
||||
{(() => {
|
||||
const onNowNext = findBuiltInOnNowNext(data.graphicsElements);
|
||||
const ids = draft.graphicsElementIds ?? [];
|
||||
const enabled = onNowNext != null && ids.includes(onNowNext.id);
|
||||
return (
|
||||
<Row
|
||||
control={340}
|
||||
help={
|
||||
onNowNext == null
|
||||
? 'The built-in On Now / Next element is not available yet.'
|
||||
: 'Briefly shows the current and next program at each program change. Not shown in HLS Direct mode.'
|
||||
}
|
||||
label="Show On Now / Next overlay"
|
||||
>
|
||||
<Switch
|
||||
checked={enabled}
|
||||
disabled={hlsDirect || onNowNext == null}
|
||||
hideLabel
|
||||
label="Show On Now / Next overlay"
|
||||
onChange={(next) => {
|
||||
if (onNowNext == null) return;
|
||||
const without = ids.filter((id) => id !== onNowNext.id);
|
||||
set({ graphicsElementIds: next ? [...without, onNowNext.id] : without });
|
||||
}}
|
||||
size="sm"
|
||||
/>
|
||||
</Row>
|
||||
);
|
||||
})()}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Typecheck + build the SPA**
|
||||
|
||||
Run: `cd web && npm run build`
|
||||
Expected: PASS (no TS errors; `graphicsElementIds` and `builtIn` resolve against the regenerated types).
|
||||
|
||||
- [ ] **Step 6: Web test (if harness present)**
|
||||
|
||||
If `web/src/screens/ChannelEditScreen.test.tsx` exists, add a test that renders the Branding pane with a built-in element in `data.graphicsElements` and asserts toggling the switch adds/removes the id from the draft. Give heavy-render tests an explicit per-test timeout (e.g. `{ timeout: 15000 }`). Run: `cd web && npm run test -- ChannelEditScreen`. If no component-test harness exists for this screen, note that verification is covered by the live-E2E in Task 6 and skip.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add web/src/api/graphicsElements.ts web/src/screens/ChannelEditScreen.tsx web/src/screens/ChannelEditScreen.test.tsx
|
||||
git -c core.hooksPath=/dev/null commit -m "feat(74): channel Branding-tab On Now/Next overlay toggle"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Live-E2E verification + docs + decision records
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/domain-model.md`, `docs/channels.md`
|
||||
- Modify: `docs/decisions.md` (+ `docs/blazor-route-parity.md` only if a route changed — it did not)
|
||||
- No code (verification + docs)
|
||||
|
||||
- [ ] **Step 1: Boot a fresh local instance**
|
||||
|
||||
Run: `scripts/e2e-local.sh` (fresh config dir — do NOT reuse an existing one). Confirm startup logs show the graphics-element refresh created the built-in row (grep the log for `on-now-next.yml`).
|
||||
|
||||
- [ ] **Step 2: Confirm the built-in element is served**
|
||||
|
||||
Run: `curl -s localhost:<port>/api/v1/graphics-elements | jq '.[] | select(.builtIn==true)'`
|
||||
Expected: one element with `builtIn: true` named "On Now / Next".
|
||||
|
||||
- [ ] **Step 3: Enable the overlay on a channel with real playout**
|
||||
|
||||
`GET /api/v1/channels/{id}`, then `PUT` the same body with `graphicsElementIds` set to `[<builtInId>]`. Re-`GET` and confirm `graphicsElementIds` round-trips.
|
||||
|
||||
- [ ] **Step 4: Stream and visually confirm the burned-in bug**
|
||||
|
||||
Curl the channel's HLS (never a browser tab), pull a `.ts`/`.m4s` segment a few seconds after a program boundary, and extract a frame:
|
||||
```bash
|
||||
ffmpeg -i <segment> -frames:v 1 /tmp/on-now-next-frame.png
|
||||
```
|
||||
Open the PNG and confirm the NOW/NEXT text bug is rendered in the corner. Repeat across a program boundary to confirm the text updates. (This is a visual/operator check — a curl assertion cannot verify pixels.)
|
||||
|
||||
- [ ] **Step 5: Note the guide-cache caveat outcome**
|
||||
|
||||
If NOW/NEXT is wrong, check guide-cache freshness (`ChannelGuideCacheFolder/{number}.xml`). For a normal channel it should match; if on-demand/time-shift channels drift, file a scoped follow-up issue rather than expanding this PR.
|
||||
|
||||
- [ ] **Step 6: Update docs**
|
||||
|
||||
- `docs/domain-model.md`: document the channel-level graphics-element attachment (`ChannelGraphicsElement`) and where it's edited (Channel editor → Branding → "Show On Now / Next overlay").
|
||||
- `docs/channels.md`: add the overlay toggle + its transcode-only limitation (no HLS-Direct).
|
||||
- `docs/decisions.md`: add a decision record (with `key:`, `Signals:`, `status: active`, `since: 2026-07-22`) for **channel-level graphics-element attachment + the built-in seeded text-element pattern**, cross-referencing `iptv.logo-drives-bug-preset`. Suggested key: `graphics.channel-level-attachment`. Then run `python3 scripts/build_decisions_catalog.py` (or the documented regen) so `docs/decisions/README.md` updates, and validate with `python3 scripts/decisions_validate.py`.
|
||||
|
||||
- [ ] **Step 7: Commit docs**
|
||||
|
||||
```bash
|
||||
git add docs/domain-model.md docs/channels.md docs/decisions.md docs/decisions/README.md
|
||||
git -c core.hooksPath=/dev/null commit -m "docs(74): channel-level graphics attachment + On Now/Next overlay"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Local gate, independent review, push, PR
|
||||
|
||||
- [ ] **Step 1: Full local gate**
|
||||
|
||||
```bash
|
||||
dotnet build ErsatzTV.sln
|
||||
dotnet test ErsatzTV.Tests
|
||||
cd web && npm run build && npm run test && cd ..
|
||||
```
|
||||
Expected: all green. Investigate any failure before proceeding.
|
||||
|
||||
- [ ] **Step 2: BOM + format gate on the touched set**
|
||||
|
||||
Run the BOM check and `dotnet format whitespace ... --folder --include <changed .cs>` under `bash -c` (Global Constraints). Fix any reported file.
|
||||
|
||||
- [ ] **Step 3: Independent review (MANDATORY)**
|
||||
|
||||
This diff touches a DB migration, an API write-path handler, and the render path, and exceeds ~150 C# lines — independent review is required (`process.independent-review-rubric`). Run a cold-context, cross-model review over the full branch diff. Fold fixes as follow-up commits; re-review the fix commit; loop to a clean `Review-verdict: MERGEABLE @ <head-sha>` (or an explicit acceptable-defer with a filed follow-up).
|
||||
|
||||
- [ ] **Step 4: Push + open PR + arm CI monitor**
|
||||
|
||||
Push the branch once (batch all commits — CI runs can't be cancelled), open a PR (`fixes #74`), and arm the CI monitor on the head sha at PR-open. Ensure #74's issue body carries a `## Done-when` checklist (adversarial-review-passed; tests-green; live-E2E; docs-updated) so the merge-consent gate can derive consent.
|
||||
|
||||
- [ ] **Step 5: Session close**
|
||||
|
||||
Run the H12 audit (`scripts/issue-qualification-audit.sh`), post a `## Closing record` on #74, remove the `in-progress` label after merge, and run `scripts/refresh-shared-checkout.sh`.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage:** §3.1 join → Task 1; §3.2 selector hook + eager-load → Task 2; §3.3 seed → Task 3; §3.4 API + builtIn → Task 4; §3.5 SPA toggle → Task 5; §5 tests → Tasks 1/2/3/4 (+ E2E Task 6); §6 risks (guide-cache) → Task 6 Step 5; §7 docs → Tasks 4/6; §8 out-of-scope (no live preview) → Task 5 honored. All spec sections map to tasks.
|
||||
|
||||
**Deviations from the spec, made explicit:** (a) Row creation is delegated to the existing `RefreshGraphicsElementsHandler` (single owner) rather than done in the seeder — the seeder only materializes the file + marker; this is cleaner ownership and keeps the seeder unit-testable with `MockFileSystem`. (b) The style set uses three named styles (`now`/`sub`/`next`) so NOW title, subtitle, and NEXT line differ visually.
|
||||
|
||||
**Placeholder scan:** the only intentionally-open value is the two file-harness lookups called out in Task 4 Step 1 and Task 5 Step 6 (copy the existing test harness / confirm a web test harness exists) — these are "read the neighbor and mirror it" instructions, not code TODOs. The `opacity_expression` is concrete (`LinearFadeDuration(content_seconds, 4, 1, 6)`), verified against `OpacityExpressionHelper`.
|
||||
|
||||
**Type consistency:** `GraphicsElementIds` is `List<int>` on the command/request and `int[]` on the response DTO (matches existing DTO array style); `ChannelGraphicsElement` navs and FKs are named identically across entity, config, includes, and reconcile. `builtIn` derives from `GraphicsElementDefaults.OnNowNextFileName` in both the seeder-identity and the API handler.
|
||||
@@ -0,0 +1,265 @@
|
||||
# Design — Per-channel "On Now / Next" transient overlay (#74)
|
||||
|
||||
- **Issue:** ersatztv#74 ("[Blue sky] On-screen 'On Now / On Next' overlay (channel-surf bug)")
|
||||
- **Date:** 2026-07-22
|
||||
- **Status:** approved design, pre-implementation
|
||||
- **Scope chosen:** full feature — seeded preset element + per-channel SPA toggle + docs + live E2E
|
||||
|
||||
## 1. Summary
|
||||
|
||||
Burn a transient "On Now / Next" text bug onto a channel's transcoded stream, shown for
|
||||
~8 seconds at each program transition, for authentic channel-surf feel. The overlay shows the
|
||||
current program (title + episode/subtitle) and the next program (title + start time).
|
||||
|
||||
**Key finding that shaped the design:** the core capability already exists. ErsatzTV has a
|
||||
SkiaSharp graphics engine (`ErsatzTV.Infrastructure/Streaming/Graphics/`) that renders dynamic
|
||||
text per playout item, with EPG "current + next N programs" data already wired in as template
|
||||
variables at transcode time (`TemplateDataRepository.GetEpgTemplateData`). The graphics context
|
||||
is rebuilt per playout item, so overlay text changes per program automatically. **No new
|
||||
rendering or EPG-data infrastructure is required.** The remaining work is (a) a **channel-level
|
||||
attachment** for graphics elements (which does not exist today), (b) a **seeded preset element**,
|
||||
(c) a **per-channel SPA toggle**, and (d) verification.
|
||||
|
||||
The issue's original worry ("requires stream-overlay work in the FFmpeg pipeline beyond the static
|
||||
watermark — non-trivial") is obsolete: that infrastructure landed (upstream graphics engine +
|
||||
our #502/#511 remote-image/graphics-engine work). This places #74 alongside #73/#77 — mostly
|
||||
already-implemented — but the user chose to also ship the reusable channel-attach + toggle rather
|
||||
than a docs-only close.
|
||||
|
||||
## 2. Existing systems (grounding)
|
||||
|
||||
- **Graphics elements** (`ErsatzTV.Core/Domain/GraphicsElement.cs`): YAML-authored
|
||||
(`Text`/`Image`/`Subtitle`/`Motion`/`Script`), rendered by the graphics engine. A DB row has
|
||||
`Path` (points to a YAML file on disk), `Name`, `Kind`. Attaches only via **PlayoutItem /
|
||||
ProgramScheduleItem / BlockItem / Deco** join tables — **no channel-level attachment.**
|
||||
- **`TextGraphicsElement`** (`ErsatzTV.Core/Graphics/TextGraphicsElement.cs`): fields include
|
||||
`epg_entries:int`, `opacity_expression:string`, `location`, `*_margin_percent`, `width_percent`,
|
||||
`text_fit`, `text_align`, `z_index`, `base_style`, `styles:[StyleDefinition]`, `text:string`.
|
||||
`StyleDefinition` carries font size/weight/family, color, halo (color/width/blur), spacing.
|
||||
- **EPG at transcode time**: `GraphicsElementLoader.InitTemplateVariables` →
|
||||
`ITemplateDataRepository.GetEpgTemplateData(channelNumber, startTime, epgEntries)` reads the
|
||||
**cached** XMLTV fragment (`FileSystemLayout.ChannelGuideCacheFolder/{channelNumber}.xml`) and
|
||||
exposes an `Epg` array (`Title`, `SubTitle`, `Description`, `Start`, `Stop`, …). Count is derived
|
||||
from the element's `epg_entries`.
|
||||
- **Selection**: `ErsatzTV.Core/FFmpeg/GraphicsElementSelector.cs` →
|
||||
`SelectGraphicsElements(channel, playoutItem, now)`. Returns empty for
|
||||
`StreamingMode.HttpLiveStreamingDirect`; otherwise resolves deco entries (template deco → playout
|
||||
deco, each with a `GraphicsElementsMode` of `Merge`/`Override`/`Disable`/`Inherit`) and finally
|
||||
falls through to `playoutItem.PlayoutItemGraphicsElements`.
|
||||
- **Refresh/discovery**: `RefreshGraphicsElementsHandler` scans
|
||||
`FileSystemLayout.GraphicsElements*TemplatesFolder` for `*.yml`/`*.yaml`, inserts a
|
||||
`GraphicsElement { Path, Kind }` per new file, and **removes rows whose files vanished.** There
|
||||
are **no seeded/built-in example elements today** — all are user-supplied.
|
||||
- **#67 precedent** (`iptv.logo-drives-bug-preset`, `docs/decisions.md`): the "Channel Bug"
|
||||
*watermark* is seeded in `DbInitializer.SeedChannelBugWatermark`, guarded by a `ConfigElement`
|
||||
marker (`watermark.channel_bug_seeded`), **adopting** any existing row untouched, once per DB.
|
||||
The editor toggle reflects a server-provided discriminator (`WatermarkResponseModel.imageSource`),
|
||||
**not** a fragile name-match — a lesson we carry over.
|
||||
|
||||
Note: watermarks (`ChannelWatermark`, image-only, direct `Channel.WatermarkId` FK) and graphics
|
||||
elements are **separate parallel systems**. The text EPG overlay must use the graphics-element
|
||||
system; it cannot reuse the watermark FK (a channel has only one `WatermarkId`, already used by the
|
||||
#67 logo-bug — reusing it would collide, and multi-line EPG text is a poor fit for the single-image
|
||||
watermark model). Hence a new channel-level graphics attachment.
|
||||
|
||||
## 3. Components / changes
|
||||
|
||||
### 3.1 Domain + persistence — `ChannelGraphicsElement` join (NEW)
|
||||
|
||||
A 5th join table, structurally identical to the existing four:
|
||||
|
||||
```csharp
|
||||
// ErsatzTV.Core/Domain/ChannelGraphicsElement.cs
|
||||
public class ChannelGraphicsElement
|
||||
{
|
||||
public int ChannelId { get; set; }
|
||||
public Channel Channel { get; set; }
|
||||
public int GraphicsElementId { get; set; }
|
||||
public GraphicsElement GraphicsElement { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
- `Channel` gains `List<ChannelGraphicsElement> ChannelGraphicsElements`.
|
||||
- `GraphicsElement` gains `List<ChannelGraphicsElement> ChannelGraphicsElements` (+ `List<Channel> Channels` if the existing skip-nav convention is followed — match the `DecoGraphicsElement` config exactly).
|
||||
- EF configuration mirroring `GraphicsElementConfiguration` / `DecoGraphicsElement` (composite key `{ChannelId, GraphicsElementId}`, cascade on channel delete, restrict/cascade on element delete consistent with the existing joins).
|
||||
- **Dual-provider migration** via `scripts/add-migration.sh Add_ChannelGraphicsElement` (Sqlite + MySql). Named consistently with the existing `*_Add_DecoGraphicsElements` migrations.
|
||||
|
||||
### 3.2 Render-path hook — `GraphicsElementSelector`
|
||||
|
||||
Append channel-level elements at the **final fall-through**, alongside
|
||||
`playoutItem.PlayoutItemGraphicsElements`:
|
||||
|
||||
```csharp
|
||||
result.AddRange(playoutItem.PlayoutItemGraphicsElements);
|
||||
result.AddRange(channel.ChannelGraphicsElements
|
||||
.Map(cge => new PlayoutItemGraphicsElement { PlayoutItem = playoutItem, GraphicsElement = cge.GraphicsElement }));
|
||||
return result;
|
||||
```
|
||||
|
||||
**Precedence:** channel overlays are a **base layer** — they merge with `Merge`-mode deco elements
|
||||
and per-playout-item elements. A deco in `Override` or `Disable` mode `return`s before the
|
||||
fall-through, so it **suppresses** the channel overlay (a deliberate, documented rule: decos can
|
||||
override channel defaults). `HttpLiveStreamingDirect` continues to return empty early (overlay not
|
||||
applicable when ErsatzTV isn't transcoding — a documented limitation surfaced in the SPA helper
|
||||
text).
|
||||
|
||||
**Eager-load requirement:** wherever the `Channel` is loaded for the streaming/transcode path
|
||||
(the query behind `GetPlayoutItemProcessByChannelNumberHandler` →
|
||||
`FFmpegLibraryProcessService.CreateProcess`), add
|
||||
`.Include(c => c.ChannelGraphicsElements).ThenInclude(cge => cge.GraphicsElement)` so the selector
|
||||
sees populated navs. Verify no other caller relies on the channel being loaded without this include
|
||||
(a missing include yields a silent empty list, not a crash — so an explicit test guards it).
|
||||
|
||||
### 3.3 Seeded preset element — `on-now-next.yml` (NEW seed pattern)
|
||||
|
||||
- Ship `on-now-next.yml` as an embedded/content resource in the app (a `resources/`-style folder
|
||||
copied at build, or an embedded resource written out at seed time).
|
||||
- A seeder (extend `DbInitializer`, or a dedicated `IGraphicsElementSeeder` it calls) that, guarded
|
||||
by a new `ConfigElement` marker `graphics.on_now_next_seeded` (new
|
||||
`ConfigElementKey.GraphicsOnNowNextSeeded`):
|
||||
1. If the marker is set, do nothing (once per DB — a deleted preset is **not** resurrected,
|
||||
matching #67's reasoning that `DbInitializer` runs every startup).
|
||||
2. Copy the YAML into `FileSystemLayout.GraphicsElementsTextTemplatesFolder/on-now-next.yml`
|
||||
**only if the file is absent** (never overwrite operator edits — adopt-not-clobber).
|
||||
3. Ensure a `GraphicsElement` row exists for that path (reuse the row-creation + name-parse logic
|
||||
from `RefreshGraphicsElementsHandler`; do not duplicate it — factor a shared helper or invoke
|
||||
the refresh so `Name` is parsed from the YAML `name:` field).
|
||||
4. Set the marker.
|
||||
- Idempotency + adopt semantics covered by a test mirroring `DbInitializerChannelBugWatermarkTests`.
|
||||
|
||||
**Seed order dependency:** the element row must exist before a channel can reference it. If
|
||||
`RefreshGraphicsElements` runs only on demand, the seeder must create the row itself (step 3). If it
|
||||
already runs at startup, ensure the seeder's file-copy precedes it. This ordering is an
|
||||
implementation checkpoint.
|
||||
|
||||
**Seeded YAML (illustrative — finalized during implementation against the loader):**
|
||||
|
||||
```yaml
|
||||
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
|
||||
# fade in over first ~1s, hold, fade out ~7-8s into the item (contentTime in seconds)
|
||||
opacity_expression: "<verified against OpacityExpressionHelper during impl>"
|
||||
base_style: now
|
||||
styles:
|
||||
- name: now
|
||||
font_size: 30
|
||||
font_weight: 700
|
||||
text_color: "#FFFFFF"
|
||||
halo_color: "#000000"
|
||||
halo_width: 2
|
||||
- name: next
|
||||
font_size: 22
|
||||
font_weight: 400
|
||||
text_color: "#DDDDDD"
|
||||
halo_color: "#000000"
|
||||
halo_width: 2
|
||||
text: |
|
||||
NOW {{ Epg[0].Title }}
|
||||
{{ Epg[0].SubTitle }}
|
||||
NEXT {{ Epg[1].Title }} · {{ Epg[1].Start }}
|
||||
```
|
||||
|
||||
The exact `opacity_expression` grammar, the style-span markup that applies `now`/`next` styles to
|
||||
specific lines, and datetime formatting of `Epg[1].Start` are finalized in implementation by
|
||||
reading `GraphicsEngine`/`OpacityExpressionHelper`/`TextElement` and the Scriban template helpers —
|
||||
these are element-authoring details, not architectural unknowns.
|
||||
|
||||
### 3.4 API
|
||||
|
||||
- `ChannelController` detail + update DTOs gain `graphicsElementIds: int[]` (the channel's attached
|
||||
graphics elements), backing the join. Update replaces the set (consistent with existing
|
||||
PUT-replace list conventions).
|
||||
- `GraphicsElementResponseModel` gains a **server-derived `builtIn: bool`**, computed by comparing
|
||||
the row's `Path` to the seeded template path — a robust discriminator so the SPA finds the seeded
|
||||
"On Now / Next" element without a fragile name-match (the direct #67 lesson). (Alternative if a
|
||||
richer taxonomy is wanted later: a `source` enum; `builtIn` is the YAGNI choice now.)
|
||||
- Regenerate artifacts: build the app project first, then `./scripts/update-openapi.sh`, then
|
||||
`npm run generate:api`. This touches `ErsatzTV/Controllers/Api/**` and `ErsatzTV.Core/Api/**` →
|
||||
the blocking `api-docs` CI gate requires the regenerated `v1.json`/`v1.d.ts`/`endpoint-index.md`
|
||||
in the same diff.
|
||||
|
||||
### 3.5 SPA
|
||||
|
||||
- `web/src/screens/ChannelEditScreen.tsx`, **Branding tab**, beside the existing "Use logo as
|
||||
on-screen bug" switch: add a "Show On Now / Next overlay" `Switch`.
|
||||
- On ⇒ add the `builtIn` element's id (found via the existing `GET /api/v1/graphics-elements`,
|
||||
filtered by `builtIn === true`) to the channel draft's `graphicsElementIds`.
|
||||
- Off ⇒ remove it.
|
||||
- Follows the exact pattern of the logo-bug toggle (`logoBugEnabled` / `findLogoBugWatermark`),
|
||||
generalized to graphics elements.
|
||||
- **Scope trim (YAGNI):** no live pixel-preview (the image `BugPreview` renders a static image;
|
||||
live EPG-text rendering in the browser is disproportionate). Instead a short static caption
|
||||
describing what shows and noting it applies only to transcoded modes (not HLS-Direct).
|
||||
|
||||
## 4. Data flow (enable → render)
|
||||
|
||||
1. Operator opens Channel editor → Branding → toggles "Show On Now / Next overlay" on → save.
|
||||
2. `PUT /api/v1/channels/{id}` includes the seeded element id in `graphicsElementIds` → handler
|
||||
reconciles the `ChannelGraphicsElement` join set.
|
||||
3. On stream start, `HlsSessionWorker` → `GetPlayoutItemProcessByChannelNumberHandler` loads the
|
||||
channel (with the new include) → `GraphicsElementSelector.SelectGraphicsElements` appends the
|
||||
channel overlay element (unless a deco Override/Disable suppresses it).
|
||||
4. `FFmpegLibraryProcessService.CreateProcess` builds the graphics context;
|
||||
`GraphicsElementLoader` parses the YAML, resolves `Epg[0]`/`Epg[1]` from the cached guide, and
|
||||
the graphics engine renders the faded text bug into the piped BGRA frames composited by ffmpeg.
|
||||
5. At the next playout item, a fresh process/context re-resolves NOW/NEXT → the bug updates and
|
||||
re-fades.
|
||||
|
||||
## 5. Testing / verification
|
||||
|
||||
- **NUnit (Core/Infrastructure):**
|
||||
- `GraphicsElementSelector` composition: channel overlay merges on the no-deco and `Merge` paths;
|
||||
`Override`/`Disable` deco suppresses it; `HttpLiveStreamingDirect` returns empty. (Extends the
|
||||
existing selector tests.)
|
||||
- Seeder idempotency/adopt: marker gates once-per-DB; existing file/row adopted untouched; deleted
|
||||
preset not resurrected (mirror `DbInitializerChannelBugWatermarkTests`).
|
||||
- Channel update reconciles the `ChannelGraphicsElement` set (add/remove).
|
||||
- **Live E2E** (`scripts/e2e-local.sh`, fresh config dir): create a channel with real playout,
|
||||
enable the toggle, stream via curl (never a browser tab), and confirm the burned-in NOW/NEXT bug
|
||||
renders and changes across a program boundary. Required because this is an API write-path +
|
||||
render-path change (`release.live-e2e-required`).
|
||||
- **Independent review is mandatory** (`process.independent-review-rubric`): the diff touches a DB
|
||||
migration, an API write-path handler, and the render path, and exceeds ~150 C# lines. Cross-model
|
||||
/ cold-context review before merge.
|
||||
|
||||
## 6. Risks / open items
|
||||
|
||||
- **Guide-cache freshness governs EPG accuracy** — the overlay reads the same cached XMLTV the guide
|
||||
serves. Validate on a normal channel. On-demand / time-shifted channels (see #68) may need the
|
||||
guide refreshed on thaw for the overlay to match; if E2E shows drift there, file a scoped
|
||||
follow-up rather than expanding this PR.
|
||||
- **HLS-Direct has no overlay** (ErsatzTV isn't transcoding) — documented limitation, surfaced in
|
||||
the toggle's caption.
|
||||
- **Seed file lifecycle** — if an operator deletes the YAML, `RefreshGraphicsElements` removes the
|
||||
row and channels silently lose the bug (acceptable, mirrors deleting a preset). The marker keeps
|
||||
it from silently reappearing.
|
||||
- **PR size** — migration + render path + API + seeding + SPA is a sizable single PR; keep slices
|
||||
reviewable and land behind the mandatory review.
|
||||
|
||||
## 7. Docs to update (same PR)
|
||||
|
||||
- `docs/domain-model.md` — channel-level graphics-element attachment (`ChannelGraphicsElement`).
|
||||
- `docs/api-conventions.md` checklist + regenerated `v1.json` / `endpoint-index.md`
|
||||
(`release.api-contract-ci-gate`).
|
||||
- `docs/decisions.md` — new record(s): channel-level graphics attachment + the seeded
|
||||
graphics-element pattern (with `key:`/`Signals:`), cross-referencing `iptv.logo-drives-bug-preset`.
|
||||
- `docs/channels.md` — the On Now/Next overlay toggle + its transcode-only limitation.
|
||||
- `docs/spa-conventions.md` — only if the toggle introduces a new pattern (likely not; it reuses the
|
||||
logo-bug pattern).
|
||||
|
||||
## 8. Out of scope / deferred
|
||||
|
||||
- Live pixel-preview of the text overlay in the editor (YAGNI; static caption instead).
|
||||
- Intra-item text refresh (a program running long won't roll "NEXT" mid-item — text is baked at
|
||||
item start, which is fine for the transient-on-transition model).
|
||||
- Per-channel geometry/content customization of the bug (all channels share the seeded preset;
|
||||
operators can edit the YAML or attach a different element — the join is general).
|
||||
- Overlay on HLS-Direct.
|
||||
- Periodic/always-on display modes (only transient-on-transition is built).
|
||||
Vendored
+3
@@ -201,6 +201,7 @@ export interface components {
|
||||
"idleBehavior": components["schemas"]["ChannelIdleBehavior"];
|
||||
"isEnabled": boolean;
|
||||
"showInEpg": boolean;
|
||||
"graphicsElementIds": Array<number>;
|
||||
};
|
||||
"ChannelGuideChannelResponseModel": {
|
||||
"number": string;
|
||||
@@ -761,6 +762,7 @@ export interface components {
|
||||
"GraphicsElementResponseModel": {
|
||||
"id": number;
|
||||
"name": string;
|
||||
"builtIn": boolean;
|
||||
};
|
||||
"GuideMode": "Normal" | "Filler";
|
||||
"HardwareAccelerationKind": "None" | "Qsv" | "Nvenc" | "Vaapi" | "VideoToolbox" | "Amf" | "V4l2m2m" | "Rkmpp";
|
||||
@@ -1638,6 +1640,7 @@ export interface components {
|
||||
"idleBehavior": components["schemas"]["ChannelIdleBehavior"];
|
||||
"isEnabled": boolean;
|
||||
"showInEpg": boolean;
|
||||
"graphicsElementIds": null | Array<number>;
|
||||
};
|
||||
"UpdateChannelTemplateRequest": {
|
||||
"name": string;
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { GraphicsElement } from './pickers';
|
||||
|
||||
// Prefer the server-declared built-in flag; never match on the user-editable name.
|
||||
export function findBuiltInOnNowNext(elements: GraphicsElement[]): GraphicsElement | null {
|
||||
return elements.find((e) => e.builtIn) ?? null;
|
||||
}
|
||||
@@ -11,6 +11,7 @@ export * from './decos';
|
||||
export * from './decoTemplates';
|
||||
export * from './ffmpegProfiles';
|
||||
export * from './fillerPresets';
|
||||
export * from './graphicsElements';
|
||||
export * from './guide';
|
||||
export * from './imageFolders';
|
||||
export * from './languages';
|
||||
|
||||
@@ -31,7 +31,8 @@ const channel = {
|
||||
transcodeMode: 'OnDemand',
|
||||
idleBehavior: 'StopOnDisconnect',
|
||||
isEnabled: true,
|
||||
showInEpg: true
|
||||
showInEpg: true,
|
||||
graphicsElementIds: []
|
||||
};
|
||||
|
||||
// The PUT body is the GET response minus the read-only id / playoutCount fields.
|
||||
@@ -52,6 +53,7 @@ function json(body: unknown, status = 200): Response {
|
||||
interface FetchOptions {
|
||||
channelOverrides?: Record<string, unknown>;
|
||||
channelStatus?: number;
|
||||
graphicsElements?: Record<string, unknown>[];
|
||||
onPut?: (body: unknown) => void;
|
||||
putResponseOverrides?: Record<string, unknown>;
|
||||
watermarks?: Record<string, unknown>[];
|
||||
@@ -60,6 +62,7 @@ interface FetchOptions {
|
||||
function mockApi({
|
||||
channelOverrides = {},
|
||||
channelStatus = 200,
|
||||
graphicsElements = [],
|
||||
onPut,
|
||||
putResponseOverrides = {},
|
||||
watermarks = [{ id: 2, name: 'Corner bug', imageSource: 'Custom' }]
|
||||
@@ -111,6 +114,10 @@ function mockApi({
|
||||
return Promise.resolve(json([{ id: 3, name: 'Bumpers' }]));
|
||||
}
|
||||
|
||||
if (url.startsWith('/api/v1/graphics-elements')) {
|
||||
return Promise.resolve(json(graphicsElements));
|
||||
}
|
||||
|
||||
if (url === '/api/v1/channels') {
|
||||
return Promise.resolve(json([{ id: 5, number: '5', name: 'Cartoons', group: 'ChicoryTV' }]));
|
||||
}
|
||||
@@ -279,6 +286,10 @@ describe('ChannelEditScreen', () => {
|
||||
return Promise.resolve(json([{ id: 3, name: 'Bumpers' }]));
|
||||
}
|
||||
|
||||
if (url.startsWith('/api/v1/graphics-elements')) {
|
||||
return Promise.resolve(json([]));
|
||||
}
|
||||
|
||||
if (url === '/api/v1/channels') {
|
||||
return Promise.resolve(json([{ id: 5, number: '5', name: 'Cartoons', group: 'ChicoryTV' }]));
|
||||
}
|
||||
@@ -512,6 +523,95 @@ describe('ChannelEditScreen', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('show On Now / Next overlay toggle', () => {
|
||||
it(
|
||||
'ticks the toggle when the built-in element id is in graphicsElementIds',
|
||||
async () => {
|
||||
mockApi({
|
||||
channelOverrides: { graphicsElementIds: [7] },
|
||||
graphicsElements: [{ id: 7, name: 'On Now / Next', builtIn: true }]
|
||||
});
|
||||
render(<ChannelEditScreen />);
|
||||
|
||||
await screen.findByDisplayValue('Cartoons');
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Branding/ }));
|
||||
|
||||
expect(await screen.findByRole('switch', { name: 'Show On Now / Next overlay' })).toHaveAttribute(
|
||||
'aria-checked',
|
||||
'true'
|
||||
);
|
||||
},
|
||||
15000
|
||||
);
|
||||
|
||||
it(
|
||||
'adds the built-in element id to graphicsElementIds on save when switched on',
|
||||
async () => {
|
||||
const puts: unknown[] = [];
|
||||
mockApi({
|
||||
channelOverrides: { graphicsElementIds: [] },
|
||||
graphicsElements: [{ id: 7, name: 'On Now / Next', builtIn: true }],
|
||||
onPut: (body) => puts.push(body)
|
||||
});
|
||||
render(<ChannelEditScreen />);
|
||||
|
||||
await screen.findByDisplayValue('Cartoons');
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Branding/ }));
|
||||
|
||||
fireEvent.click(await screen.findByRole('switch', { name: 'Show On Now / Next overlay' }));
|
||||
|
||||
const saveButton = await screen.findByRole('button', { name: 'Save changes' });
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
await waitFor(() => expect(puts).toHaveLength(1));
|
||||
expect((puts[0] as { graphicsElementIds: number[] }).graphicsElementIds).toEqual([7]);
|
||||
},
|
||||
15000
|
||||
);
|
||||
|
||||
it(
|
||||
'removes the built-in element id from graphicsElementIds on save when switched off',
|
||||
async () => {
|
||||
const puts: unknown[] = [];
|
||||
mockApi({
|
||||
channelOverrides: { graphicsElementIds: [7] },
|
||||
graphicsElements: [{ id: 7, name: 'On Now / Next', builtIn: true }],
|
||||
onPut: (body) => puts.push(body)
|
||||
});
|
||||
render(<ChannelEditScreen />);
|
||||
|
||||
await screen.findByDisplayValue('Cartoons');
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Branding/ }));
|
||||
|
||||
fireEvent.click(await screen.findByRole('switch', { name: 'Show On Now / Next overlay' }));
|
||||
|
||||
const saveButton = await screen.findByRole('button', { name: 'Save changes' });
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
await waitFor(() => expect(puts).toHaveLength(1));
|
||||
expect((puts[0] as { graphicsElementIds: number[] }).graphicsElementIds).toEqual([]);
|
||||
},
|
||||
15000
|
||||
);
|
||||
|
||||
it(
|
||||
'disables the toggle when no built-in element is present',
|
||||
async () => {
|
||||
mockApi({ graphicsElements: [] });
|
||||
render(<ChannelEditScreen />);
|
||||
|
||||
await screen.findByDisplayValue('Cartoons');
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Branding/ }));
|
||||
|
||||
expect(await screen.findByRole('switch', { name: 'Show On Now / Next overlay' })).toHaveAttribute(
|
||||
'aria-disabled',
|
||||
'true'
|
||||
);
|
||||
},
|
||||
15000
|
||||
);
|
||||
});
|
||||
|
||||
describe('on-screen bug preview', () => {
|
||||
it('renders the preview image using the fetched watermark geometry', async () => {
|
||||
mockApi({
|
||||
@@ -641,6 +741,10 @@ describe('ChannelEditScreen', () => {
|
||||
return Promise.resolve(json([{ id: 3, name: 'Bumpers' }]));
|
||||
}
|
||||
|
||||
if (url.startsWith('/api/v1/graphics-elements')) {
|
||||
return Promise.resolve(json([]));
|
||||
}
|
||||
|
||||
if (url === '/api/v1/channels') {
|
||||
return Promise.resolve(json([{ id: 5, number: '5', name: 'Cartoons', group: 'ChicoryTV' }]));
|
||||
}
|
||||
|
||||
@@ -15,12 +15,14 @@ import { navigateToPath } from '../routing';
|
||||
import { Badge, BugPreview, Button, Card, ChannelLogo, Input, Select, Switch, type BugPreviewGeometry } from '../components';
|
||||
import {
|
||||
ApiError,
|
||||
findBuiltInOnNowNext,
|
||||
findLogoBugWatermark,
|
||||
getChannelById,
|
||||
getChannels,
|
||||
getChannelStreamSelectors,
|
||||
getFFmpegProfiles,
|
||||
getFillerPresets,
|
||||
getGraphicsElements,
|
||||
getLanguages,
|
||||
getMusicVideoCreditsTemplates,
|
||||
getWatermark,
|
||||
@@ -32,6 +34,7 @@ import {
|
||||
type ChannelSummary,
|
||||
type FFmpegProfile,
|
||||
type FillerPreset,
|
||||
type GraphicsElement,
|
||||
type LanguageCode,
|
||||
type UpdateChannelRequest,
|
||||
type Watermark
|
||||
@@ -153,7 +156,8 @@ function draftFromChannel(channel: Channel): UpdateChannelRequest {
|
||||
transcodeMode: channel.transcodeMode,
|
||||
idleBehavior: channel.idleBehavior,
|
||||
isEnabled: channel.isEnabled,
|
||||
showInEpg: channel.showInEpg
|
||||
showInEpg: channel.showInEpg,
|
||||
graphicsElementIds: channel.graphicsElementIds ?? []
|
||||
};
|
||||
}
|
||||
|
||||
@@ -276,6 +280,7 @@ interface ReferenceData {
|
||||
channels: ChannelSummary[];
|
||||
fillerPresets: FillerPreset[];
|
||||
watermarks: Watermark[];
|
||||
graphicsElements: GraphicsElement[];
|
||||
ffmpegProfiles: FFmpegProfile[];
|
||||
languages: LanguageCode[];
|
||||
musicVideoCreditsTemplates: string[];
|
||||
@@ -825,6 +830,35 @@ function BrandingPane({
|
||||
)}
|
||||
</div>
|
||||
</Row>
|
||||
{(() => {
|
||||
const onNowNext = findBuiltInOnNowNext(data.graphicsElements);
|
||||
const ids = draft.graphicsElementIds ?? [];
|
||||
const enabled = onNowNext != null && ids.includes(onNowNext.id);
|
||||
return (
|
||||
<Row
|
||||
control={340}
|
||||
help={
|
||||
onNowNext == null
|
||||
? 'The built-in On Now / Next element is not available yet.'
|
||||
: 'Briefly shows the current and next program at each program change. Not shown in HLS Direct mode.'
|
||||
}
|
||||
label="Show On Now / Next overlay"
|
||||
>
|
||||
<Switch
|
||||
checked={enabled}
|
||||
disabled={hlsDirect || onNowNext == null}
|
||||
hideLabel
|
||||
label="Show On Now / Next overlay"
|
||||
onChange={(next) => {
|
||||
if (onNowNext == null) return;
|
||||
const without = ids.filter((id) => id !== onNowNext.id);
|
||||
set({ graphicsElementIds: next ? [...without, onNowNext.id] : without });
|
||||
}}
|
||||
size="sm"
|
||||
/>
|
||||
</Row>
|
||||
);
|
||||
})()}
|
||||
<Row help={hlsDirect ? 'Not used in HLS Direct mode.' : 'Overlay applied to the channel.'} label="Watermark">
|
||||
<Select
|
||||
disabled={hlsDirect}
|
||||
@@ -903,25 +937,47 @@ export function ChannelEditScreen() {
|
||||
getChannels(),
|
||||
getLanguages(),
|
||||
getMusicVideoCreditsTemplates(),
|
||||
getChannelStreamSelectors()
|
||||
getChannelStreamSelectors(),
|
||||
getGraphicsElements()
|
||||
])
|
||||
.then(([channel, ffmpegProfiles, watermarks, fillerPresets, channels, languages, musicVideoCreditsTemplates, streamSelectors]) => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
.then(
|
||||
([
|
||||
channel,
|
||||
ffmpegProfiles,
|
||||
watermarks,
|
||||
fillerPresets,
|
||||
channels,
|
||||
languages,
|
||||
musicVideoCreditsTemplates,
|
||||
streamSelectors,
|
||||
graphicsElements
|
||||
]) => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
const initial = draftFromChannel(channel);
|
||||
const initialExternalLogoUrl = externalLogoUrlFromChannel(channel);
|
||||
setDraft(initial);
|
||||
setSaved(initial);
|
||||
setExternalLogoUrl(initialExternalLogoUrl);
|
||||
setSavedExternalLogoUrl(initialExternalLogoUrl);
|
||||
setLoaded({
|
||||
channelId,
|
||||
data: { channels, ffmpegProfiles, fillerPresets, languages, musicVideoCreditsTemplates, streamSelectors, watermarks },
|
||||
playoutLocked: channel.playoutSource === 'Generated' && channel.playoutCount > 0
|
||||
});
|
||||
})
|
||||
const initial = draftFromChannel(channel);
|
||||
const initialExternalLogoUrl = externalLogoUrlFromChannel(channel);
|
||||
setDraft(initial);
|
||||
setSaved(initial);
|
||||
setExternalLogoUrl(initialExternalLogoUrl);
|
||||
setSavedExternalLogoUrl(initialExternalLogoUrl);
|
||||
setLoaded({
|
||||
channelId,
|
||||
data: {
|
||||
channels,
|
||||
ffmpegProfiles,
|
||||
fillerPresets,
|
||||
graphicsElements,
|
||||
languages,
|
||||
musicVideoCreditsTemplates,
|
||||
streamSelectors,
|
||||
watermarks
|
||||
},
|
||||
playoutLocked: channel.playoutSource === 'Generated' && channel.playoutCount > 0
|
||||
});
|
||||
}
|
||||
)
|
||||
.catch((error: unknown) => {
|
||||
if (!active) {
|
||||
return;
|
||||
|
||||
Reference in New Issue
Block a user