From 9f42333465d8e1787ce2820e2c8d27d02ba1854b Mon Sep 17 00:00:00 2001 From: Jason Dove <1695733+jasongdove@users.noreply.github.com> Date: Mon, 3 Apr 2023 23:09:54 -0500 Subject: [PATCH] cache data for xmltv (#1228) * cache channel list for xmltv * used cached channel data for xmltv * fixes * update changelog --- CHANGELOG.md | 1 + .../Channels/Commands/RefreshChannelData.cs | 3 + .../Commands/RefreshChannelDataHandler.cs | 521 ++++++++++++++++++ .../Channels/Commands/RefreshChannelList.cs | 3 + .../Commands/RefreshChannelListHandler.cs | 113 ++++ .../Channels/Queries/GetChannelGuide.cs | 4 +- .../Queries/GetChannelGuideHandler.cs | 74 ++- .../Playouts/Commands/BuildPlayoutHandler.cs | 19 + .../Playouts/Commands/CreatePlayoutHandler.cs | 4 +- .../Playouts/Commands/DeletePlayoutHandler.cs | 34 +- ErsatzTV.Core/Emby/EmbyUrl.cs | 11 +- ErsatzTV.Core/FileSystemLayout.cs | 1 + ErsatzTV.Core/Iptv/ChannelGuide.cs | 510 +---------------- ErsatzTV.Core/Jellyfin/JellyfinUrl.cs | 25 + .../Data/Repositories/ChannelRepository.cs | 3 +- ErsatzTV/Controllers/IptvController.cs | 3 +- ErsatzTV/Services/SchedulerService.cs | 5 + ErsatzTV/Services/WorkerService.cs | 7 + 18 files changed, 813 insertions(+), 528 deletions(-) create mode 100644 ErsatzTV.Application/Channels/Commands/RefreshChannelData.cs create mode 100644 ErsatzTV.Application/Channels/Commands/RefreshChannelDataHandler.cs create mode 100644 ErsatzTV.Application/Channels/Commands/RefreshChannelList.cs create mode 100644 ErsatzTV.Application/Channels/Commands/RefreshChannelListHandler.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index bc70a7c1b..007a4a0ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Changed - Use Poster artwork for XMLTV if available - If Poster artwork is unavailable, use Thumbnail +- Improve XMLTV response time by caching data as playouts are updated ## [0.7.6-beta] - 2023-03-24 ### Added diff --git a/ErsatzTV.Application/Channels/Commands/RefreshChannelData.cs b/ErsatzTV.Application/Channels/Commands/RefreshChannelData.cs new file mode 100644 index 000000000..a3b336134 --- /dev/null +++ b/ErsatzTV.Application/Channels/Commands/RefreshChannelData.cs @@ -0,0 +1,3 @@ +namespace ErsatzTV.Application.Channels; + +public record RefreshChannelData(string ChannelNumber) : IRequest, IBackgroundServiceRequest; diff --git a/ErsatzTV.Application/Channels/Commands/RefreshChannelDataHandler.cs b/ErsatzTV.Application/Channels/Commands/RefreshChannelDataHandler.cs new file mode 100644 index 000000000..07a6e6dc7 --- /dev/null +++ b/ErsatzTV.Application/Channels/Commands/RefreshChannelDataHandler.cs @@ -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 +{ + private readonly RecyclableMemoryStreamManager _recyclableMemoryStreamManager; + private readonly IDbContextFactory _dbContextFactory; + private readonly ILocalFileSystem _localFileSystem; + private readonly ILogger _logger; + + public RefreshChannelDataHandler( + RecyclableMemoryStreamManager recyclableMemoryStreamManager, + IDbContextFactory dbContextFactory, + ILocalFileSystem localFileSystem, + ILogger 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 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 = 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 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 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 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 ParseContentRating(string contentRating, string system) + { + Option 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.None + : new ContentRating(None, first); + }).Flatten(); + } + + private record ContentRating(Option System, string Value); + + private string GetPrioritizedArtworkPath(Metadata metadata) + { + Option 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); + } +} diff --git a/ErsatzTV.Application/Channels/Commands/RefreshChannelList.cs b/ErsatzTV.Application/Channels/Commands/RefreshChannelList.cs new file mode 100644 index 000000000..f7c25638d --- /dev/null +++ b/ErsatzTV.Application/Channels/Commands/RefreshChannelList.cs @@ -0,0 +1,3 @@ +namespace ErsatzTV.Application.Channels; + +public record RefreshChannelList : IRequest, IBackgroundServiceRequest; diff --git a/ErsatzTV.Application/Channels/Commands/RefreshChannelListHandler.cs b/ErsatzTV.Application/Channels/Commands/RefreshChannelListHandler.cs new file mode 100644 index 000000000..107b3edd2 --- /dev/null +++ b/ErsatzTV.Application/Channels/Commands/RefreshChannelListHandler.cs @@ -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 +{ + private readonly RecyclableMemoryStreamManager _recyclableMemoryStreamManager; + private readonly IDbContextFactory _dbContextFactory; + private readonly ILocalFileSystem _localFileSystem; + + public RefreshChannelListHandler( + RecyclableMemoryStreamManager recyclableMemoryStreamManager, + IDbContextFactory 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 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 rowParser = reader.GetRowParser(); + + while (await reader.ReadAsync()) { + yield return rowParser(reader); + } + + while (await reader.NextResultAsync()) {} + } + + private static List 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); +} diff --git a/ErsatzTV.Application/Channels/Queries/GetChannelGuide.cs b/ErsatzTV.Application/Channels/Queries/GetChannelGuide.cs index 4eb24fb1a..cdb8af720 100644 --- a/ErsatzTV.Application/Channels/Queries/GetChannelGuide.cs +++ b/ErsatzTV.Application/Channels/Queries/GetChannelGuide.cs @@ -1,5 +1,7 @@ +using ErsatzTV.Core; using ErsatzTV.Core.Iptv; namespace ErsatzTV.Application.Channels; -public record GetChannelGuide(string Scheme, string Host, string BaseUrl, string AccessToken) : IRequest; +public record GetChannelGuide + (string Scheme, string Host, string BaseUrl, string AccessToken) : IRequest>; diff --git a/ErsatzTV.Application/Channels/Queries/GetChannelGuideHandler.cs b/ErsatzTV.Application/Channels/Queries/GetChannelGuideHandler.cs index 880622f99..eb857975f 100644 --- a/ErsatzTV.Application/Channels/Queries/GetChannelGuideHandler.cs +++ b/ErsatzTV.Application/Channels/Queries/GetChannelGuideHandler.cs @@ -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.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; using Microsoft.IO; namespace ErsatzTV.Application.Channels; -public class GetChannelGuideHandler : IRequestHandler +public class GetChannelGuideHandler : IRequestHandler> { - private readonly IChannelRepository _channelRepository; + private readonly IDbContextFactory _dbContextFactory; private readonly RecyclableMemoryStreamManager _recyclableMemoryStreamManager; + private readonly ILocalFileSystem _localFileSystem; public GetChannelGuideHandler( - IChannelRepository channelRepository, - RecyclableMemoryStreamManager recyclableMemoryStreamManager) + IDbContextFactory dbContextFactory, + RecyclableMemoryStreamManager recyclableMemoryStreamManager, + ILocalFileSystem localFileSystem) { - _channelRepository = channelRepository; + _dbContextFactory = dbContextFactory; _recyclableMemoryStreamManager = recyclableMemoryStreamManager; + _localFileSystem = localFileSystem; } - public Task Handle(GetChannelGuide request, CancellationToken cancellationToken) => - _channelRepository.GetAllForGuide() - .Map( - channels => new ChannelGuide( - _recyclableMemoryStreamManager, - request.Scheme, - request.Host, - request.BaseUrl, - channels, - request.AccessToken)); + public async Task> Handle( + GetChannelGuide request, + CancellationToken cancellationToken) + { + await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); + + string channelsFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, "channels.xml"); + if (!_localFileSystem.FileExists(channelsFile)) + { + 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(); + + 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); + } } diff --git a/ErsatzTV.Application/Playouts/Commands/BuildPlayoutHandler.cs b/ErsatzTV.Application/Playouts/Commands/BuildPlayoutHandler.cs index c24cf8149..1aa3f5e03 100644 --- a/ErsatzTV.Application/Playouts/Commands/BuildPlayoutHandler.cs +++ b/ErsatzTV.Application/Playouts/Commands/BuildPlayoutHandler.cs @@ -1,5 +1,7 @@ using System.Threading.Channels; using Bugsnag; +using Dapper; +using ErsatzTV.Application.Channels; using ErsatzTV.Application.Subtitles; using ErsatzTV.Core; using ErsatzTV.Core.Domain; @@ -56,6 +58,23 @@ public class BuildPlayoutHandler : IRequestHandler maybeChannelNumber = await dbContext.Connection + .QuerySingleOrDefaultAsync( + @"select C.Number from Channel C + inner join Playout P on C.Id = P.ChannelId + where P.Id = @PlayoutId", + new { request.PlayoutId }) + .Map(Optional); + + foreach (string channelNumber in maybeChannelNumber) + { + string fileName = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, $"{channelNumber}.xml"); + if (hasChanges || !File.Exists(fileName)) + { + await _workerChannel.WriteAsync(new RefreshChannelData(channelNumber)); + } + } + await _workerChannel.WriteAsync(new ExtractEmbeddedSubtitles(playout.Id)); } catch (Exception ex) diff --git a/ErsatzTV.Application/Playouts/Commands/CreatePlayoutHandler.cs b/ErsatzTV.Application/Playouts/Commands/CreatePlayoutHandler.cs index 646817ea0..faf08aef9 100644 --- a/ErsatzTV.Application/Playouts/Commands/CreatePlayoutHandler.cs +++ b/ErsatzTV.Application/Playouts/Commands/CreatePlayoutHandler.cs @@ -1,4 +1,5 @@ using System.Threading.Channels; +using ErsatzTV.Application.Channels; using ErsatzTV.Core; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Scheduling; @@ -28,7 +29,7 @@ public class CreatePlayoutHandler : IRequestHandler validation = await Validate(dbContext, request); - return await LanguageExtensions.Apply(validation, playout => PersistPlayout(dbContext, playout)); + return await validation.Apply(playout => PersistPlayout(dbContext, playout)); } private async Task PersistPlayout(TvContext dbContext, Playout playout) @@ -36,6 +37,7 @@ public class CreatePlayoutHandler : IRequestHandler> { + private readonly ChannelWriter _workerChannel; private readonly IDbContextFactory _dbContextFactory; + private readonly ILocalFileSystem _localFileSystem; - public DeletePlayoutHandler(IDbContextFactory dbContextFactory) => + public DeletePlayoutHandler( + ChannelWriter workerChannel, + IDbContextFactory dbContextFactory, + ILocalFileSystem localFileSystem) + { + _workerChannel = workerChannel; _dbContextFactory = dbContextFactory; + _localFileSystem = localFileSystem; + } public async Task> Handle( DeletePlayout request, CancellationToken cancellationToken) { - await using TvContext dbContext = _dbContextFactory.CreateDbContext(); + await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); Option maybePlayout = await dbContext.Playouts - .OrderBy(p => p.Id) - .FirstOrDefaultAsync(p => p.Id == request.PlayoutId, cancellationToken); + .AsNoTracking() + .Include(p => p.Channel) + .SelectOneAsync(p => p.Id, p => p.Id == request.PlayoutId); foreach (Playout playout in maybePlayout) { dbContext.Playouts.Remove(playout); await dbContext.SaveChangesAsync(cancellationToken); + + // delete channel data from channel guide cache + string cacheFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, $"{playout.Channel.Number}.xml"); + if (_localFileSystem.FileExists(cacheFile)) + { + File.Delete(cacheFile); + } + + // refresh channel list to remove channel that has no playout + await _workerChannel.WriteAsync(new RefreshChannelList(), cancellationToken); } return maybePlayout diff --git a/ErsatzTV.Core/Emby/EmbyUrl.cs b/ErsatzTV.Core/Emby/EmbyUrl.cs index 726599c14..ed60d1850 100644 --- a/ErsatzTV.Core/Emby/EmbyUrl.cs +++ b/ErsatzTV.Core/Emby/EmbyUrl.cs @@ -40,8 +40,8 @@ public static class EmbyUrl .AppendPathSegment(pathSegment) .SetQueryParams(query); } - - public static Url ProxyForArtwork(string scheme, string host, string artwork, ArtworkKind artworkKind) + + public static string PlaceholderProxyForArtwork(string artwork, ArtworkKind artworkKind, int height) { string[] split = artwork.Replace("emby://", string.Empty).Split('?'); if (split.Length != 2) @@ -58,9 +58,12 @@ public static class EmbyUrl _ => "posters" }; - return Url.Parse($"{scheme}://{host}/iptv/artwork/{artworkFolder}/emby") + return Url.Parse($"http://not-a-real-host/iptv/artwork/{artworkFolder}/emby") .AppendPathSegment(pathSegment) - .SetQueryParams(query); + .SetQueryParams(query) + .SetQueryParam("maxHeight", height) + .ToString() + .Replace("http://not-a-real-host", "{RequestBase}"); } public static Url RelativeProxyForArtwork(string artwork) diff --git a/ErsatzTV.Core/FileSystemLayout.cs b/ErsatzTV.Core/FileSystemLayout.cs index 657006cad..da258335c 100644 --- a/ErsatzTV.Core/FileSystemLayout.cs +++ b/ErsatzTV.Core/FileSystemLayout.cs @@ -23,6 +23,7 @@ public static class FileSystemLayout public static readonly string LegacyImageCacheFolder = Path.Combine(AppDataFolder, "cache", "images"); public static readonly string ResourcesCacheFolder = Path.Combine(AppDataFolder, "cache", "resources"); + public static readonly string ChannelGuideCacheFolder = Path.Combine(AppDataFolder, "cache", "channel-guide"); public static readonly string PlexSecretsPath = Path.Combine(AppDataFolder, "plex-secrets.json"); public static readonly string JellyfinSecretsPath = Path.Combine(AppDataFolder, "jellyfin-secrets.json"); diff --git a/ErsatzTV.Core/Iptv/ChannelGuide.cs b/ErsatzTV.Core/Iptv/ChannelGuide.cs index d22065649..22a278f5a 100644 --- a/ErsatzTV.Core/Iptv/ChannelGuide.cs +++ b/ErsatzTV.Core/Iptv/ChannelGuide.cs @@ -1,42 +1,23 @@ using System.Text; using System.Xml; -using ErsatzTV.Core.Domain; -using ErsatzTV.Core.Domain.Filler; -using ErsatzTV.Core.Emby; -using ErsatzTV.Core.Jellyfin; using Microsoft.IO; -using Serilog; namespace ErsatzTV.Core.Iptv; public class ChannelGuide { - private readonly List _channels; - private readonly string _host; - private readonly string _baseUrl; + private readonly string _channelsFragment; + private readonly Dictionary _channelDataFragments; private readonly RecyclableMemoryStreamManager _recyclableMemoryStreamManager; - private readonly string _scheme; - private readonly string _accessTokenUri; public ChannelGuide( RecyclableMemoryStreamManager recyclableMemoryStreamManager, - string scheme, - string host, - string baseUrl, - List channels, - string accessToken) + string channelsFragment, + Dictionary channelDataFragments) { _recyclableMemoryStreamManager = recyclableMemoryStreamManager; - _scheme = scheme; - _host = host; - _baseUrl = baseUrl; - _channels = channels; - - _accessTokenUri = string.Empty; - if (!string.IsNullOrWhiteSpace(accessToken)) - { - _accessTokenUri = $"?access_token={accessToken}"; - } + _channelsFragment = channelsFragment; + _channelDataFragments = channelDataFragments; } public string ToXml() @@ -47,314 +28,13 @@ public class ChannelGuide xml.WriteStartElement("tv"); xml.WriteAttributeString("generator-info-name", "ersatztv"); + + xml.WriteRaw(_channelsFragment); - var sortedChannelItems = new Dictionary>(); - - foreach (Channel channel in _channels.OrderBy(c => decimal.Parse(c.Number))) + foreach ((string channelNumber, string channelDataFragment) in _channelDataFragments.OrderBy( + kvp => decimal.Parse(kvp.Key))) { - var sortedItems = channel.Playouts.Collect(p => p.Items).OrderBy(x => x.Start).ToList(); - sortedChannelItems.Add(channel, sortedItems); - - if (sortedItems.Any()) - { - xml.WriteStartElement("channel"); - xml.WriteAttributeString("id", $"{channel.Number}.etv"); - - xml.WriteStartElement("display-name"); - xml.WriteString($"{channel.Number} {channel.Name}"); - xml.WriteEndElement(); // display-name (number and name) - - xml.WriteStartElement("display-name"); - xml.WriteString(channel.Number); - xml.WriteEndElement(); // display-name (number) - - xml.WriteStartElement("display-name"); - xml.WriteString(channel.Name); - xml.WriteEndElement(); // display-name (name) - - foreach (string category in GetCategories(channel.Categories)) - { - xml.WriteStartElement("category"); - xml.WriteAttributeString("lang", "en"); - xml.WriteString(category); - xml.WriteEndElement(); // category - } - - xml.WriteStartElement("icon"); - string logo = Optional(channel.Artwork).Flatten() - .Filter(a => a.ArtworkKind == ArtworkKind.Logo) - .HeadOrNone() - .Match( - artwork => $"{_scheme}://{_host}{_baseUrl}/iptv/logos/{artwork.Path}.jpg{_accessTokenUri}", - () => $"{_scheme}://{_host}{_baseUrl}/iptv/images/ersatztv-500.png{_accessTokenUri}"); - xml.WriteAttributeString("src", logo); - xml.WriteEndElement(); // icon - - xml.WriteEndElement(); // channel - } - } - - foreach ((Channel channel, List sorted) in sortedChannelItems.OrderBy( - kvp => decimal.Parse(kvp.Key.Number))) - { - // 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 = GetContentRating(displayItem); - - xml.WriteStartElement("programme"); - xml.WriteAttributeString("start", start); - xml.WriteAttributeString("stop", stop); - xml.WriteAttributeString("channel", $"{channel.Number}.etv"); - - xml.WriteStartElement("title"); - xml.WriteAttributeString("lang", "en"); - xml.WriteString(title); - xml.WriteEndElement(); // title - - if (!string.IsNullOrWhiteSpace(subtitle)) - { - xml.WriteStartElement("sub-title"); - xml.WriteAttributeString("lang", "en"); - xml.WriteString(subtitle); - xml.WriteEndElement(); // subtitle - } - - if (!isSameCustomShow) - { - if (!string.IsNullOrWhiteSpace(description)) - { - xml.WriteStartElement("desc"); - xml.WriteAttributeString("lang", "en"); - xml.WriteString(description); - xml.WriteEndElement(); // desc - } - } - - if (!hasCustomTitle && displayItem.MediaItem is Movie movie) - { - foreach (MovieMetadata metadata in movie.MovieMetadata.HeadOrNone()) - { - if (metadata.Year.HasValue) - { - xml.WriteStartElement("date"); - xml.WriteString(metadata.Year.Value.ToString()); - xml.WriteEndElement(); // date - } - - xml.WriteStartElement("category"); - xml.WriteAttributeString("lang", "en"); - xml.WriteString("Movie"); - xml.WriteEndElement(); // category - - foreach (Genre genre in Optional(metadata.Genres).Flatten()) - { - xml.WriteStartElement("category"); - xml.WriteAttributeString("lang", "en"); - xml.WriteString(genre.Name); - xml.WriteEndElement(); // 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)) - { - xml.WriteStartElement("icon"); - xml.WriteAttributeString("src", poster); - xml.WriteEndElement(); // icon - } - } - } - - if (!hasCustomTitle && displayItem.MediaItem is MusicVideo musicVideo) - { - foreach (MusicVideoMetadata metadata in musicVideo.MusicVideoMetadata.HeadOrNone()) - { - if (metadata.Year.HasValue) - { - xml.WriteStartElement("date"); - xml.WriteString(metadata.Year.Value.ToString()); - xml.WriteEndElement(); // date - } - - xml.WriteStartElement("category"); - xml.WriteAttributeString("lang", "en"); - xml.WriteString("Music"); - xml.WriteEndElement(); // category - - // music video genres - foreach (Genre genre in Optional(metadata.Genres).Flatten()) - { - xml.WriteStartElement("category"); - xml.WriteAttributeString("lang", "en"); - xml.WriteString(genre.Name); - xml.WriteEndElement(); // category - } - - // artist genres - Option maybeMetadata = - Optional(musicVideo.Artist?.ArtistMetadata.HeadOrNone()).Flatten(); - foreach (ArtistMetadata artistMetadata in maybeMetadata) - { - foreach (Genre genre in Optional(artistMetadata.Genres).Flatten()) - { - xml.WriteStartElement("category"); - xml.WriteAttributeString("lang", "en"); - xml.WriteString(genre.Name); - xml.WriteEndElement(); // category - } - } - - string artworkPath = GetPrioritizedArtworkPath(metadata); - if (!string.IsNullOrWhiteSpace(artworkPath)) - { - xml.WriteStartElement("icon"); - xml.WriteAttributeString("src", artworkPath); - xml.WriteEndElement(); // icon - } - } - } - - if (!hasCustomTitle && displayItem.MediaItem is Song song) - { - xml.WriteStartElement("category"); - xml.WriteAttributeString("lang", "en"); - xml.WriteString("Music"); - xml.WriteEndElement(); // category - - foreach (SongMetadata metadata in song.SongMetadata.HeadOrNone()) - { - string artworkPath = GetPrioritizedArtworkPath(metadata); - if (!string.IsNullOrWhiteSpace(artworkPath)) - { - xml.WriteStartElement("icon"); - xml.WriteAttributeString("src", artworkPath); - xml.WriteEndElement(); // icon - } - } - } - - if (displayItem.MediaItem is Episode episode && (!hasCustomTitle || isSameCustomShow)) - { - Option maybeMetadata = - Optional(episode.Season?.Show?.ShowMetadata.HeadOrNone()).Flatten(); - foreach (ShowMetadata metadata in maybeMetadata) - { - xml.WriteStartElement("category"); - xml.WriteAttributeString("lang", "en"); - xml.WriteString("Series"); - xml.WriteEndElement(); // category - - foreach (Genre genre in Optional(metadata.Genres).Flatten()) - { - xml.WriteStartElement("category"); - xml.WriteAttributeString("lang", "en"); - xml.WriteString(genre.Name); - xml.WriteEndElement(); // category - } - - string artworkPath = GetPrioritizedArtworkPath(metadata); - if (!string.IsNullOrWhiteSpace(artworkPath)) - { - xml.WriteStartElement("icon"); - xml.WriteAttributeString("src", artworkPath); - xml.WriteEndElement(); // icon - } - } - - if (!isSameCustomShow) - { - int s = Optional(episode.Season?.SeasonNumber).IfNone(-1); - // TODO: multi-episode? - int e = episode.EpisodeMetadata.HeadOrNone().Match(em => em.EpisodeNumber, -1); - if (s >= 0 && e > 0) - { - xml.WriteStartElement("episode-num"); - xml.WriteAttributeString("system", "onscreen"); - xml.WriteString($"S{s:00}E{e:00}"); - xml.WriteEndElement(); // episode-num - - xml.WriteStartElement("episode-num"); - xml.WriteAttributeString("system", "xmltv_ns"); - xml.WriteString($"{s - 1}.{e - 1}.0/1"); - xml.WriteEndElement(); // episode-num - } - } - } - - xml.WriteStartElement("previously-shown"); - xml.WriteEndElement(); // previously-shown - - foreach (ContentRating rating in contentRating) - { - xml.WriteStartElement("rating"); - foreach (string system in rating.System) - { - xml.WriteAttributeString("system", system); - } - - xml.WriteStartElement("value"); - xml.WriteString(rating.Value); - xml.WriteEndElement(); // value - xml.WriteEndElement(); // rating - } - - xml.WriteEndElement(); // programme - - i++; - } + xml.WriteRaw(channelDataFragment); } xml.WriteEndElement(); // tv @@ -363,172 +43,4 @@ public class ChannelGuide xml.Flush(); return Encoding.UTF8.GetString(ms.ToArray()); } - - private string GetArtworkUrl(Artwork artwork, ArtworkKind artworkKind) - { - string artworkPath = artwork.Path; - - int height = artworkKind switch - { - ArtworkKind.Thumbnail => 220, - _ => 440 - }; - - if (artworkPath.StartsWith("jellyfin://")) - { - artworkPath = JellyfinUrl.ProxyForArtwork(_scheme, _host, artworkPath, artworkKind) - .SetQueryParam("fillHeight", height); - } - else if (artworkPath.StartsWith("emby://")) - { - artworkPath = EmbyUrl.ProxyForArtwork(_scheme, _host, artworkPath, artworkKind) - .SetQueryParam("maxHeight", height); - } - else - { - string artworkFolder = artworkKind switch - { - ArtworkKind.Thumbnail => "thumbnails", - _ => "posters" - }; - - artworkPath = $"{_scheme}://{_host}{_baseUrl}/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 static Option 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) - { - Log.Logger.Warning(ex, "Failed to get content rating for playout item {Item}", GetTitle(playoutItem)); - return None; - } - } - - private static Option ParseContentRating(string contentRating, string system) - { - Option 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.None - : new ContentRating(None, first); - }).Flatten(); - } - - private static List GetCategories(string categories) => - (categories ?? string.Empty).Split(',') - .Map(s => s.Trim()) - .Filter(s => !string.IsNullOrWhiteSpace(s)) - .Distinct() - .ToList(); - - private record ContentRating(Option System, string Value); - - private string GetPrioritizedArtworkPath(Domain.Metadata metadata) - { - Option 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); - } } diff --git a/ErsatzTV.Core/Jellyfin/JellyfinUrl.cs b/ErsatzTV.Core/Jellyfin/JellyfinUrl.cs index d407cbe1a..01e18b16a 100644 --- a/ErsatzTV.Core/Jellyfin/JellyfinUrl.cs +++ b/ErsatzTV.Core/Jellyfin/JellyfinUrl.cs @@ -41,6 +41,31 @@ public static class JellyfinUrl .SetQueryParams(query); } + public static string PlaceholderProxyForArtwork(string artwork, ArtworkKind artworkKind, int height) + { + string[] split = artwork.Replace("jellyfin://", string.Empty).Split('?'); + if (split.Length != 2) + { + return artwork; + } + + string pathSegment = split[0]; + QueryParamCollection query = Url.ParseQueryParams(split[1]); + + string artworkFolder = artworkKind switch + { + ArtworkKind.Thumbnail => "thumbnails", + _ => "posters" + }; + + return Url.Parse($"http://not-a-real-host/iptv/artwork/{artworkFolder}/jellyfin") + .AppendPathSegment(pathSegment) + .SetQueryParams(query) + .SetQueryParam("fillHeight", height) + .ToString() + .Replace("http://not-a-real-host", "{RequestBase}"); + } + public static Url ProxyForArtwork(string scheme, string host, string artwork, ArtworkKind artworkKind) { string[] split = artwork.Replace("jellyfin://", string.Empty).Split('?'); diff --git a/ErsatzTV.Infrastructure/Data/Repositories/ChannelRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/ChannelRepository.cs index 715405424..86367d2b6 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/ChannelRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/ChannelRepository.cs @@ -47,8 +47,9 @@ public class ChannelRepository : IChannelRepository public async Task> GetAllForGuide() { - await using TvContext dbContext = _dbContextFactory.CreateDbContext(); + await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); return await dbContext.Channels + .AsNoTracking() .Include(c => c.Artwork) .Include(c => c.Playouts) .ThenInclude(p => p.Items) diff --git a/ErsatzTV/Controllers/IptvController.cs b/ErsatzTV/Controllers/IptvController.cs index 17e96e06a..2bc5add9a 100644 --- a/ErsatzTV/Controllers/IptvController.cs +++ b/ErsatzTV/Controllers/IptvController.cs @@ -9,6 +9,7 @@ using ErsatzTV.Core.Errors; using ErsatzTV.Core.FFmpeg; using ErsatzTV.Core.Interfaces.FFmpeg; using ErsatzTV.Core.Iptv; +using ErsatzTV.Extensions; using ErsatzTV.Filters; using MediatR; using Microsoft.AspNetCore.Mvc; @@ -55,7 +56,7 @@ public class IptvController : ControllerBase Request.Host.ToString(), Request.PathBase, Request.Query["access_token"])) - .Map(Ok); + .ToActionResult(); [HttpGet("iptv/hdhr/channel/{channelNumber}.ts")] public Task GetHDHRVideo(string channelNumber, [FromQuery] string mode = "ts") diff --git a/ErsatzTV/Services/SchedulerService.cs b/ErsatzTV/Services/SchedulerService.cs index 05011c76e..eceb38132 100644 --- a/ErsatzTV/Services/SchedulerService.cs +++ b/ErsatzTV/Services/SchedulerService.cs @@ -1,6 +1,7 @@ using System.Threading.Channels; using Bugsnag; using ErsatzTV.Application; +using ErsatzTV.Application.Channels; using ErsatzTV.Application.Emby; using ErsatzTV.Application.Jellyfin; using ErsatzTV.Application.Maintenance; @@ -91,6 +92,7 @@ public class SchedulerService : BackgroundService try { await DeleteOrphanedArtwork(cancellationToken); + await RefreshChannelGuideChannelList(cancellationToken); await BuildPlayouts(cancellationToken); #if !DEBUG_NO_SYNC await ScanLocalMediaSources(cancellationToken); @@ -185,6 +187,9 @@ public class SchedulerService : BackgroundService } } + private ValueTask RefreshChannelGuideChannelList(CancellationToken cancellationToken) => + _workerChannel.WriteAsync(new RefreshChannelList(), cancellationToken); + private async Task ScanLocalMediaSources(CancellationToken cancellationToken) { using IServiceScope scope = _serviceScopeFactory.CreateScope(); diff --git a/ErsatzTV/Services/WorkerService.cs b/ErsatzTV/Services/WorkerService.cs index 8fe133551..dd758b7ef 100644 --- a/ErsatzTV/Services/WorkerService.cs +++ b/ErsatzTV/Services/WorkerService.cs @@ -1,6 +1,7 @@ using System.Threading.Channels; using Bugsnag; using ErsatzTV.Application; +using ErsatzTV.Application.Channels; using ErsatzTV.Application.Maintenance; using ErsatzTV.Application.MediaCollections; using ErsatzTV.Application.Playouts; @@ -47,6 +48,12 @@ public class WorkerService : BackgroundService switch (request) { + case RefreshChannelList refreshChannelList: + await mediator.Send(refreshChannelList, cancellationToken); + break; + case RefreshChannelData refreshChannelData: + await mediator.Send(refreshChannelData, cancellationToken); + break; case BuildPlayout buildPlayout: Either buildPlayoutResult = await mediator.Send( buildPlayout,