Browse Source

cache data for xmltv (#1228)

* cache channel list for xmltv

* used cached channel data for xmltv

* fixes

* update changelog
pull/1229/head
Jason Dove 3 years ago committed by GitHub
parent
commit
9f42333465
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 1
      CHANGELOG.md
  2. 3
      ErsatzTV.Application/Channels/Commands/RefreshChannelData.cs
  3. 521
      ErsatzTV.Application/Channels/Commands/RefreshChannelDataHandler.cs
  4. 3
      ErsatzTV.Application/Channels/Commands/RefreshChannelList.cs
  5. 113
      ErsatzTV.Application/Channels/Commands/RefreshChannelListHandler.cs
  6. 4
      ErsatzTV.Application/Channels/Queries/GetChannelGuide.cs
  7. 74
      ErsatzTV.Application/Channels/Queries/GetChannelGuideHandler.cs
  8. 19
      ErsatzTV.Application/Playouts/Commands/BuildPlayoutHandler.cs
  9. 4
      ErsatzTV.Application/Playouts/Commands/CreatePlayoutHandler.cs
  10. 34
      ErsatzTV.Application/Playouts/Commands/DeletePlayoutHandler.cs
  11. 9
      ErsatzTV.Core/Emby/EmbyUrl.cs
  12. 1
      ErsatzTV.Core/FileSystemLayout.cs
  13. 508
      ErsatzTV.Core/Iptv/ChannelGuide.cs
  14. 25
      ErsatzTV.Core/Jellyfin/JellyfinUrl.cs
  15. 3
      ErsatzTV.Infrastructure/Data/Repositories/ChannelRepository.cs
  16. 3
      ErsatzTV/Controllers/IptvController.cs
  17. 5
      ErsatzTV/Services/SchedulerService.cs
  18. 7
      ErsatzTV/Services/WorkerService.cs

1
CHANGELOG.md

@ -17,6 +17,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). @@ -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

3
ErsatzTV.Application/Channels/Commands/RefreshChannelData.cs

@ -0,0 +1,3 @@ @@ -0,0 +1,3 @@
namespace ErsatzTV.Application.Channels;
public record RefreshChannelData(string ChannelNumber) : IRequest, IBackgroundServiceRequest;

521
ErsatzTV.Application/Channels/Commands/RefreshChannelDataHandler.cs

@ -0,0 +1,521 @@ @@ -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);
}
}

3
ErsatzTV.Application/Channels/Commands/RefreshChannelList.cs

@ -0,0 +1,3 @@ @@ -0,0 +1,3 @@
namespace ErsatzTV.Application.Channels;
public record RefreshChannelList : IRequest, IBackgroundServiceRequest;

113
ErsatzTV.Application/Channels/Commands/RefreshChannelListHandler.cs

@ -0,0 +1,113 @@ @@ -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);
}

4
ErsatzTV.Application/Channels/Queries/GetChannelGuide.cs

@ -1,5 +1,7 @@ @@ -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<ChannelGuide>;
public record GetChannelGuide
(string Scheme, string Host, string BaseUrl, string AccessToken) : IRequest<Either<BaseError, ChannelGuide>>;

74
ErsatzTV.Application/Channels/Queries/GetChannelGuideHandler.cs

@ -1,30 +1,72 @@ @@ -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<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 ILocalFileSystem _localFileSystem;
public GetChannelGuideHandler(
IChannelRepository channelRepository,
RecyclableMemoryStreamManager recyclableMemoryStreamManager)
IDbContextFactory<TvContext> dbContextFactory,
RecyclableMemoryStreamManager recyclableMemoryStreamManager,
ILocalFileSystem localFileSystem)
{
_channelRepository = channelRepository;
_dbContextFactory = dbContextFactory;
_recyclableMemoryStreamManager = recyclableMemoryStreamManager;
_localFileSystem = localFileSystem;
}
public Task<ChannelGuide> Handle(GetChannelGuide request, CancellationToken cancellationToken) =>
_channelRepository.GetAllForGuide()
.Map(
channels => new ChannelGuide(
_recyclableMemoryStreamManager,
request.Scheme,
request.Host,
request.BaseUrl,
channels,
request.AccessToken));
public async Task<Either<BaseError, ChannelGuide>> 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<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);
}
}

19
ErsatzTV.Application/Playouts/Commands/BuildPlayoutHandler.cs

@ -1,5 +1,7 @@ @@ -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<BuildPlayout, Either<BaseErro @@ -56,6 +58,23 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
_ffmpegSegmenterService.PlayoutUpdated(playout.Channel.Number);
}
Option<string> maybeChannelNumber = await dbContext.Connection
.QuerySingleOrDefaultAsync<string>(
@"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)

4
ErsatzTV.Application/Playouts/Commands/CreatePlayoutHandler.cs

@ -1,4 +1,5 @@ @@ -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<CreatePlayout, Either<BaseEr @@ -28,7 +29,7 @@ public class CreatePlayoutHandler : IRequestHandler<CreatePlayout, Either<BaseEr
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Playout> 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<CreatePlayoutResponse> PersistPlayout(TvContext dbContext, Playout playout)
@ -36,6 +37,7 @@ public class CreatePlayoutHandler : IRequestHandler<CreatePlayout, Either<BaseEr @@ -36,6 +37,7 @@ public class CreatePlayoutHandler : IRequestHandler<CreatePlayout, Either<BaseEr
await dbContext.Playouts.AddAsync(playout);
await dbContext.SaveChangesAsync();
await _channel.WriteAsync(new BuildPlayout(playout.Id, PlayoutBuildMode.Reset));
await _channel.WriteAsync(new RefreshChannelList());
return new CreatePlayoutResponse(playout.Id);
}

