Files
ersatztv/ErsatzTV.Application/Channels/Commands/RefreshChannelDataHandler.cs
T
timothyandClaude Opus 5 b5dee26202
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 8s
PR Gates / Docs update reminder (pull_request) Successful in 13s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 19s
review-verdict/h10 Review-verdict: MERGEABLE @ b5dee26 (base: main)
PR Gates / decisions lifecycle (pull_request) Successful in 20s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 15s
Review verdict / Set review-verdict status (pull_request_target) Successful in 6s
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 4m32s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m44s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m18s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 5m53s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 8s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 6s
fix(701): guard SongMetadata's nullable primitive collections at the read site
Both search indexers opened UpdateSong with

    metadata.AlbumArtists ??= [];
    metadata.Artists ??= [];

Artists/AlbumArtists hold the whole list in ONE COLUMN rather than
being navigations. So unlike the same `??= []` idiom on
Genres/Tags/Artwork all around them, the property IS the column value:
assigning it on a TRACKED entity flips the entry to Modified and the
next SaveChanges writes [] over a NULL column. This is the mechanism an
adversarial review demonstrated in #691, which is why that issue's
entity-level guard was reverted in favour of guarding at the read site.

Measured rather than reasoned about, per the issue's first done-when
box. Restoring ONLY the `??= []` clause (the real predecessor lines,
not a hand-written mutant) reddens the new fixture on
`metadata.Artists should be null but was []`; a probe variant with the
first two assertions replaced by prints reports STATE=Modified and the
raw column moving from NULL to "[]". Today's two feeds are both
AsNoTracking (SearchRepository.GetItemToIndex and GetAllSongs), so no
shipped caller loses data -- but that is a property of two callers, not
of the indexer, and #691 already recorded it as a loaded gun. The
fixture pins the indexer's own contract instead.

Removing the assignment is not sufficient alone: it was load-bearing
for the four reads below it, and deleting it by itself converts a
silent write into a live throw on every untagged song. Measured by
deleting only those two lines from the real predecessor file:
NullReferenceException, thrown at the foreach (cited by symbol: a line
number in a mutant that exists in no committed tree is unreproducible
by construction). The
exception type follows the read FORM, not the field -- foreach yields
NRE, string.Join/ToList yield ArgumentNullException -- and this PR
contains two of each, which is why no single exception-name grep
characterises the class. So each site moves together with its reads:

- LuceneSearchIndex.UpdateSong / ElasticSearchIndex.UpdateSong: hoist
  Optional(...).Flatten().ToList() locals and read those.
