* include all block playout items in xmltv * double check whether channel preview will work
689 lines
27 KiB
C#
689 lines
27 KiB
C#
using System.Globalization;
|
|
using System.Xml;
|
|
using ErsatzTV.Core;
|
|
using ErsatzTV.Core.Domain;
|
|
using ErsatzTV.Core.Domain.Filler;
|
|
using ErsatzTV.Core.Emby;
|
|
using ErsatzTV.Core.Interfaces.Metadata;
|
|
using ErsatzTV.Core.Jellyfin;
|
|
using ErsatzTV.Core.Streaming;
|
|
using ErsatzTV.Infrastructure.Data;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.IO;
|
|
using Newtonsoft.Json;
|
|
|
|
namespace ErsatzTV.Application.Channels;
|
|
|
|
public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
|
{
|
|
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
|
private readonly ILocalFileSystem _localFileSystem;
|
|
private readonly ILogger<RefreshChannelDataHandler> _logger;
|
|
private readonly RecyclableMemoryStreamManager _recyclableMemoryStreamManager;
|
|
|
|
public RefreshChannelDataHandler(
|
|
RecyclableMemoryStreamManager recyclableMemoryStreamManager,
|
|
IDbContextFactory<TvContext> dbContextFactory,
|
|
ILocalFileSystem localFileSystem,
|
|
ILogger<RefreshChannelDataHandler> logger)
|
|
{
|
|
_recyclableMemoryStreamManager = recyclableMemoryStreamManager;
|
|
_dbContextFactory = dbContextFactory;
|
|
_localFileSystem = localFileSystem;
|
|
_logger = logger;
|
|
}
|
|
|
|
public async Task Handle(RefreshChannelData request, CancellationToken cancellationToken)
|
|
{
|
|
_localFileSystem.EnsureFolderExists(FileSystemLayout.ChannelGuideCacheFolder);
|
|
|
|
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
|
|
|
List<Playout> playouts = await dbContext.Playouts
|
|
.AsNoTracking()
|
|
.Filter(pi => pi.Channel.Number == request.ChannelNumber)
|
|
.Include(p => p.Items)
|
|
.ThenInclude(i => i.MediaItem)
|
|
.ThenInclude(i => (i as Episode).EpisodeMetadata)
|
|
.Include(p => p.Items)
|
|
.ThenInclude(i => i.MediaItem)
|
|
.ThenInclude(i => (i as Episode).Season)
|
|
.ThenInclude(s => s.Show)
|
|
.ThenInclude(s => s.ShowMetadata)
|
|
.ThenInclude(sm => sm.Artwork)
|
|
.Include(p => p.Items)
|
|
.ThenInclude(i => i.MediaItem)
|
|
.ThenInclude(i => (i as Episode).Season)
|
|
.ThenInclude(s => s.Show)
|
|
.ThenInclude(s => s.ShowMetadata)
|
|
.ThenInclude(em => em.Genres)
|
|
.Include(p => p.Items)
|
|
.ThenInclude(i => i.MediaItem)
|
|
.ThenInclude(i => (i as Movie).MovieMetadata)
|
|
.ThenInclude(mm => mm.Artwork)
|
|
.Include(p => p.Items)
|
|
.ThenInclude(i => i.MediaItem)
|
|
.ThenInclude(i => (i as Movie).MovieMetadata)
|
|
.ThenInclude(mm => mm.Genres)
|
|
.Include(p => p.Items)
|
|
.ThenInclude(i => i.MediaItem)
|
|
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
|
|
.ThenInclude(mm => mm.Artwork)
|
|
.Include(p => p.Items)
|
|
.ThenInclude(i => i.MediaItem)
|
|
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
|
|
.ThenInclude(mvm => mvm.Genres)
|
|
.Include(p => p.Items)
|
|
.ThenInclude(i => i.MediaItem)
|
|
.ThenInclude(i => (i as MusicVideo).Artist)
|
|
.ThenInclude(a => a.ArtistMetadata)
|
|
.ThenInclude(am => am.Genres)
|
|
.Include(p => p.Items)
|
|
.ThenInclude(i => i.MediaItem)
|
|
.ThenInclude(i => (i as OtherVideo).OtherVideoMetadata)
|
|
.ThenInclude(vm => vm.Artwork)
|
|
.Include(p => p.Items)
|
|
.ThenInclude(i => i.MediaItem)
|
|
.ThenInclude(i => (i as Song).SongMetadata)
|
|
.ThenInclude(vm => vm.Artwork)
|
|
.ToListAsync(cancellationToken);
|
|
|
|
List<PlayoutItem> sorted = [];
|
|
|
|
foreach (Playout playout in playouts)
|
|
{
|
|
switch (playout.ProgramSchedulePlayoutType)
|
|
{
|
|
case ProgramSchedulePlayoutType.Flood:
|
|
case ProgramSchedulePlayoutType.Block:
|
|
sorted.AddRange(playouts.Collect(p => p.Items).OrderBy(pi => pi.Start));
|
|
break;
|
|
case ProgramSchedulePlayoutType.ExternalJson:
|
|
sorted.AddRange(await CollectExternalJsonItems(playout.ExternalJsonFile));
|
|
break;
|
|
}
|
|
}
|
|
|
|
await using RecyclableMemoryStream ms = _recyclableMemoryStreamManager.GetStream();
|
|
await using var xml = XmlWriter.Create(
|
|
ms,
|
|
new XmlWriterSettings { Async = true, ConformanceLevel = ConformanceLevel.Fragment });
|
|
|
|
// skip all filler that isn't pre-roll
|
|
var i = 0;
|
|
while (i < sorted.Count && sorted[i].FillerKind != FillerKind.None &&
|
|
sorted[i].FillerKind != FillerKind.PreRoll)
|
|
{
|
|
i++;
|
|
}
|
|
|
|
while (i < sorted.Count)
|
|
{
|
|
PlayoutItem startItem = sorted[i];
|
|
int j = i;
|
|
while (sorted[j].FillerKind != FillerKind.None && j + 1 < sorted.Count)
|
|
{
|
|
j++;
|
|
}
|
|
|
|
PlayoutItem displayItem = sorted[j];
|
|
bool hasCustomTitle = !string.IsNullOrWhiteSpace(startItem.CustomTitle);
|
|
|
|
int finishIndex = j;
|
|
while (finishIndex + 1 < sorted.Count && (sorted[finishIndex + 1].GuideGroup == startItem.GuideGroup
|
|
|| sorted[finishIndex + 1].FillerKind is FillerKind.GuideMode
|
|
or FillerKind.Tail or FillerKind.Fallback))
|
|
{
|
|
finishIndex++;
|
|
}
|
|
|
|
int customShowId = -1;
|
|
if (displayItem.MediaItem is Episode ep)
|
|
{
|
|
customShowId = ep.Season.ShowId;
|
|
}
|
|
|
|
bool isSameCustomShow = hasCustomTitle;
|
|
for (int x = j; x <= finishIndex; x++)
|
|
{
|
|
isSameCustomShow = isSameCustomShow && sorted[x].MediaItem is Episode e &&
|
|
customShowId == e.Season.ShowId;
|
|
}
|
|
|
|
PlayoutItem finishItem = sorted[finishIndex];
|
|
i = finishIndex;
|
|
|
|
string start = startItem.StartOffset.ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture)
|
|
.Replace(":", string.Empty);
|
|
string stop = displayItem.GuideFinishOffset.HasValue
|
|
? displayItem.GuideFinishOffset.Value.ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture)
|
|
.Replace(":", string.Empty)
|
|
: finishItem.FinishOffset.ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture)
|
|
.Replace(":", string.Empty);
|
|
|
|
string title = GetTitle(displayItem);
|
|
string subtitle = GetSubtitle(displayItem);
|
|
string description = GetDescription(displayItem);
|
|
Option<ContentRating> contentRating = GetContentRating(displayItem);
|
|
|
|
await xml.WriteStartElementAsync(null, "programme", null);
|
|
await xml.WriteAttributeStringAsync(null, "start", null, start);
|
|
await xml.WriteAttributeStringAsync(null, "stop", null, stop);
|
|
await xml.WriteAttributeStringAsync(null, "channel", null, $"{request.ChannelNumber}.etv");
|
|
|
|
await xml.WriteStartElementAsync(null, "title", null);
|
|
await xml.WriteAttributeStringAsync(null, "lang", null, "en");
|
|
await xml.WriteStringAsync(title);
|
|
await xml.WriteEndElementAsync(); // title
|
|
|
|
if (!string.IsNullOrWhiteSpace(subtitle))
|
|
{
|
|
await xml.WriteStartElementAsync(null, "sub-title", null);
|
|
await xml.WriteAttributeStringAsync(null, "lang", null, "en");
|
|
await xml.WriteStringAsync(subtitle);
|
|
await xml.WriteEndElementAsync(); // subtitle
|
|
}
|
|
|
|
if (!isSameCustomShow)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(description))
|
|
{
|
|
await xml.WriteStartElementAsync(null, "desc", null);
|
|
await xml.WriteAttributeStringAsync(null, "lang", null, "en");
|
|
await xml.WriteStringAsync(description);
|
|
await xml.WriteEndElementAsync(); // desc
|
|
}
|
|
}
|
|
|
|
if (!hasCustomTitle && displayItem.MediaItem is Movie movie)
|
|
{
|
|
foreach (MovieMetadata metadata in movie.MovieMetadata.HeadOrNone())
|
|
{
|
|
if (metadata.Year.HasValue)
|
|
{
|
|
await xml.WriteStartElementAsync(null, "date", null);
|
|
await xml.WriteStringAsync(metadata.Year.Value.ToString(CultureInfo.InvariantCulture));
|
|
await xml.WriteEndElementAsync(); // date
|
|
}
|
|
|
|
await xml.WriteStartElementAsync(null, "category", null);
|
|
await xml.WriteAttributeStringAsync(null, "lang", null, "en");
|
|
await xml.WriteStringAsync("Movie");
|
|
await xml.WriteEndElementAsync(); // category
|
|
|
|
foreach (Genre genre in Optional(metadata.Genres).Flatten().OrderBy(g => g.Name))
|
|
{
|
|
await xml.WriteStartElementAsync(null, "category", null);
|
|
await xml.WriteAttributeStringAsync(null, "lang", null, "en");
|
|
await xml.WriteStringAsync(genre.Name);
|
|
await xml.WriteEndElementAsync(); // category
|
|
}
|
|
|
|
string poster = Optional(metadata.Artwork).Flatten()
|
|
.Filter(a => a.ArtworkKind == ArtworkKind.Poster)
|
|
.HeadOrNone()
|
|
.Match(a => GetArtworkUrl(a, ArtworkKind.Poster), () => string.Empty);
|
|
|
|
if (!string.IsNullOrWhiteSpace(poster))
|
|
{
|
|
await xml.WriteStartElementAsync(null, "icon", null);
|
|
await xml.WriteAttributeStringAsync(null, "src", null, poster);
|
|
await xml.WriteEndElementAsync(); // icon
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!hasCustomTitle && displayItem.MediaItem is MusicVideo musicVideo)
|
|
{
|
|
foreach (MusicVideoMetadata metadata in musicVideo.MusicVideoMetadata.HeadOrNone())
|
|
{
|
|
if (metadata.Year.HasValue)
|
|
{
|
|
await xml.WriteStartElementAsync(null, "date", null);
|
|
await xml.WriteStringAsync(metadata.Year.Value.ToString(CultureInfo.InvariantCulture));
|
|
await xml.WriteEndElementAsync(); // date
|
|
}
|
|
|
|
await xml.WriteStartElementAsync(null, "category", null);
|
|
await xml.WriteAttributeStringAsync(null, "lang", null, "en");
|
|
await xml.WriteStringAsync("Music");
|
|
await xml.WriteEndElementAsync(); // category
|
|
|
|
// music video genres
|
|
foreach (Genre genre in Optional(metadata.Genres).Flatten().OrderBy(g => g.Name))
|
|
{
|
|
await xml.WriteStartElementAsync(null, "category", null);
|
|
await xml.WriteAttributeStringAsync(null, "lang", null, "en");
|
|
await xml.WriteStringAsync(genre.Name);
|
|
await xml.WriteEndElementAsync(); // category
|
|
}
|
|
|
|
// artist genres
|
|
Option<ArtistMetadata> maybeMetadata =
|
|
Optional(musicVideo.Artist?.ArtistMetadata.HeadOrNone()).Flatten();
|
|
foreach (ArtistMetadata artistMetadata in maybeMetadata)
|
|
{
|
|
foreach (Genre genre in Optional(artistMetadata.Genres).Flatten().OrderBy(g => g.Name))
|
|
{
|
|
await xml.WriteStartElementAsync(null, "category", null);
|
|
await xml.WriteAttributeStringAsync(null, "lang", null, "en");
|
|
await xml.WriteStringAsync(genre.Name);
|
|
await xml.WriteEndElementAsync(); // category
|
|
}
|
|
}
|
|
|
|
string artworkPath = GetPrioritizedArtworkPath(metadata);
|
|
if (!string.IsNullOrWhiteSpace(artworkPath))
|
|
{
|
|
await xml.WriteStartElementAsync(null, "icon", null);
|
|
await xml.WriteAttributeStringAsync(null, "src", null, artworkPath);
|
|
await xml.WriteEndElementAsync(); // icon
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!hasCustomTitle && displayItem.MediaItem is Song song)
|
|
{
|
|
await xml.WriteStartElementAsync(null, "category", null);
|
|
await xml.WriteAttributeStringAsync(null, "lang", null, "en");
|
|
await xml.WriteStringAsync("Music");
|
|
await xml.WriteEndElementAsync(); // category
|
|
|
|
foreach (SongMetadata metadata in song.SongMetadata.HeadOrNone())
|
|
{
|
|
string artworkPath = GetPrioritizedArtworkPath(metadata);
|
|
if (!string.IsNullOrWhiteSpace(artworkPath))
|
|
{
|
|
await xml.WriteStartElementAsync(null, "icon", null);
|
|
await xml.WriteAttributeStringAsync(null, "src", null, artworkPath);
|
|
await xml.WriteEndElementAsync(); // icon
|
|
}
|
|
}
|
|
}
|
|
|
|
if (displayItem.MediaItem is Episode episode && (!hasCustomTitle || isSameCustomShow))
|
|
{
|
|
Option<ShowMetadata> maybeMetadata =
|
|
Optional(episode.Season?.Show?.ShowMetadata.HeadOrNone()).Flatten();
|
|
foreach (ShowMetadata metadata in maybeMetadata)
|
|
{
|
|
await xml.WriteStartElementAsync(null, "category", null);
|
|
await xml.WriteAttributeStringAsync(null, "lang", null, "en");
|
|
await xml.WriteStringAsync("Series");
|
|
await xml.WriteEndElementAsync(); // category
|
|
|
|
foreach (Genre genre in Optional(metadata.Genres).Flatten().OrderBy(g => g.Name))
|
|
{
|
|
await xml.WriteStartElementAsync(null, "category", null);
|
|
await xml.WriteAttributeStringAsync(null, "lang", null, "en");
|
|
await xml.WriteStringAsync(genre.Name);
|
|
await xml.WriteEndElementAsync(); // category
|
|
}
|
|
|
|
string artworkPath = GetPrioritizedArtworkPath(metadata);
|
|
if (!string.IsNullOrWhiteSpace(artworkPath))
|
|
{
|
|
await xml.WriteStartElementAsync(null, "icon", null);
|
|
await xml.WriteAttributeStringAsync(null, "src", null, artworkPath);
|
|
await xml.WriteEndElementAsync(); // icon
|
|
}
|
|
}
|
|
|
|
if (!isSameCustomShow)
|
|
{
|
|
int s = await Optional(episode.Season?.SeasonNumber).IfNoneAsync(-1);
|
|
// TODO: multi-episode?
|
|
int e = episode.EpisodeMetadata.HeadOrNone().Match(em => em.EpisodeNumber, -1);
|
|
if (s >= 0 && e > 0)
|
|
{
|
|
await xml.WriteStartElementAsync(null, "episode-num", null);
|
|
await xml.WriteAttributeStringAsync(null, "system", null, "onscreen");
|
|
await xml.WriteStringAsync($"S{s:00}E{e:00}");
|
|
await xml.WriteEndElementAsync(); // episode-num
|
|
|
|
await xml.WriteStartElementAsync(null, "episode-num", null);
|
|
await xml.WriteAttributeStringAsync(null, "system", null, "xmltv_ns");
|
|
await xml.WriteStringAsync($"{s - 1}.{e - 1}.0/1");
|
|
await xml.WriteEndElementAsync(); // episode-num
|
|
}
|
|
}
|
|
}
|
|
|
|
await xml.WriteStartElementAsync(null, "previously-shown", null);
|
|
await xml.WriteEndElementAsync(); // previously-shown
|
|
|
|
foreach (ContentRating rating in contentRating)
|
|
{
|
|
await xml.WriteStartElementAsync(null, "rating", null);
|
|
foreach (string system in rating.System)
|
|
{
|
|
await xml.WriteAttributeStringAsync(null, "system", null, system);
|
|
}
|
|
|
|
await xml.WriteStartElementAsync(null, "value", null);
|
|
await xml.WriteStringAsync(rating.Value);
|
|
await xml.WriteEndElementAsync(); // value
|
|
await xml.WriteEndElementAsync(); // rating
|
|
}
|
|
|
|
await xml.WriteEndElementAsync(); // programme
|
|
|
|
i++;
|
|
}
|
|
|
|
await xml.FlushAsync();
|
|
|
|
string tempFile = Path.GetTempFileName();
|
|
await File.WriteAllBytesAsync(tempFile, ms.ToArray(), cancellationToken);
|
|
|
|
string targetFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, $"{request.ChannelNumber}.xml");
|
|
File.Move(tempFile, targetFile, true);
|
|
}
|
|
|
|
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 GetTitle(PlayoutItem playoutItem)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(playoutItem.CustomTitle))
|
|
{
|
|
return playoutItem.CustomTitle;
|
|
}
|
|
|
|
return playoutItem.MediaItem switch
|
|
{
|
|
Movie m => m.MovieMetadata.HeadOrNone().Map(mm => mm.Title ?? string.Empty)
|
|
.IfNone("[unknown movie]"),
|
|
Episode e => e.Season.Show.ShowMetadata.HeadOrNone().Map(em => em.Title ?? string.Empty)
|
|
.IfNone("[unknown show]"),
|
|
MusicVideo mv => mv.Artist.ArtistMetadata.HeadOrNone().Map(am => am.Title ?? string.Empty)
|
|
.IfNone("[unknown artist]"),
|
|
OtherVideo ov => ov.OtherVideoMetadata.HeadOrNone().Map(vm => vm.Title ?? string.Empty)
|
|
.IfNone("[unknown video]"),
|
|
Song s => s.SongMetadata.HeadOrNone().Map(sm => sm.Artist ?? string.Empty)
|
|
.IfNone("[unknown artist]"),
|
|
_ => "[unknown]"
|
|
};
|
|
}
|
|
|
|
private static string GetSubtitle(PlayoutItem playoutItem)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(playoutItem.CustomTitle))
|
|
{
|
|
return string.Empty;
|
|
}
|
|
|
|
return playoutItem.MediaItem switch
|
|
{
|
|
Episode e => e.EpisodeMetadata.HeadOrNone().Match(
|
|
em => em.Title ?? string.Empty,
|
|
() => string.Empty),
|
|
MusicVideo mv => mv.MusicVideoMetadata.HeadOrNone().Match(
|
|
mvm => mvm.Title ?? string.Empty,
|
|
() => string.Empty),
|
|
Song s => s.SongMetadata.HeadOrNone().Match(
|
|
mvm => mvm.Title ?? string.Empty,
|
|
() => string.Empty),
|
|
_ => string.Empty
|
|
};
|
|
}
|
|
|
|
private static string GetDescription(PlayoutItem playoutItem)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(playoutItem.CustomTitle))
|
|
{
|
|
return string.Empty;
|
|
}
|
|
|
|
return playoutItem.MediaItem switch
|
|
{
|
|
Movie m => m.MovieMetadata.HeadOrNone().Map(mm => mm.Plot ?? string.Empty).IfNone(string.Empty),
|
|
Episode e => e.EpisodeMetadata.HeadOrNone().Map(em => em.Plot ?? string.Empty)
|
|
.IfNone(string.Empty),
|
|
MusicVideo mv => mv.MusicVideoMetadata.HeadOrNone().Map(mvm => mvm.Plot ?? string.Empty)
|
|
.IfNone(string.Empty),
|
|
OtherVideo ov => ov.OtherVideoMetadata.HeadOrNone().Map(ovm => ovm.Plot ?? string.Empty)
|
|
.IfNone(string.Empty),
|
|
_ => string.Empty
|
|
};
|
|
}
|
|
|
|
private Option<ContentRating> GetContentRating(PlayoutItem playoutItem)
|
|
{
|
|
try
|
|
{
|
|
return playoutItem.MediaItem switch
|
|
{
|
|
Movie m => m.MovieMetadata
|
|
.HeadOrNone()
|
|
.Match(mm => ParseContentRating(mm.ContentRating, "MPAA"), () => None),
|
|
Episode e => e.Season.Show.ShowMetadata
|
|
.HeadOrNone()
|
|
.Match(sm => ParseContentRating(sm.ContentRating, "VCHIP"), () => None),
|
|
_ => None
|
|
};
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "Failed to get content rating for playout item {Item}", GetTitle(playoutItem));
|
|
return None;
|
|
}
|
|
}
|
|
|
|
private static Option<ContentRating> ParseContentRating(string contentRating, string system)
|
|
{
|
|
Option<string> maybeFirst = (contentRating ?? string.Empty).Split('/').HeadOrNone();
|
|
return maybeFirst.Map(
|
|
first =>
|
|
{
|
|
string[] split = first.Split(':');
|
|
if (split.Length == 2)
|
|
{
|
|
return split[0].Equals("us", StringComparison.OrdinalIgnoreCase)
|
|
? new ContentRating(system, split[1].ToUpperInvariant())
|
|
: new ContentRating(None, split[1].ToUpperInvariant());
|
|
}
|
|
|
|
return string.IsNullOrWhiteSpace(first)
|
|
? Option<ContentRating>.None
|
|
: new ContentRating(None, first);
|
|
}).Flatten();
|
|
}
|
|
|
|
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 (_localFileSystem.FileExists(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
|
|
}
|
|
]
|
|
};
|
|
}
|
|
|
|
private sealed record ContentRating(Option<string> System, string Value);
|
|
}
|