mirror of https://github.com/ErsatzTV/ErsatzTV.git
Browse Source
* cache channel list for xmltv * used cached channel data for xmltv * fixes * update changelogpull/1229/head
18 changed files with 813 additions and 528 deletions
@ -0,0 +1,3 @@ |
|||||||
|
namespace ErsatzTV.Application.Channels; |
||||||
|
|
||||||
|
public record RefreshChannelData(string ChannelNumber) : IRequest, IBackgroundServiceRequest; |
||||||
@ -0,0 +1,521 @@ |
|||||||
|
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.Infrastructure.Data; |
||||||
|
using Microsoft.EntityFrameworkCore; |
||||||
|
using Microsoft.Extensions.Logging; |
||||||
|
using Microsoft.IO; |
||||||
|
|
||||||
|
namespace ErsatzTV.Application.Channels; |
||||||
|
|
||||||
|
public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData> |
||||||
|
{ |
||||||
|
private readonly RecyclableMemoryStreamManager _recyclableMemoryStreamManager; |
||||||
|
private readonly IDbContextFactory<TvContext> _dbContextFactory; |
||||||
|
private readonly ILocalFileSystem _localFileSystem; |
||||||
|
private readonly ILogger<RefreshChannelDataHandler> _logger; |
||||||
|
|
||||||
|
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<PlayoutItem> sorted = 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) |
||||||
|
.Map(list => list.Collect(p => p.Items).OrderBy(pi => pi.Start).ToList()); |
||||||
|
|
||||||
|
using MemoryStream 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").Replace(":", string.Empty); |
||||||
|
string stop = displayItem.GuideFinishOffset.HasValue |
||||||
|
? displayItem.GuideFinishOffset.Value.ToString("yyyyMMddHHmmss zzz").Replace(":", string.Empty) |
||||||
|
: finishItem.FinishOffset.ToString("yyyyMMddHHmmss zzz").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()); |
||||||
|
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()); |
||||||
|
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("jellyfin://")) |
||||||
|
{ |
||||||
|
artworkPath = JellyfinUrl.PlaceholderProxyForArtwork(artworkPath, artworkKind, height); |
||||||
|
} |
||||||
|
else if (artworkPath.StartsWith("emby://")) |
||||||
|
{ |
||||||
|
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].ToLowerInvariant() == "us" |
||||||
|
? 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 record ContentRating(Option<string> System, string Value); |
||||||
|
|
||||||
|
private 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); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,3 @@ |
|||||||
|
namespace ErsatzTV.Application.Channels; |
||||||
|
|
||||||
|
public record RefreshChannelList : IRequest, IBackgroundServiceRequest; |
||||||
@ -0,0 +1,113 @@ |
|||||||
|
using System.Data; |
||||||
|
using System.Data.Common; |
||||||
|
using System.Xml; |
||||||
|
using Dapper; |
||||||
|
using ErsatzTV.Core; |
||||||
|
using ErsatzTV.Core.Interfaces.Metadata; |
||||||
|
using ErsatzTV.Infrastructure.Data; |
||||||
|
using Microsoft.EntityFrameworkCore; |
||||||
|
using Microsoft.IO; |
||||||
|
|
||||||
|
namespace ErsatzTV.Application.Channels; |
||||||
|
|
||||||
|
public class RefreshChannelListHandler : IRequestHandler<RefreshChannelList> |
||||||
|
{ |
||||||
|
private readonly RecyclableMemoryStreamManager _recyclableMemoryStreamManager; |
||||||
|
private readonly IDbContextFactory<TvContext> _dbContextFactory; |
||||||
|
private readonly ILocalFileSystem _localFileSystem; |
||||||
|
|
||||||
|
public RefreshChannelListHandler( |
||||||
|
RecyclableMemoryStreamManager recyclableMemoryStreamManager, |
||||||
|
IDbContextFactory<TvContext> dbContextFactory, |
||||||
|
ILocalFileSystem localFileSystem) |
||||||
|
{ |
||||||
|
_recyclableMemoryStreamManager = recyclableMemoryStreamManager; |
||||||
|
_dbContextFactory = dbContextFactory; |
||||||
|
_localFileSystem = localFileSystem; |
||||||
|
} |
||||||
|
|
||||||
|
public async Task Handle(RefreshChannelList request, CancellationToken cancellationToken) |
||||||
|
{ |
||||||
|
_localFileSystem.EnsureFolderExists(FileSystemLayout.ChannelGuideCacheFolder); |
||||||
|
|
||||||
|
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); |
||||||
|
|
||||||
|
using MemoryStream ms = _recyclableMemoryStreamManager.GetStream(); |
||||||
|
await using var xml = XmlWriter.Create( |
||||||
|
ms, |
||||||
|
new XmlWriterSettings { Async = true, ConformanceLevel = ConformanceLevel.Fragment }); |
||||||
|
|
||||||
|
await foreach (ChannelResult channel in GetChannels(dbContext).WithCancellation(cancellationToken)) |
||||||
|
{ |
||||||
|
await xml.WriteStartElementAsync(null, "channel", null); |
||||||
|
await xml.WriteAttributeStringAsync(null, "id", null, $"{channel.Number}.etv"); |
||||||
|
|
||||||
|
await xml.WriteStartElementAsync(null, "display-name", null); |
||||||
|
await xml.WriteStringAsync($"{channel.Number} {channel.Name}"); |
||||||
|
await xml.WriteEndElementAsync(); // display-name (number and name)
|
||||||
|
|
||||||
|
await xml.WriteStartElementAsync(null, "display-name", null); |
||||||
|
await xml.WriteStringAsync(channel.Number); |
||||||
|
await xml.WriteEndElementAsync(); // display-name (number)
|
||||||
|
|
||||||
|
await xml.WriteStartElementAsync(null, "display-name", null); |
||||||
|
await xml.WriteStringAsync(channel.Name); |
||||||
|
await xml.WriteEndElementAsync(); // display-name (name)
|
||||||
|
|
||||||
|
foreach (string category in GetCategories(channel.Categories)) |
||||||
|
{ |
||||||
|
await xml.WriteStartElementAsync(null, "category", null); |
||||||
|
await xml.WriteAttributeStringAsync(null, "lang", null, "en"); |
||||||
|
await xml.WriteStringAsync(category); |
||||||
|
await xml.WriteEndElementAsync(); // category
|
||||||
|
} |
||||||
|
|
||||||
|
await xml.WriteStartElementAsync(null, "icon", null); |
||||||
|
await xml.WriteAttributeStringAsync(null, "src", null, GetIconUrl(channel)); |
||||||
|
await xml.WriteEndElementAsync(); // icon
|
||||||
|
|
||||||
|
await xml.WriteEndElementAsync(); // channel
|
||||||
|
} |
||||||
|
|
||||||
|
await xml.FlushAsync(); |
||||||
|
|
||||||
|
string tempFile = Path.GetTempFileName(); |
||||||
|
await File.WriteAllBytesAsync(tempFile, ms.ToArray(), cancellationToken); |
||||||
|
|
||||||
|
string targetFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, "channels.xml"); |
||||||
|
File.Move(tempFile, targetFile, true); |
||||||
|
} |
||||||
|
|
||||||
|
private static async IAsyncEnumerable<ChannelResult> GetChannels(TvContext dbContext) |
||||||
|
{ |
||||||
|
const string QUERY = @"select C.Number, C.Name, C.Categories, A.Path as ArtworkPath
|
||||||
|
from Channel C |
||||||
|
left outer join Artwork A on C.Id = A.ChannelId and A.ArtworkKind = 2 |
||||||
|
where C.Id in (select ChannelId from Playout) |
||||||
|
order by CAST(C.Number as real)";
|
||||||
|
|
||||||
|
await using var reader = (DbDataReader)await dbContext.Connection.ExecuteReaderAsync(QUERY); |
||||||
|
Func<IDataReader, ChannelResult> rowParser = reader.GetRowParser<ChannelResult>(); |
||||||
|
|
||||||
|
while (await reader.ReadAsync()) { |
||||||
|
yield return rowParser(reader); |
||||||
|
} |
||||||
|
|
||||||
|
while (await reader.NextResultAsync()) {} |
||||||
|
} |
||||||
|
|
||||||
|
private static List<string> GetCategories(string categories) => |
||||||
|
(categories ?? string.Empty).Split(',') |
||||||
|
.Map(s => s.Trim()) |
||||||
|
.Filter(s => !string.IsNullOrWhiteSpace(s)) |
||||||
|
.Distinct() |
||||||
|
.ToList(); |
||||||
|
|
||||||
|
private static string GetIconUrl(ChannelResult channel) => |
||||||
|
string.IsNullOrWhiteSpace(channel.ArtworkPath) |
||||||
|
? "{RequestBase}/iptv/images/ersatztv-500.png{AccessTokenUri}" |
||||||
|
: $"{{RequestBase}}/iptv/logos/{channel.ArtworkPath}.jpg{{AccessTokenUri}}"; |
||||||
|
|
||||||
|
// ReSharper disable once ClassNeverInstantiated.Local
|
||||||
|
private record ChannelResult(string Number, string Name, string Categories, string ArtworkPath); |
||||||
|
} |
||||||
@ -1,5 +1,7 @@ |
|||||||
|
using ErsatzTV.Core; |
||||||
using ErsatzTV.Core.Iptv; |
using ErsatzTV.Core.Iptv; |
||||||
|
|
||||||
namespace ErsatzTV.Application.Channels; |
namespace ErsatzTV.Application.Channels; |
||||||
|
|
||||||
public record GetChannelGuide(string Scheme, string Host, string BaseUrl, string AccessToken) : IRequest<ChannelGuide>; |
public record GetChannelGuide |
||||||
|
(string Scheme, string Host, string BaseUrl, string AccessToken) : IRequest<Either<BaseError, ChannelGuide>>; |
||||||
|
|||||||
@ -1,30 +1,72 @@ |
|||||||
using ErsatzTV.Core.Interfaces.Repositories; |
using System.Text; |
||||||
|
using ErsatzTV.Core; |
||||||
|
using ErsatzTV.Core.Interfaces.Metadata; |
||||||
using ErsatzTV.Core.Iptv; |
using ErsatzTV.Core.Iptv; |
||||||
|
using ErsatzTV.Infrastructure.Data; |
||||||
|
using Microsoft.EntityFrameworkCore; |
||||||
using Microsoft.IO; |
using Microsoft.IO; |
||||||
|
|
||||||
namespace ErsatzTV.Application.Channels; |
namespace ErsatzTV.Application.Channels; |
||||||
|
|
||||||
public class GetChannelGuideHandler : IRequestHandler<GetChannelGuide, ChannelGuide> |
public class GetChannelGuideHandler : IRequestHandler<GetChannelGuide, Either<BaseError, ChannelGuide>> |
||||||
{ |
{ |
||||||
private readonly IChannelRepository _channelRepository; |
private readonly IDbContextFactory<TvContext> _dbContextFactory; |
||||||
private readonly RecyclableMemoryStreamManager _recyclableMemoryStreamManager; |
private readonly RecyclableMemoryStreamManager _recyclableMemoryStreamManager; |
||||||
|
private readonly ILocalFileSystem _localFileSystem; |
||||||
|
|
||||||
public GetChannelGuideHandler( |
public GetChannelGuideHandler( |
||||||
IChannelRepository channelRepository, |
IDbContextFactory<TvContext> dbContextFactory, |
||||||
RecyclableMemoryStreamManager recyclableMemoryStreamManager) |
RecyclableMemoryStreamManager recyclableMemoryStreamManager, |
||||||
|
ILocalFileSystem localFileSystem) |
||||||
{ |
{ |
||||||
_channelRepository = channelRepository; |
_dbContextFactory = dbContextFactory; |
||||||
_recyclableMemoryStreamManager = recyclableMemoryStreamManager; |
_recyclableMemoryStreamManager = recyclableMemoryStreamManager; |
||||||
|
_localFileSystem = localFileSystem; |
||||||
} |
} |
||||||
|
|
||||||
public Task<ChannelGuide> Handle(GetChannelGuide request, CancellationToken cancellationToken) => |
public async Task<Either<BaseError, ChannelGuide>> Handle( |
||||||
_channelRepository.GetAllForGuide() |
GetChannelGuide request, |
||||||
.Map( |
CancellationToken cancellationToken) |
||||||
channels => new ChannelGuide( |
{ |
||||||
_recyclableMemoryStreamManager, |
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); |
||||||
request.Scheme, |
|
||||||
request.Host, |
string channelsFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, "channels.xml"); |
||||||
request.BaseUrl, |
if (!_localFileSystem.FileExists(channelsFile)) |
||||||
channels, |
{ |
||||||
request.AccessToken)); |
return BaseError.New($"Required file {channelsFile} is missing"); |
||||||
|
} |
||||||
|
|
||||||
|
string accessTokenUri = string.Empty; |
||||||
|
if (!string.IsNullOrWhiteSpace(request.AccessToken)) |
||||||
|
{ |
||||||
|
accessTokenUri = $"?access_token={request.AccessToken}"; |
||||||
|
} |
||||||
|
|
||||||
|
string channelsFragment = await File.ReadAllTextAsync(channelsFile, Encoding.UTF8, cancellationToken); |
||||||
|
|
||||||
|
// TODO: is regex faster?
|
||||||
|
channelsFragment = channelsFragment |
||||||
|
.Replace("{RequestBase}", $"{request.Scheme}://{request.Host}{request.BaseUrl}") |
||||||
|
.Replace("{AccessTokenUri}", accessTokenUri); |
||||||
|
|
||||||
|
var channelDataFragments = new Dictionary<string, string>(); |
||||||
|
|
||||||
|
foreach (string fileName in _localFileSystem.ListFiles(FileSystemLayout.ChannelGuideCacheFolder)) |
||||||
|
{ |
||||||
|
if (fileName.Contains("channels")) |
||||||
|
{ |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
string channelDataFragment = await File.ReadAllTextAsync(fileName, Encoding.UTF8, cancellationToken); |
||||||
|
|
||||||
|
channelDataFragment = channelDataFragment |
||||||
|
.Replace("{RequestBase}", $"{request.Scheme}://{request.Host}{request.BaseUrl}") |
||||||
|
.Replace("{AccessTokenUri}", accessTokenUri); |
||||||
|
|
||||||
|
channelDataFragments.Add(Path.GetFileNameWithoutExtension(fileName), channelDataFragment); |
||||||
|
} |
||||||
|
|
||||||
|
return new ChannelGuide(_recyclableMemoryStreamManager, channelsFragment, channelDataFragments); |
||||||
|
} |
||||||
} |
} |
||||||
|
|||||||
Loading…
Reference in new issue