mirror of https://github.com/ErsatzTV/ErsatzTV.git
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
688 lines
27 KiB
688 lines
27 KiB
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); |
|
}
|
|
|