- RefreshChannelDataHandler: the Scriban context took the raw nullable
  lists (the issue's second item). The shipped _song.sbntxt only does
  array.join, but a custom template is free to do anything.

The population was derived from the MODEL rather than from the issue's
file list, and the obvious derivation is wrong: "the IList<string>
properties under ErsatzTV.Core/Domain" returns two of eight. It misses
the six value-converted collections (ProgramScheduleAlternate and
PlayoutTemplate each carrying DaysOfMonth, MonthsOfYear, DaysOfWeek),
declared as plain ICollection<T> and made single columns only in
Data/Configurations -- and their storage differs (comma-separated text
for the int converter, JSON for the enum one), so the shared property
is "one scalar column", not the serialization. No site applies `??=`
to any of the six, so this defect has no instance there; whether a null
can REACH one at runtime is unverified and is filed as #823 rather than
asserted either way. Only the SongMetadata pair is left NULL in
practice, by FallbackMetadataProvider. Every site touching either field
was then swept; the remaining readers were already guarded by #691.

The fixture carries two anti-vacuity guards, both witnessed:

- A POSITIVE CONTROL (`writer.NumDocs.ShouldBe(1)`). Every other
  assertion says something did NOT happen, so all of them hold
  vacuously if UpdateSong never runs -- and it silently stops running
  if a future refactor gates UpdateItems on `_initialized`, which this
  fixture bypasses by injecting the writer. Verified BOTH directions:
  with that gate added the control fails `NumDocs should be 1 but was
  0`, and with the control removed the whole test PASSES while the code
  under test is unreachable.
- A capturing logger, because UpdateSong wraps its body in a catch that
  assigns metadata.Song = null -- severing a required relationship and
  cascading the metadata to Deleted. Without it the probe silently
  measures the error path; on the first run it did exactly that (a bare
  ILanguageCodeService substitute NPEs inside AddLanguages). The raw
  column helper also fails loudly on a missing row, since ExecuteScalar
  returns CLR null for both "NULL column" and "no such row".

ElasticSearchIndex has no equivalent fixture -- it needs a stubbed
transport -- so its change is by inspection against the Lucene one, and
the gap is filed as #824 rather than covered by a source-text guard.

The whitespace-only churn in ElasticSearchIndex.cs is the #311
fix-as-you-touch format gate: it scopes to whole changed FILES.
`git diff -w` over that file shows only the two hunks above.

Local gate: ErsatzTV.Tests 2006 passed / 4 pre-existing skips,
Core.Tests 685/1 skip, Infrastructure.Tests 114, Architecture.Tests 7,
Scanner.Tests 1504 -- 0 failures in each. scripts/tests 874 passed / 2
skipped. dotnet format whitespace --verify-no-changes clean on the four
touched files, no BOM on any. decisions_validate OK.

Fixes #701

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 00:49:57 +02:00

1057 lines
40 KiB
C#

using System.Globalization;
using System.IO.Abstractions;
using System.Xml;
using ErsatzTV.Application.Configuration;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Emby;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Iptv;
using ErsatzTV.Core.Jellyfin;
using ErsatzTV.Core.Streaming;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.IO;
using Newtonsoft.Json;
using Scriban;
using Scriban.Runtime;
using WebMarkupMin.Core;
namespace ErsatzTV.Application.Channels;
public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
{
private readonly IConfigElementRepository _configElementRepository;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly IFileSystem _fileSystem;
private readonly ILocalFileSystem _localFileSystem;
private readonly ILogger<RefreshChannelDataHandler> _logger;
private readonly RecyclableMemoryStreamManager _recyclableMemoryStreamManager;
public RefreshChannelDataHandler(
RecyclableMemoryStreamManager recyclableMemoryStreamManager,
IDbContextFactory<TvContext> dbContextFactory,
IFileSystem fileSystem,
ILocalFileSystem localFileSystem,
IConfigElementRepository configElementRepository,
ILogger<RefreshChannelDataHandler> logger)
{
_recyclableMemoryStreamManager = recyclableMemoryStreamManager;
_dbContextFactory = dbContextFactory;
_fileSystem = fileSystem;
_localFileSystem = localFileSystem;
_configElementRepository = configElementRepository;
_logger = logger;
}
public async Task Handle(RefreshChannelData request, CancellationToken cancellationToken)
{
try
{
_logger.LogDebug("Refreshing channel data (XMLTV) for channel {Channel}", request.ChannelNumber);
_localFileSystem.EnsureFolderExists(FileSystemLayout.ChannelGuideCacheFolder);
string targetFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, $"{request.ChannelNumber}.xml");
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
int hiddenCount = await dbContext.Channels
.Where(c => c.Number == request.ChannelNumber && c.ShowInEpg == false)
.CountAsync(cancellationToken);
if (hiddenCount > 0)
{
File.Delete(targetFile);
return;
}
string movieTemplateFileName = GetMovieTemplateFileName();
string episodeTemplateFileName = GetEpisodeTemplateFileName();
string musicVideoTemplateFileName = GetMusicVideoTemplateFileName();
string songTemplateFileName = GetSongTemplateFileName();
string otherVideoTemplateFileName = GetOtherVideoTemplateFileName();
string remoteStreamTemplateFileName = GetRemoteStreamTemplateFileName();
if (movieTemplateFileName is null || episodeTemplateFileName is null ||
musicVideoTemplateFileName is null ||
songTemplateFileName is null || otherVideoTemplateFileName is null ||
remoteStreamTemplateFileName is null)
{
return;
}
var minifier = new XmlMinifier(
new XmlMinificationSettings
{
MinifyWhitespace = true,
RemoveXmlComments = true,
CollapseTagsWithoutContent = true
});
var templateContext = new XmlTemplateContext();
string movieText = await File.ReadAllTextAsync(movieTemplateFileName, cancellationToken);
var movieTemplate = Template.Parse(movieText, movieTemplateFileName);
string episodeText = await File.ReadAllTextAsync(episodeTemplateFileName, cancellationToken);
var episodeTemplate = Template.Parse(episodeText, episodeTemplateFileName);
string musicVideoText = await File.ReadAllTextAsync(musicVideoTemplateFileName, cancellationToken);
var musicVideoTemplate = Template.Parse(musicVideoText, musicVideoTemplateFileName);
string songText = await File.ReadAllTextAsync(songTemplateFileName, cancellationToken);
var songTemplate = Template.Parse(songText, songTemplateFileName);
string otherVideoText = await File.ReadAllTextAsync(otherVideoTemplateFileName, cancellationToken);
var otherVideoTemplate = Template.Parse(otherVideoText, otherVideoTemplateFileName);
string remoteStreamText = await File.ReadAllTextAsync(remoteStreamTemplateFileName, cancellationToken);
var remoteStreamTemplate = Template.Parse(remoteStreamText, remoteStreamTemplateFileName);
TimeSpan playoutOffset = TimeSpan.Zero;
string mirrorChannelNumber = null;
Option<Channel> maybeChannel = await dbContext.Channels
.AsNoTracking()
.Include(c => c.MirrorSourceChannel)
.Filter(c => c.PlayoutSource == ChannelPlayoutSource.Mirror && c.MirrorSourceChannelId != null)
.SelectOneAsync(
c => c.Number == request.ChannelNumber,
c => c.Number == request.ChannelNumber,
cancellationToken);
foreach (Channel channel in maybeChannel)
{
mirrorChannelNumber = channel.MirrorSourceChannel.Number;
playoutOffset = channel.PlayoutOffset ?? TimeSpan.Zero;
}
List<Playout> playouts = await dbContext.Playouts
.AsNoTracking()
.Filter(pi => pi.Channel.Number == (mirrorChannelNumber ?? request.ChannelNumber))
.IncludeGuideMetadata()
.AsSplitQuery()
.ToListAsync(cancellationToken);
await using RecyclableMemoryStream ms = _recyclableMemoryStreamManager.GetStream();
await using var xml = XmlWriter.Create(
ms,
new XmlWriterSettings { Async = true, ConformanceLevel = ConformanceLevel.Fragment });
int daysToBuild = await _configElementRepository
.GetValue<int>(ConfigElementKey.XmltvDaysToBuild, cancellationToken)
.IfNoneAsync(2);
DateTimeOffset finish = DateTimeOffset.UtcNow.AddDays(daysToBuild);
foreach (Playout playout in playouts)
{
switch (playout.ScheduleKind)
{
case PlayoutScheduleKind.Classic:
case PlayoutScheduleKind.Sequential:
case PlayoutScheduleKind.Scripted:
var floodSorted = playouts
.Collect(p => p.Items)
.OrderBy(pi => pi.Start)
.Filter(pi => pi.StartOffset <= finish)
.ToList();
foreach (var item in floodSorted)
{
item.Start += playoutOffset;
item.Finish += playoutOffset;
}
await WriteScheduleXml(
request,
playout.ScheduleKind,
floodSorted,
templateContext,
movieTemplate,
episodeTemplate,
musicVideoTemplate,
songTemplate,
otherVideoTemplate,
remoteStreamTemplate,
minifier,
xml,
cancellationToken);
break;
case PlayoutScheduleKind.Block:
var blockSorted = playouts
.Collect(p => p.Items)
.OrderBy(pi => pi.Start)
.Filter(pi => pi.StartOffset <= finish)
.ToList();
foreach (var item in blockSorted)
{
item.Start += playoutOffset;
item.Finish += playoutOffset;
}
await WriteScheduleXml(
request,
playout.ScheduleKind,
blockSorted,
templateContext,
movieTemplate,
episodeTemplate,
musicVideoTemplate,
songTemplate,
otherVideoTemplate,
remoteStreamTemplate,
minifier,
xml,
cancellationToken);
break;
case PlayoutScheduleKind.ExternalJson:
var externalJsonSorted = (await CollectExternalJsonItems(playout.ScheduleFile))
.Filter(pi => pi.StartOffset <= finish)
.ToList();
foreach (var item in externalJsonSorted)
{
item.Start += playoutOffset;
item.Finish += playoutOffset;
}
await WriteScheduleXml(
request,
playout.ScheduleKind,
externalJsonSorted,
templateContext,
movieTemplate,
episodeTemplate,
musicVideoTemplate,
songTemplate,
otherVideoTemplate,
remoteStreamTemplate,
minifier,
xml,
cancellationToken);
break;
}
}
await xml.FlushAsync();
string tempFile = Path.GetTempFileName();
await File.WriteAllBytesAsync(tempFile, ms.ToArray(), cancellationToken);
File.Move(tempFile, targetFile, true);
}
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
{
// do nothing
}
}
private async Task WriteScheduleXml(
RefreshChannelData request,
PlayoutScheduleKind scheduleKind,
List<PlayoutItem> sorted,
XmlTemplateContext templateContext,
Template movieTemplate,
Template episodeTemplate,
Template musicVideoTemplate,
Template songTemplate,
Template otherVideoTemplate,
Template remoteStreamTemplate,
XmlMinifier minifier,
XmlWriter xml,
CancellationToken cancellationToken)
{
XmltvTimeZone xmltvTimeZone = await _configElementRepository
.GetValue<XmltvTimeZone>(ConfigElementKey.XmltvTimeZone, cancellationToken)
.IfNoneAsync(XmltvTimeZone.Local);
XmltvBlockBehavior xmltvBlockBehavior = await _configElementRepository
.GetValue<XmltvBlockBehavior>(ConfigElementKey.XmltvBlockBehavior, cancellationToken)
.IfNoneAsync(XmltvBlockBehavior.SplitTimeEvenly);
// guide-group / filler-merge logic is shared with the JSON guide query so the two cannot drift
foreach (ChannelGuideEntry entry in ChannelGuideProjector.Project(
scheduleKind,
sorted,
xmltvTimeZone,
xmltvBlockBehavior))
{
string start = entry.Start
.ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture)
.Replace(":", string.Empty);
string stop = entry.Stop
.ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture)
.Replace(":", string.Empty);
await WriteItemToXml(
request,
entry.DisplayItem,
start,
stop,
entry.HasCustomTitle,
templateContext,
movieTemplate,
episodeTemplate,
musicVideoTemplate,
songTemplate,
otherVideoTemplate,
remoteStreamTemplate,
minifier,
xml);
}
}
private static async Task WriteItemToXml(
RefreshChannelData request,
PlayoutItem displayItem,
string start,
string stop,
bool hasCustomTitle,
XmlTemplateContext templateContext,
Template movieTemplate,
Template episodeTemplate,
Template musicVideoTemplate,
Template songTemplate,
Template otherVideoTemplate,
Template remoteStreamTemplate,
XmlMinifier minifier,
XmlWriter xml)
{
string title = ChannelGuideMetadata.GetTitle(displayItem);
string subtitle = ChannelGuideMetadata.GetSubtitle(displayItem);
Option<string> maybeTemplateOutput = displayItem.MediaItem switch
{
Movie templateMovie => await ProcessMovieTemplate(
request,
templateMovie,
start,
stop,
hasCustomTitle,
displayItem,
title,
templateContext,
movieTemplate),
Episode templateEpisode => await ProcessEpisodeTemplate(
request,
templateEpisode,
start,
stop,
hasCustomTitle,
displayItem,
title,
subtitle,
templateContext,
episodeTemplate),
MusicVideo templateMusicVideo => await ProcessMusicVideoTemplate(
request,
templateMusicVideo,
start,
stop,
hasCustomTitle,
displayItem,
title,
subtitle,
templateContext,
musicVideoTemplate),
Song templateSong => await ProcessSongTemplate(
request,
templateSong,
start,
stop,
hasCustomTitle,
displayItem,
title,
subtitle,
templateContext,
songTemplate),
OtherVideo templateOtherVideo => await ProcessOtherVideoTemplate(
request,
templateOtherVideo,
start,
stop,
hasCustomTitle,
displayItem,
title,
templateContext,
otherVideoTemplate),
RemoteStream templateRemoteStream => await ProcessRemoteStreamTemplate(
request,
templateRemoteStream,
start,
stop,
hasCustomTitle,
displayItem,
title,
templateContext,
remoteStreamTemplate),
_ => Option<string>.None
};
foreach (string templateOutput in maybeTemplateOutput)
{
MarkupMinificationResult minified = minifier.Minify(templateOutput);
await xml.WriteRawAsync(minified.MinifiedContent);
}
}
private static async Task<Option<string>> ProcessMovieTemplate(
RefreshChannelData request,
Movie templateMovie,
string start,
string stop,
bool hasCustomTitle,
PlayoutItem displayItem,
string title,
XmlTemplateContext templateContext,
Template movieTemplate)
{
foreach (MovieMetadata metadata in templateMovie.MovieMetadata.HeadOrNone())
{
metadata.Genres ??= [];
metadata.Guids ??= [];
string poster = Optional(metadata.Artwork).Flatten()
.Filter(a => a.ArtworkKind == ArtworkKind.Poster)
.HeadOrNone()
.Match(a => GetArtworkUrl(a, ArtworkKind.Poster), () => string.Empty);
var data = new
{
ProgrammeStart = start,
ProgrammeStop = stop,
ChannelId = ChannelIdentifier.FromNumber(request.ChannelNumber),
ChannelIdLegacy = ChannelIdentifier.LegacyFromNumber(request.ChannelNumber),
request.ChannelNumber,
HasCustomTitle = hasCustomTitle,
displayItem.CustomTitle,
MovieTitle = title,
MovieHasPlot = !string.IsNullOrWhiteSpace(metadata.Plot),
MoviePlot = metadata.Plot,
MovieHasYear = metadata.Year.HasValue,
MovieYear = metadata.Year,
MovieGenres = metadata.Genres.Map(g => g.Name).OrderBy(n => n),
MovieHasArtwork = !string.IsNullOrWhiteSpace(poster),
MovieArtworkUrl = poster,
MovieHasContentRating = !string.IsNullOrWhiteSpace(metadata.ContentRating),
MovieContentRating = metadata.ContentRating,
MovieGuids = metadata.Guids.Map(g => g.Guid)
};
var scriptObject = new ScriptObject();
scriptObject.Import(data);
templateContext.PushGlobal(scriptObject);
return await movieTemplate.RenderAsync(templateContext);
}
return Option<string>.None;
}
private static async Task<Option<string>> ProcessEpisodeTemplate(
RefreshChannelData request,
Episode templateEpisode,
string start,
string stop,
bool hasCustomTitle,
PlayoutItem displayItem,
string title,
string subtitle,
XmlTemplateContext templateContext,
Template episodeTemplate)
{
foreach (EpisodeMetadata metadata in templateEpisode.EpisodeMetadata.HeadOrNone())
{
metadata.Genres ??= [];
metadata.Guids ??= [];
foreach (ShowMetadata showMetadata in Optional(
templateEpisode.Season?.Show?.ShowMetadata.HeadOrNone()).Flatten())
{
showMetadata.Genres ??= [];
showMetadata.Guids ??= [];
string artworkPath = GetPrioritizedArtworkPath(showMetadata);
string thumbnailPath = GetPrioritizedArtworkPath(metadata);
var data = new
{
ProgrammeStart = start,
ProgrammeStop = stop,
ChannelId = ChannelIdentifier.FromNumber(request.ChannelNumber),
ChannelIdLegacy = ChannelIdentifier.LegacyFromNumber(request.ChannelNumber),
request.ChannelNumber,
HasCustomTitle = hasCustomTitle,
displayItem.CustomTitle,
ShowTitle = title,
EpisodeHasTitle = !string.IsNullOrWhiteSpace(subtitle),
EpisodeTitle = subtitle,
EpisodeHasPlot = !string.IsNullOrWhiteSpace(metadata.Plot),
EpisodePlot = metadata.Plot,
ShowHasYear = showMetadata.Year.HasValue,
ShowYear = showMetadata.Year,
ShowGenres = showMetadata.Genres.Map(g => g.Name).OrderBy(n => n),
EpisodeHasArtwork = !string.IsNullOrWhiteSpace(artworkPath),
EpisodeArtworkUrl = artworkPath,
EpisodeHasThumbnail = !string.IsNullOrWhiteSpace(thumbnailPath),
EpisodeThumbnailUrl = thumbnailPath,
SeasonNumber = templateEpisode.Season?.SeasonNumber ?? 0,
metadata.EpisodeNumber,
ShowHasContentRating = !string.IsNullOrWhiteSpace(showMetadata.ContentRating),
ShowContentRating = showMetadata.ContentRating,
ShowGuids = showMetadata.Guids.Map(g => g.Guid),
EpisodeGuids = metadata.Guids.Map(g => g.Guid)
};
var scriptObject = new ScriptObject();
scriptObject.Import(data);
templateContext.PushGlobal(scriptObject);
return await episodeTemplate.RenderAsync(templateContext);
}
}
return Option<string>.None;
}
private static async Task<Option<string>> ProcessMusicVideoTemplate(
RefreshChannelData request,
MusicVideo templateMusicVideo,
string start,
string stop,
bool hasCustomTitle,
PlayoutItem displayItem,
string title,
string subtitle,
XmlTemplateContext templateContext,
Template musicVideoTemplate)
{
foreach (MusicVideoMetadata metadata in templateMusicVideo.MusicVideoMetadata.HeadOrNone())
{
metadata.Genres ??= [];
metadata.Artists ??= [];
metadata.Studios ??= [];
metadata.Directors ??= [];
string artworkPath = GetPrioritizedArtworkPath(metadata);
Option<ArtistMetadata> maybeMetadata =
Optional(templateMusicVideo.Artist?.ArtistMetadata.HeadOrNone()).Flatten();
var data = new
{
ProgrammeStart = start,
ProgrammeStop = stop,
ChannelId = ChannelIdentifier.FromNumber(request.ChannelNumber),
ChannelIdLegacy = ChannelIdentifier.LegacyFromNumber(request.ChannelNumber),
request.ChannelNumber,
HasCustomTitle = hasCustomTitle,
displayItem.CustomTitle,
ArtistTitle = title,
MusicVideoTitle = subtitle,
MusicVideoHasPlot = !string.IsNullOrWhiteSpace(metadata.Plot),
MusicVideoPlot = metadata.Plot,
MusicVideoHasYear = metadata.Year.HasValue,
MusicVideoYear = metadata.Year,
MusicVideoGenres = metadata.Genres.Map(g => g.Name).OrderBy(n => n),
ArtistGenres = maybeMetadata.SelectMany(m => m.Genres.Map(g => g.Name)).OrderBy(n => n),
MusicVideoHasArtwork = !string.IsNullOrWhiteSpace(artworkPath),
MusicVideoArtworkUrl = artworkPath,
MusicVideoHasTrack = metadata.Track.HasValue,
MusicVideoTrack = metadata.Track,
MusicVideoHasAlbum = !string.IsNullOrWhiteSpace(metadata.Album),
MusicVideoAlbum = metadata.Album,
MusicVideoHasReleaseDate = metadata.ReleaseDate.HasValue,
MusicVideoReleaseDate = metadata.ReleaseDate,
MusicVideoAllArtists = metadata.Artists.Map(a => a.Name),
MusicVideoStudios = metadata.Studios.Map(s => s.Name),
MusicVideoDirectors = metadata.Directors.Map(d => d.Name)
};
var scriptObject = new ScriptObject();
scriptObject.Import(data);
templateContext.PushGlobal(scriptObject);
return await musicVideoTemplate.RenderAsync(templateContext);
}
return Option<string>.None;
}
private static async Task<Option<string>> ProcessSongTemplate(
RefreshChannelData request,
Song templateSong,
string start,
string stop,
bool hasCustomTitle,
PlayoutItem displayItem,
string title,
string subtitle,
XmlTemplateContext templateContext,
Template songTemplate)
{
foreach (SongMetadata metadata in templateSong.SongMetadata.HeadOrNone())
{
metadata.Genres ??= [];
metadata.Studios ??= [];
// Artists/AlbumArtists are NULLABLE primitive collections, so they are guarded at the read site
// rather than assigned back onto `metadata` like the navigations above (ersatztv#701/#691): they
// are scalar JSON-array columns, so `??= []` on a tracked entity would persist `[]` over NULL.
// The shipped `_song.sbntxt` only does `array.join`, but a user template is free to do anything.
List<string> songArtists = Optional(metadata.Artists).Flatten().ToList();
List<string> songAlbumArtists = Optional(metadata.AlbumArtists).Flatten().ToList();
string artworkPath = GetPrioritizedArtworkPath(metadata);
var data = new
{
ProgrammeStart = start,
ProgrammeStop = stop,
ChannelId = ChannelIdentifier.FromNumber(request.ChannelNumber),
ChannelIdLegacy = ChannelIdentifier.LegacyFromNumber(request.ChannelNumber),
request.ChannelNumber,
HasCustomTitle = hasCustomTitle,
displayItem.CustomTitle,
SongTitle = subtitle,
SongArtists = songArtists,
SongAlbumArtists = songAlbumArtists,
SongHasYear = metadata.Year.HasValue,
SongYear = metadata.Year,
SongGenres = metadata.Genres.Map(g => g.Name).OrderBy(n => n),
SongHasArtwork = !string.IsNullOrWhiteSpace(artworkPath),
SongArtworkUrl = artworkPath,
SongHasTrack = !string.IsNullOrWhiteSpace(metadata.Track),
SongTrack = metadata.Track,
SongHasComment = !string.IsNullOrWhiteSpace(metadata.Comment),
SongComment = metadata.Comment,
SongHasAlbum = !string.IsNullOrWhiteSpace(metadata.Album),
SongAlbum = metadata.Album,
SongHasReleaseDate = metadata.ReleaseDate.HasValue,
SongReleaseDate = metadata.ReleaseDate,
SongStudios = metadata.Studios.Map(s => s.Name)
};
var scriptObject = new ScriptObject();
scriptObject.Import(data);
templateContext.PushGlobal(scriptObject);
return await songTemplate.RenderAsync(templateContext);
}
return Option<string>.None;
}
private static async Task<Option<string>> ProcessOtherVideoTemplate(
RefreshChannelData request,
OtherVideo templateOtherVideo,
string start,
string stop,
bool hasCustomTitle,
PlayoutItem displayItem,
string title,
XmlTemplateContext templateContext,
Template otherVideoTemplate)
{
foreach (OtherVideoMetadata metadata in templateOtherVideo.OtherVideoMetadata.HeadOrNone())
{
metadata.Genres ??= [];
metadata.Guids ??= [];
string artworkPath = GetPrioritizedArtworkPath(metadata);
var data = new
{
ProgrammeStart = start,
ProgrammeStop = stop,
ChannelId = ChannelIdentifier.FromNumber(request.ChannelNumber),
ChannelIdLegacy = ChannelIdentifier.LegacyFromNumber(request.ChannelNumber),
request.ChannelNumber,
HasCustomTitle = hasCustomTitle,
displayItem.CustomTitle,
OtherVideoTitle = title,
OtherVideoHasPlot = !string.IsNullOrWhiteSpace(metadata.Plot),
OtherVideoPlot = metadata.Plot,
OtherVideoHasYear = metadata.Year.HasValue,
OtherVideoYear = metadata.Year,
OtherVideoHasArtwork = !string.IsNullOrWhiteSpace(artworkPath),
OtherVideoArtworkUrl = artworkPath,
OtherVideoGenres = metadata.Genres.Map(g => g.Name).OrderBy(n => n),
OtherVideoHasContentRating = !string.IsNullOrWhiteSpace(metadata.ContentRating),
OtherVideoContentRating = metadata.ContentRating
};
var scriptObject = new ScriptObject();
scriptObject.Import(data);
templateContext.PushGlobal(scriptObject);
return await otherVideoTemplate.RenderAsync(templateContext);
}
return Option<string>.None;
}
private static async Task<Option<string>> ProcessRemoteStreamTemplate(
RefreshChannelData request,
RemoteStream templateRemoteStream,
string start,
string stop,
bool hasCustomTitle,
PlayoutItem displayItem,
string title,
XmlTemplateContext templateContext,
Template remoteStreamTemplate)
{
foreach (RemoteStreamMetadata metadata in templateRemoteStream.RemoteStreamMetadata.HeadOrNone())
{
metadata.Genres ??= [];
metadata.Guids ??= [];
string artworkPath = GetPrioritizedArtworkPath(metadata);
var data = new
{
ProgrammeStart = start,
ProgrammeStop = stop,
ChannelId = ChannelIdentifier.FromNumber(request.ChannelNumber),
ChannelIdLegacy = ChannelIdentifier.LegacyFromNumber(request.ChannelNumber),
request.ChannelNumber,
HasCustomTitle = hasCustomTitle,
displayItem.CustomTitle,
RemoteStreamTitle = title,
RemoteStreamHasPlot = !string.IsNullOrWhiteSpace(metadata.Plot),
RemoteStreamPlot = metadata.Plot,
RemoteStreamHasYear = metadata.Year.HasValue,
RemoteStreamYear = metadata.Year,
RemoteStreamHasArtwork = !string.IsNullOrWhiteSpace(artworkPath),
RemoteStreamArtworkUrl = artworkPath,
RemoteStreamGenres = metadata.Genres.Map(g => g.Name).OrderBy(n => n),
RemoteStreamHasContentRating = !string.IsNullOrWhiteSpace(metadata.ContentRating),
RemoteStreamContentRating = metadata.ContentRating
};
var scriptObject = new ScriptObject();
scriptObject.Import(data);
templateContext.PushGlobal(scriptObject);
return await remoteStreamTemplate.RenderAsync(templateContext);
}
return Option<string>.None;
}
private string GetMovieTemplateFileName()
{
string templateFileName = _localFileSystem.GetCustomOrDefaultFile(
FileSystemLayout.ChannelGuideTemplatesFolder,
"movie.sbntxt");
// fail if file doesn't exist
if (!_fileSystem.File.Exists(templateFileName))
{
_logger.LogError(
"Unable to generate movie XMLTV fragment without template file {File}; please restart ErsatzTV",
templateFileName);
return null;
}
return templateFileName;
}
private string GetEpisodeTemplateFileName()
{
string templateFileName = _localFileSystem.GetCustomOrDefaultFile(
FileSystemLayout.ChannelGuideTemplatesFolder,
"episode.sbntxt");
// fail if file doesn't exist
if (!_fileSystem.File.Exists(templateFileName))
{
_logger.LogError(
"Unable to generate episode XMLTV fragment without template file {File}; please restart ErsatzTV",
templateFileName);
return null;
}
return templateFileName;
}
private string GetMusicVideoTemplateFileName()
{
string templateFileName = _localFileSystem.GetCustomOrDefaultFile(
FileSystemLayout.ChannelGuideTemplatesFolder,
"musicVideo.sbntxt");
// fail if file doesn't exist
if (!_fileSystem.File.Exists(templateFileName))
{
_logger.LogError(
"Unable to generate music video XMLTV fragment without template file {File}; please restart ErsatzTV",
templateFileName);
return null;
}
return templateFileName;
}
private string GetSongTemplateFileName()
{
string templateFileName = _localFileSystem.GetCustomOrDefaultFile(
FileSystemLayout.ChannelGuideTemplatesFolder,
"song.sbntxt");
// fail if file doesn't exist
if (!_fileSystem.File.Exists(templateFileName))
{
_logger.LogError(
"Unable to generate song XMLTV fragment without template file {File}; please restart ErsatzTV",
templateFileName);
return null;
}
return templateFileName;
}
private string GetOtherVideoTemplateFileName()
{
string templateFileName = _localFileSystem.GetCustomOrDefaultFile(
FileSystemLayout.ChannelGuideTemplatesFolder,
"otherVideo.sbntxt");
// fail if file doesn't exist
if (!_fileSystem.File.Exists(templateFileName))
{
_logger.LogError(
"Unable to generate other video XMLTV fragment without template file {File}; please restart ErsatzTV",
templateFileName);
return null;
}
return templateFileName;
}
private string GetRemoteStreamTemplateFileName()
{
string templateFileName = _localFileSystem.GetCustomOrDefaultFile(
FileSystemLayout.ChannelGuideTemplatesFolder,
"remoteStream.sbntxt");
// fail if file doesn't exist
if (!_fileSystem.File.Exists(templateFileName))
{
_logger.LogError(
"Unable to generate remote stream XMLTV fragment without template file {File}; please restart ErsatzTV",
templateFileName);
return null;
}
return templateFileName;
}
private static string GetArtworkUrl(Artwork artwork, ArtworkKind artworkKind)
{
string artworkPath = artwork.Path;
int height = artworkKind switch
{
ArtworkKind.Thumbnail => 220,
_ => 440
};
if (artworkPath.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
artworkPath.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
{
return artworkPath;
}
if (artworkPath.StartsWith("jellyfin://", StringComparison.OrdinalIgnoreCase))
{
artworkPath = JellyfinUrl.PlaceholderProxyForArtwork(artworkPath, artworkKind, height);
}
else if (artworkPath.StartsWith("emby://", StringComparison.OrdinalIgnoreCase))
{
artworkPath = EmbyUrl.PlaceholderProxyForArtwork(artworkPath, artworkKind, height);
}
else
{
string artworkFolder = artworkKind switch
{
ArtworkKind.Thumbnail => "thumbnails",
_ => "posters"
};
artworkPath = $"{{RequestBase}}/iptv/artwork/{artworkFolder}/{artwork.Path}.jpg{{AccessTokenUri}}";
}
return artworkPath;
}
private static string GetPrioritizedArtworkPath(Metadata metadata)
{
Option<string> maybeArtwork = Optional(metadata.Artwork).Flatten()
.Filter(a => a.ArtworkKind == ArtworkKind.Poster)
.HeadOrNone()
.Map(a => GetArtworkUrl(a, ArtworkKind.Poster));
if (maybeArtwork.IsNone)
{
maybeArtwork = Optional(metadata.Artwork).Flatten()
.Filter(a => a.ArtworkKind == ArtworkKind.Thumbnail)
.HeadOrNone()
.Map(a => GetArtworkUrl(a, ArtworkKind.Thumbnail));
}
return maybeArtwork.IfNone(string.Empty);
}
private async Task<List<PlayoutItem>> CollectExternalJsonItems(string path)
{
var result = new List<PlayoutItem>();
if (_fileSystem.File.Exists(path))
{
Option<ExternalJsonChannel> maybeChannel = JsonConvert.DeserializeObject<ExternalJsonChannel>(
await File.ReadAllTextAsync(path));
// must deserialize channel from json
foreach (ExternalJsonChannel channel in maybeChannel)
{
// TODO: null start time should log and throw
DateTimeOffset startTime = DateTimeOffset.Parse(
channel.StartTime ?? string.Empty,
CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal).ToLocalTime();
for (var i = 0; i < channel.Programs.Length; i++)
{
ExternalJsonProgram program = channel.Programs[i];
int milliseconds = program.Duration;
DateTimeOffset nextStart = startTime + TimeSpan.FromMilliseconds(milliseconds);
if (program.Duration >= channel.GuideMinimumDurationSeconds * 1000)
{
result.Add(BuildPlayoutItem(startTime, program, i));
}
startTime = nextStart;
}
}
}
return result;
}
private static PlayoutItem BuildPlayoutItem(DateTimeOffset startTime, ExternalJsonProgram program, int count)
{
MediaItem mediaItem = program.Type switch
{
"episode" => BuildEpisode(program),
_ => BuildMovie(program)
};
return new PlayoutItem
{
Start = startTime.UtcDateTime,
Finish = startTime.AddMilliseconds(program.Duration).UtcDateTime,
FillerKind = FillerKind.None,
ChapterTitle = null,
GuideFinish = null,
GuideGroup = count,
CustomTitle = null,
InPoint = TimeSpan.Zero,
OutPoint = TimeSpan.FromMilliseconds(program.Duration),
MediaItem = mediaItem
};
}
private static Episode BuildEpisode(ExternalJsonProgram program)
{
var artwork = new List<Artwork>();
if (!string.IsNullOrWhiteSpace(program.Icon))
{
artwork.Add(
new Artwork
{
ArtworkKind = ArtworkKind.Thumbnail,
Path = program.Icon,
SourcePath = program.Icon
});
}
return new Episode
{
MediaVersions =
[
new MediaVersion
{
Duration = TimeSpan.FromMilliseconds(program.Duration)
}
],
EpisodeMetadata =
[
new EpisodeMetadata
{
EpisodeNumber = program.Episode,
Title = program.Title
}
],
Season = new Season
{
SeasonNumber = program.Season,
Show = new Show
{
ShowMetadata =
[
new ShowMetadata
{
Title = program.ShowTitle,
Artwork = artwork
}
]
}
}
};
}
private static Movie BuildMovie(ExternalJsonProgram program)
{
var artwork = new List<Artwork>();
if (!string.IsNullOrWhiteSpace(program.Icon))
{
artwork.Add(
new Artwork
{
ArtworkKind = ArtworkKind.Poster,
Path = program.Icon,
SourcePath = program.Icon
});
}
return new Movie
{
MediaVersions =
[
new MediaVersion
{
Duration = TimeSpan.FromMilliseconds(program.Duration)
}
],
MovieMetadata =
[
new MovieMetadata
{
Title = program.Title,
Year = program.Year,
Artwork = artwork
}
]
};
}
}