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
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>
1057 lines
40 KiB
C#
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
|
|
}
|
|
]
|
|
};
|
|
}
|
|
}
|