34
ErsatzTV.Application/Playouts/Commands/DeletePlayoutHandler.cs

@ -1,31 +1,55 @@ @@ -1,31 +1,55 @@
using ErsatzTV.Core;
using System.Threading.Channels;
using ErsatzTV.Application.Channels;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Playouts;
public class DeletePlayoutHandler : IRequestHandler<DeletePlayout, Either<BaseError, Unit>>
{
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly ILocalFileSystem _localFileSystem;
public DeletePlayoutHandler(IDbContextFactory<TvContext> dbContextFactory) =>
public DeletePlayoutHandler(
ChannelWriter<IBackgroundServiceRequest> workerChannel,
IDbContextFactory<TvContext> dbContextFactory,
ILocalFileSystem localFileSystem)
{
_workerChannel = workerChannel;
_dbContextFactory = dbContextFactory;
_localFileSystem = localFileSystem;
}
public async Task<Either<BaseError, Unit>> Handle(
DeletePlayout request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Option<Playout> 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

9
ErsatzTV.Core/Emby/EmbyUrl.cs

@ -41,7 +41,7 @@ public static class EmbyUrl @@ -41,7 +41,7 @@ public static class EmbyUrl
.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 @@ -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)

1
ErsatzTV.Core/FileSystemLayout.cs

@ -23,6 +23,7 @@ public static class FileSystemLayout @@ -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");

508
ErsatzTV.Core/Iptv/ChannelGuide.cs

@ -1,42 +1,23 @@ @@ -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<Channel> _channels;
private readonly string _host;
private readonly string _baseUrl;
private readonly string _channelsFragment;
private readonly Dictionary<string, string> _channelDataFragments;
private readonly RecyclableMemoryStreamManager _recyclableMemoryStreamManager;
private readonly string _scheme;
private readonly string _accessTokenUri;
public ChannelGuide(
RecyclableMemoryStreamManager recyclableMemoryStreamManager,
string scheme,
string host,
string baseUrl,
List<Channel> channels,
string accessToken)
string channelsFragment,
Dictionary<string, string> 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()
@ -48,313 +29,12 @@ public class ChannelGuide @@ -48,313 +29,12 @@ public class ChannelGuide
xml.WriteStartElement("tv");
xml.WriteAttributeString("generator-info-name", "ersatztv");
var sortedChannelItems = new Dictionary<Channel, List<PlayoutItem>>();
foreach (Channel channel in _channels.OrderBy(c => decimal.Parse(c.Number)))
{
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<PlayoutItem> 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> 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<ArtistMetadata> 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
}
}
xml.WriteRaw(_channelsFragment);
string artworkPath = GetPrioritizedArtworkPath(metadata);
if (!string.IsNullOrWhiteSpace(artworkPath))
foreach ((string channelNumber, string channelDataFragment) in _channelDataFragments.OrderBy(
kvp => decimal.Parse(kvp.Key)))
{
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<ShowMetadata> 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 @@ -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<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)
{
Log.Logger.Warning(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 static List<string> GetCategories(string categories) =>
(categories ?? string.Empty).Split(',')
.Map(s => s.Trim())
.Filter(s => !string.IsNullOrWhiteSpace(s))
.Distinct()
.ToList();
private record ContentRating(Option<string> System, string Value);
private string GetPrioritizedArtworkPath(Domain.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);
}
}

25
ErsatzTV.Core/Jellyfin/JellyfinUrl.cs

@ -41,6 +41,31 @@ public static class JellyfinUrl @@ -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('?');

3
ErsatzTV.Infrastructure/Data/Repositories/ChannelRepository.cs

@ -47,8 +47,9 @@ public class ChannelRepository : IChannelRepository @@ -47,8 +47,9 @@ public class ChannelRepository : IChannelRepository
public async Task<List<Channel>> 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)

3
ErsatzTV/Controllers/IptvController.cs

@ -9,6 +9,7 @@ using ErsatzTV.Core.Errors; @@ -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 @@ -55,7 +56,7 @@ public class IptvController : ControllerBase
Request.Host.ToString(),
Request.PathBase,
Request.Query["access_token"]))
.Map<ChannelGuide, IActionResult>(Ok);
.ToActionResult();
[HttpGet("iptv/hdhr/channel/{channelNumber}.ts")]
public Task<IActionResult> GetHDHRVideo(string channelNumber, [FromQuery] string mode = "ts")

5
ErsatzTV/Services/SchedulerService.cs

@ -1,6 +1,7 @@ @@ -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 @@ -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 @@ -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();

7
ErsatzTV/Services/WorkerService.cs

@ -1,6 +1,7 @@ @@ -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 @@ -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<BaseError, Unit> buildPlayoutResult = await mediator.Send(
buildPlayout,

Loading…
Cancel
Save