cache data for xmltv (#1228)
* cache channel list for xmltv * used cached channel data for xmltv * fixes * update changelog
This commit is contained in:
@@ -17,6 +17,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
|||||||
### Changed
|
### Changed
|
||||||
- Use Poster artwork for XMLTV if available
|
- Use Poster artwork for XMLTV if available
|
||||||
- If Poster artwork is unavailable, use Thumbnail
|
- 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
|
## [0.7.6-beta] - 2023-03-24
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
namespace ErsatzTV.Application.Channels;
|
||||||
|
|
||||||
|
public record RefreshChannelData(string ChannelNumber) : IRequest, IBackgroundServiceRequest;
|
||||||
@@ -0,0 +1,521 @@
|
|||||||
|
using System.Xml;
|
||||||
|
using ErsatzTV.Core;
|
||||||
|
using ErsatzTV.Core.Domain;
|
||||||
|
using ErsatzTV.Core.Domain.Filler;
|
||||||
|
using ErsatzTV.Core.Emby;
|
||||||
|
using ErsatzTV.Core.Interfaces.Metadata;
|
||||||
|
using ErsatzTV.Core.Jellyfin;
|
||||||
|
using ErsatzTV.Infrastructure.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.IO;
|
||||||
|
|
||||||
|
namespace ErsatzTV.Application.Channels;
|
||||||
|
|
||||||
|
public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||||
|
{
|
||||||
|
private readonly RecyclableMemoryStreamManager _recyclableMemoryStreamManager;
|
||||||
|
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||||
|
private readonly ILocalFileSystem _localFileSystem;
|
||||||
|
private readonly ILogger<RefreshChannelDataHandler> _logger;
|
||||||
|
|
||||||
|
public RefreshChannelDataHandler(
|
||||||
|
RecyclableMemoryStreamManager recyclableMemoryStreamManager,
|
||||||
|
IDbContextFactory<TvContext> dbContextFactory,
|
||||||
|
ILocalFileSystem localFileSystem,
|
||||||
|
ILogger<RefreshChannelDataHandler> logger)
|
||||||
|
{
|
||||||
|
_recyclableMemoryStreamManager = recyclableMemoryStreamManager;
|
||||||
|
_dbContextFactory = dbContextFactory;
|
||||||
|
_localFileSystem = localFileSystem;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task Handle(RefreshChannelData request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
_localFileSystem.EnsureFolderExists(FileSystemLayout.ChannelGuideCacheFolder);
|
||||||
|
|
||||||
|
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
|
||||||
|
List<PlayoutItem> sorted = await dbContext.Playouts
|
||||||
|
.AsNoTracking()
|
||||||
|
.Filter(pi => pi.Channel.Number == request.ChannelNumber)
|
||||||
|
.Include(p => p.Items)
|
||||||
|
.ThenInclude(i => i.MediaItem)
|
||||||
|
.ThenInclude(i => (i as Episode).EpisodeMetadata)
|
||||||
|
.Include(p => p.Items)
|
||||||
|
.ThenInclude(i => i.MediaItem)
|
||||||
|
.ThenInclude(i => (i as Episode).Season)
|
||||||
|
.ThenInclude(s => s.Show)
|
||||||
|
.ThenInclude(s => s.ShowMetadata)
|
||||||
|
.ThenInclude(sm => sm.Artwork)
|
||||||
|
.Include(p => p.Items)
|
||||||
|
.ThenInclude(i => i.MediaItem)
|
||||||
|
.ThenInclude(i => (i as Episode).Season)
|
||||||
|
.ThenInclude(s => s.Show)
|
||||||
|
.ThenInclude(s => s.ShowMetadata)
|
||||||
|
.ThenInclude(em => em.Genres)
|
||||||
|
.Include(p => p.Items)
|
||||||
|
.ThenInclude(i => i.MediaItem)
|
||||||
|
.ThenInclude(i => (i as Movie).MovieMetadata)
|
||||||
|
.ThenInclude(mm => mm.Artwork)
|
||||||
|
.Include(p => p.Items)
|
||||||
|
.ThenInclude(i => i.MediaItem)
|
||||||
|
.ThenInclude(i => (i as Movie).MovieMetadata)
|
||||||
|
.ThenInclude(mm => mm.Genres)
|
||||||
|
.Include(p => p.Items)
|
||||||
|
.ThenInclude(i => i.MediaItem)
|
||||||
|
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
|
||||||
|
.ThenInclude(mm => mm.Artwork)
|
||||||
|
.Include(p => p.Items)
|
||||||
|
.ThenInclude(i => i.MediaItem)
|
||||||
|
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
|
||||||
|
.ThenInclude(mvm => mvm.Genres)
|
||||||
|
.Include(p => p.Items)
|
||||||
|
.ThenInclude(i => i.MediaItem)
|
||||||
|
.ThenInclude(i => (i as MusicVideo).Artist)
|
||||||
|
.ThenInclude(a => a.ArtistMetadata)
|
||||||
|
.ThenInclude(am => am.Genres)
|
||||||
|
.Include(p => p.Items)
|
||||||
|
.ThenInclude(i => i.MediaItem)
|
||||||
|
.ThenInclude(i => (i as OtherVideo).OtherVideoMetadata)
|
||||||
|
.ThenInclude(vm => vm.Artwork)
|
||||||
|
.Include(p => p.Items)
|
||||||
|
.ThenInclude(i => i.MediaItem)
|
||||||
|
.ThenInclude(i => (i as Song).SongMetadata)
|
||||||
|
.ThenInclude(vm => vm.Artwork)
|
||||||
|
.ToListAsync(cancellationToken)
|
||||||
|
.Map(list => list.Collect(p => p.Items).OrderBy(pi => pi.Start).ToList());
|
||||||
|
|
||||||
|
using MemoryStream ms = _recyclableMemoryStreamManager.GetStream();
|
||||||
|
await using var xml = XmlWriter.Create(
|
||||||
|
ms,
|
||||||
|
new XmlWriterSettings { Async = true, ConformanceLevel = ConformanceLevel.Fragment });
|
||||||
|
|
||||||
|
// skip all filler that isn't pre-roll
|
||||||
|
var i = 0;
|
||||||
|
while (i < sorted.Count && sorted[i].FillerKind != FillerKind.None &&
|
||||||
|
sorted[i].FillerKind != FillerKind.PreRoll)
|
||||||
|
{
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (i < sorted.Count)
|
||||||
|
{
|
||||||
|
PlayoutItem startItem = sorted[i];
|
||||||
|
int j = i;
|
||||||
|
while (sorted[j].FillerKind != FillerKind.None && j + 1 < sorted.Count)
|
||||||
|
{
|
||||||
|
j++;
|
||||||
|
}
|
||||||
|
|
||||||
|
PlayoutItem displayItem = sorted[j];
|
||||||
|
bool hasCustomTitle = !string.IsNullOrWhiteSpace(startItem.CustomTitle);
|
||||||
|
|
||||||
|
int finishIndex = j;
|
||||||
|
while (finishIndex + 1 < sorted.Count && (sorted[finishIndex + 1].GuideGroup == startItem.GuideGroup
|
||||||
|
|| sorted[finishIndex + 1].FillerKind is FillerKind.GuideMode
|
||||||
|
or FillerKind.Tail or FillerKind.Fallback))
|
||||||
|
{
|
||||||
|
finishIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
int customShowId = -1;
|
||||||
|
if (displayItem.MediaItem is Episode ep)
|
||||||
|
{
|
||||||
|
customShowId = ep.Season.ShowId;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isSameCustomShow = hasCustomTitle;
|
||||||
|
for (int x = j; x <= finishIndex; x++)
|
||||||
|
{
|
||||||
|
isSameCustomShow = isSameCustomShow && sorted[x].MediaItem is Episode e &&
|
||||||
|
customShowId == e.Season.ShowId;
|
||||||
|
}
|
||||||
|
|
||||||
|
PlayoutItem finishItem = sorted[finishIndex];
|
||||||
|
i = finishIndex;
|
||||||
|
|
||||||
|
string start = startItem.StartOffset.ToString("yyyyMMddHHmmss zzz").Replace(":", string.Empty);
|
||||||
|
string stop = displayItem.GuideFinishOffset.HasValue
|
||||||
|
? displayItem.GuideFinishOffset.Value.ToString("yyyyMMddHHmmss zzz").Replace(":", string.Empty)
|
||||||
|
: finishItem.FinishOffset.ToString("yyyyMMddHHmmss zzz").Replace(":", string.Empty);
|
||||||
|
|
||||||
|
string title = GetTitle(displayItem);
|
||||||
|
string subtitle = GetSubtitle(displayItem);
|
||||||
|
string description = GetDescription(displayItem);
|
||||||
|
Option<ContentRating> contentRating = GetContentRating(displayItem);
|
||||||
|
|
||||||
|
await xml.WriteStartElementAsync(null, "programme", null);
|
||||||
|
await xml.WriteAttributeStringAsync(null, "start", null, start);
|
||||||
|
await xml.WriteAttributeStringAsync(null, "stop", null, stop);
|
||||||
|
await xml.WriteAttributeStringAsync(null, "channel", null, $"{request.ChannelNumber}.etv");
|
||||||
|
|
||||||
|
await xml.WriteStartElementAsync(null, "title", null);
|
||||||
|
await xml.WriteAttributeStringAsync(null, "lang", null, "en");
|
||||||
|
await xml.WriteStringAsync(title);
|
||||||
|
await xml.WriteEndElementAsync(); // title
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(subtitle))
|
||||||
|
{
|
||||||
|
await xml.WriteStartElementAsync(null, "sub-title", null);
|
||||||
|
await xml.WriteAttributeStringAsync(null, "lang", null, "en");
|
||||||
|
await xml.WriteStringAsync(subtitle);
|
||||||
|
await xml.WriteEndElementAsync(); // subtitle
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isSameCustomShow)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(description))
|
||||||
|
{
|
||||||
|
await xml.WriteStartElementAsync(null, "desc", null);
|
||||||
|
await xml.WriteAttributeStringAsync(null, "lang", null, "en");
|
||||||
|
await xml.WriteStringAsync(description);
|
||||||
|
await xml.WriteEndElementAsync(); // desc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasCustomTitle && displayItem.MediaItem is Movie movie)
|
||||||
|
{
|
||||||
|
foreach (MovieMetadata metadata in movie.MovieMetadata.HeadOrNone())
|
||||||
|
{
|
||||||
|
if (metadata.Year.HasValue)
|
||||||
|
{
|
||||||
|
await xml.WriteStartElementAsync(null, "date", null);
|
||||||
|
await xml.WriteStringAsync(metadata.Year.Value.ToString());
|
||||||
|
await xml.WriteEndElementAsync(); // date
|
||||||
|
}
|
||||||
|
|
||||||
|
await xml.WriteStartElementAsync(null, "category", null);
|
||||||
|
await xml.WriteAttributeStringAsync(null, "lang", null, "en");
|
||||||
|
await xml.WriteStringAsync("Movie");
|
||||||
|
await xml.WriteEndElementAsync(); // category
|
||||||
|
|
||||||
|
foreach (Genre genre in Optional(metadata.Genres).Flatten().OrderBy(g => g.Name))
|
||||||
|
{
|
||||||
|
await xml.WriteStartElementAsync(null, "category", null);
|
||||||
|
await xml.WriteAttributeStringAsync(null, "lang", null, "en");
|
||||||
|
await xml.WriteStringAsync(genre.Name);
|
||||||
|
await xml.WriteEndElementAsync(); // category
|
||||||
|
}
|
||||||
|
|
||||||
|
string poster = Optional(metadata.Artwork).Flatten()
|
||||||
|
.Filter(a => a.ArtworkKind == ArtworkKind.Poster)
|
||||||
|
.HeadOrNone()
|
||||||
|
.Match(a => GetArtworkUrl(a, ArtworkKind.Poster), () => string.Empty);
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(poster))
|
||||||
|
{
|
||||||
|
await xml.WriteStartElementAsync(null, "icon", null);
|
||||||
|
await xml.WriteAttributeStringAsync(null, "src", null, poster);
|
||||||
|
await xml.WriteEndElementAsync(); // icon
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasCustomTitle && displayItem.MediaItem is MusicVideo musicVideo)
|
||||||
|
{
|
||||||
|
foreach (MusicVideoMetadata metadata in musicVideo.MusicVideoMetadata.HeadOrNone())
|
||||||
|
{
|
||||||
|
if (metadata.Year.HasValue)
|
||||||
|
{
|
||||||
|
await xml.WriteStartElementAsync(null, "date", null);
|
||||||
|
await xml.WriteStringAsync(metadata.Year.Value.ToString());
|
||||||
|
await xml.WriteEndElementAsync(); // date
|
||||||
|
}
|
||||||
|
|
||||||
|
await xml.WriteStartElementAsync(null, "category", null);
|
||||||
|
await xml.WriteAttributeStringAsync(null, "lang", null, "en");
|
||||||
|
await xml.WriteStringAsync("Music");
|
||||||
|
await xml.WriteEndElementAsync(); // category
|
||||||
|
|
||||||
|
// music video genres
|
||||||
|
foreach (Genre genre in Optional(metadata.Genres).Flatten().OrderBy(g => g.Name))
|
||||||
|
{
|
||||||
|
await xml.WriteStartElementAsync(null, "category", null);
|
||||||
|
await xml.WriteAttributeStringAsync(null, "lang", null, "en");
|
||||||
|
await xml.WriteStringAsync(genre.Name);
|
||||||
|
await xml.WriteEndElementAsync(); // category
|
||||||
|
}
|
||||||
|
|
||||||
|
// artist genres
|
||||||
|
Option<ArtistMetadata> maybeMetadata =
|
||||||
|
Optional(musicVideo.Artist?.ArtistMetadata.HeadOrNone()).Flatten();
|
||||||
|
foreach (ArtistMetadata artistMetadata in maybeMetadata)
|
||||||
|
{
|
||||||
|
foreach (Genre genre in Optional(artistMetadata.Genres).Flatten().OrderBy(g => g.Name))
|
||||||
|
{
|
||||||
|
await xml.WriteStartElementAsync(null, "category", null);
|
||||||
|
await xml.WriteAttributeStringAsync(null, "lang", null, "en");
|
||||||
|
await xml.WriteStringAsync(genre.Name);
|
||||||
|
await xml.WriteEndElementAsync(); // category
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
string artworkPath = GetPrioritizedArtworkPath(metadata);
|
||||||
|
if (!string.IsNullOrWhiteSpace(artworkPath))
|
||||||
|
{
|
||||||
|
await xml.WriteStartElementAsync(null, "icon", null);
|
||||||
|
await xml.WriteAttributeStringAsync(null, "src", null, artworkPath);
|
||||||
|
await xml.WriteEndElementAsync(); // icon
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasCustomTitle && displayItem.MediaItem is Song song)
|
||||||
|
{
|
||||||
|
await xml.WriteStartElementAsync(null, "category", null);
|
||||||
|
await xml.WriteAttributeStringAsync(null, "lang", null, "en");
|
||||||
|
await xml.WriteStringAsync("Music");
|
||||||
|
await xml.WriteEndElementAsync(); // category
|
||||||
|
|
||||||
|
foreach (SongMetadata metadata in song.SongMetadata.HeadOrNone())
|
||||||
|
{
|
||||||
|
string artworkPath = GetPrioritizedArtworkPath(metadata);
|
||||||
|
if (!string.IsNullOrWhiteSpace(artworkPath))
|
||||||
|
{
|
||||||
|
await xml.WriteStartElementAsync(null, "icon", null);
|
||||||
|
await xml.WriteAttributeStringAsync(null, "src", null, artworkPath);
|
||||||
|
await xml.WriteEndElementAsync(); // icon
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (displayItem.MediaItem is Episode episode && (!hasCustomTitle || isSameCustomShow))
|
||||||
|
{
|
||||||
|
Option<ShowMetadata> maybeMetadata =
|
||||||
|
Optional(episode.Season?.Show?.ShowMetadata.HeadOrNone()).Flatten();
|
||||||
|
foreach (ShowMetadata metadata in maybeMetadata)
|
||||||
|
{
|
||||||
|
await xml.WriteStartElementAsync(null, "category", null);
|
||||||
|
await xml.WriteAttributeStringAsync(null, "lang", null, "en");
|
||||||
|
await xml.WriteStringAsync("Series");
|
||||||
|
await xml.WriteEndElementAsync(); // category
|
||||||
|
|
||||||
|
foreach (Genre genre in Optional(metadata.Genres).Flatten().OrderBy(g => g.Name))
|
||||||
|
{
|
||||||
|
await xml.WriteStartElementAsync(null, "category", null);
|
||||||
|
await xml.WriteAttributeStringAsync(null, "lang", null, "en");
|
||||||
|
await xml.WriteStringAsync(genre.Name);
|
||||||
|
await xml.WriteEndElementAsync(); // category
|
||||||
|
}
|
||||||
|
|
||||||
|
string artworkPath = GetPrioritizedArtworkPath(metadata);
|
||||||
|
if (!string.IsNullOrWhiteSpace(artworkPath))
|
||||||
|
{
|
||||||
|
await xml.WriteStartElementAsync(null, "icon", null);
|
||||||
|
await xml.WriteAttributeStringAsync(null, "src", null, artworkPath);
|
||||||
|
await xml.WriteEndElementAsync(); // icon
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isSameCustomShow)
|
||||||
|
{
|
||||||
|
int s = await Optional(episode.Season?.SeasonNumber).IfNoneAsync(-1);
|
||||||
|
// TODO: multi-episode?
|
||||||
|
int e = episode.EpisodeMetadata.HeadOrNone().Match(em => em.EpisodeNumber, -1);
|
||||||
|
if (s >= 0 && e > 0)
|
||||||
|
{
|
||||||
|
await xml.WriteStartElementAsync(null, "episode-num", null);
|
||||||
|
await xml.WriteAttributeStringAsync(null, "system", null, "onscreen");
|
||||||
|
await xml.WriteStringAsync($"S{s:00}E{e:00}");
|
||||||
|
await xml.WriteEndElementAsync(); // episode-num
|
||||||
|
|
||||||
|
await xml.WriteStartElementAsync(null, "episode-num", null);
|
||||||
|
await xml.WriteAttributeStringAsync(null, "system", null, "xmltv_ns");
|
||||||
|
await xml.WriteStringAsync($"{s - 1}.{e - 1}.0/1");
|
||||||
|
await xml.WriteEndElementAsync(); // episode-num
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await xml.WriteStartElementAsync(null, "previously-shown", null);
|
||||||
|
await xml.WriteEndElementAsync(); // previously-shown
|
||||||
|
|
||||||
|
foreach (ContentRating rating in contentRating)
|
||||||
|
{
|
||||||
|
await xml.WriteStartElementAsync(null, "rating", null);
|
||||||
|
foreach (string system in rating.System)
|
||||||
|
{
|
||||||
|
await xml.WriteAttributeStringAsync(null, "system", null, system);
|
||||||
|
}
|
||||||
|
|
||||||
|
await xml.WriteStartElementAsync(null, "value", null);
|
||||||
|
await xml.WriteStringAsync(rating.Value);
|
||||||
|
await xml.WriteEndElementAsync(); // value
|
||||||
|
await xml.WriteEndElementAsync(); // rating
|
||||||
|
}
|
||||||
|
|
||||||
|
await xml.WriteEndElementAsync(); // programme
|
||||||
|
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
await xml.FlushAsync();
|
||||||
|
|
||||||
|
string tempFile = Path.GetTempFileName();
|
||||||
|
await File.WriteAllBytesAsync(tempFile, ms.ToArray(), cancellationToken);
|
||||||
|
|
||||||
|
string targetFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, $"{request.ChannelNumber}.xml");
|
||||||
|
File.Move(tempFile, targetFile, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetArtworkUrl(Artwork artwork, ArtworkKind artworkKind)
|
||||||
|
{
|
||||||
|
string artworkPath = artwork.Path;
|
||||||
|
|
||||||
|
int height = artworkKind switch
|
||||||
|
{
|
||||||
|
ArtworkKind.Thumbnail => 220,
|
||||||
|
_ => 440
|
||||||
|
};
|
||||||
|
|
||||||
|
if (artworkPath.StartsWith("jellyfin://"))
|
||||||
|
{
|
||||||
|
artworkPath = JellyfinUrl.PlaceholderProxyForArtwork(artworkPath, artworkKind, height);
|
||||||
|
}
|
||||||
|
else if (artworkPath.StartsWith("emby://"))
|
||||||
|
{
|
||||||
|
artworkPath = EmbyUrl.PlaceholderProxyForArtwork(artworkPath, artworkKind, height);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
string artworkFolder = artworkKind switch
|
||||||
|
{
|
||||||
|
ArtworkKind.Thumbnail => "thumbnails",
|
||||||
|
_ => "posters"
|
||||||
|
};
|
||||||
|
|
||||||
|
artworkPath = $"{{RequestBase}}/iptv/artwork/{artworkFolder}/{artwork.Path}.jpg{{AccessTokenUri}}";
|
||||||
|
}
|
||||||
|
|
||||||
|
return artworkPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetTitle(PlayoutItem playoutItem)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(playoutItem.CustomTitle))
|
||||||
|
{
|
||||||
|
return playoutItem.CustomTitle;
|
||||||
|
}
|
||||||
|
|
||||||
|
return playoutItem.MediaItem switch
|
||||||
|
{
|
||||||
|
Movie m => m.MovieMetadata.HeadOrNone().Map(mm => mm.Title ?? string.Empty)
|
||||||
|
.IfNone("[unknown movie]"),
|
||||||
|
Episode e => e.Season.Show.ShowMetadata.HeadOrNone().Map(em => em.Title ?? string.Empty)
|
||||||
|
.IfNone("[unknown show]"),
|
||||||
|
MusicVideo mv => mv.Artist.ArtistMetadata.HeadOrNone().Map(am => am.Title ?? string.Empty)
|
||||||
|
.IfNone("[unknown artist]"),
|
||||||
|
OtherVideo ov => ov.OtherVideoMetadata.HeadOrNone().Map(vm => vm.Title ?? string.Empty)
|
||||||
|
.IfNone("[unknown video]"),
|
||||||
|
Song s => s.SongMetadata.HeadOrNone().Map(sm => sm.Artist ?? string.Empty)
|
||||||
|
.IfNone("[unknown artist]"),
|
||||||
|
_ => "[unknown]"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetSubtitle(PlayoutItem playoutItem)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(playoutItem.CustomTitle))
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
return playoutItem.MediaItem switch
|
||||||
|
{
|
||||||
|
Episode e => e.EpisodeMetadata.HeadOrNone().Match(
|
||||||
|
em => em.Title ?? string.Empty,
|
||||||
|
() => string.Empty),
|
||||||
|
MusicVideo mv => mv.MusicVideoMetadata.HeadOrNone().Match(
|
||||||
|
mvm => mvm.Title ?? string.Empty,
|
||||||
|
() => string.Empty),
|
||||||
|
Song s => s.SongMetadata.HeadOrNone().Match(
|
||||||
|
mvm => mvm.Title ?? string.Empty,
|
||||||
|
() => string.Empty),
|
||||||
|
_ => string.Empty
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetDescription(PlayoutItem playoutItem)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(playoutItem.CustomTitle))
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
return playoutItem.MediaItem switch
|
||||||
|
{
|
||||||
|
Movie m => m.MovieMetadata.HeadOrNone().Map(mm => mm.Plot ?? string.Empty).IfNone(string.Empty),
|
||||||
|
Episode e => e.EpisodeMetadata.HeadOrNone().Map(em => em.Plot ?? string.Empty)
|
||||||
|
.IfNone(string.Empty),
|
||||||
|
MusicVideo mv => mv.MusicVideoMetadata.HeadOrNone().Map(mvm => mvm.Plot ?? string.Empty)
|
||||||
|
.IfNone(string.Empty),
|
||||||
|
OtherVideo ov => ov.OtherVideoMetadata.HeadOrNone().Map(ovm => ovm.Plot ?? string.Empty)
|
||||||
|
.IfNone(string.Empty),
|
||||||
|
_ => string.Empty
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private Option<ContentRating> GetContentRating(PlayoutItem playoutItem)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return playoutItem.MediaItem switch
|
||||||
|
{
|
||||||
|
Movie m => m.MovieMetadata
|
||||||
|
.HeadOrNone()
|
||||||
|
.Match(mm => ParseContentRating(mm.ContentRating, "MPAA"), () => None),
|
||||||
|
Episode e => e.Season.Show.ShowMetadata
|
||||||
|
.HeadOrNone()
|
||||||
|
.Match(sm => ParseContentRating(sm.ContentRating, "VCHIP"), () => None),
|
||||||
|
_ => None
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to get content rating for playout item {Item}", GetTitle(playoutItem));
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Option<ContentRating> ParseContentRating(string contentRating, string system)
|
||||||
|
{
|
||||||
|
Option<string> maybeFirst = (contentRating ?? string.Empty).Split('/').HeadOrNone();
|
||||||
|
return maybeFirst.Map(
|
||||||
|
first =>
|
||||||
|
{
|
||||||
|
string[] split = first.Split(':');
|
||||||
|
if (split.Length == 2)
|
||||||
|
{
|
||||||
|
return split[0].ToLowerInvariant() == "us"
|
||||||
|
? new ContentRating(system, split[1].ToUpperInvariant())
|
||||||
|
: new ContentRating(None, split[1].ToUpperInvariant());
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.IsNullOrWhiteSpace(first)
|
||||||
|
? Option<ContentRating>.None
|
||||||
|
: new ContentRating(None, first);
|
||||||
|
}).Flatten();
|
||||||
|
}
|
||||||
|
|
||||||
|
private record ContentRating(Option<string> System, string Value);
|
||||||
|
|
||||||
|
private string GetPrioritizedArtworkPath(Metadata metadata)
|
||||||
|
{
|
||||||
|
Option<string> maybeArtwork = Optional(metadata.Artwork).Flatten()
|
||||||
|
.Filter(a => a.ArtworkKind == ArtworkKind.Poster)
|
||||||
|
.HeadOrNone()
|
||||||
|
.Map(a => GetArtworkUrl(a, ArtworkKind.Poster));
|
||||||
|
|
||||||
|
if (maybeArtwork.IsNone)
|
||||||
|
{
|
||||||
|
maybeArtwork = Optional(metadata.Artwork).Flatten()
|
||||||
|
.Filter(a => a.ArtworkKind == ArtworkKind.Thumbnail)
|
||||||
|
.HeadOrNone()
|
||||||
|
.Map(a => GetArtworkUrl(a, ArtworkKind.Thumbnail));
|
||||||
|
}
|
||||||
|
|
||||||
|
return maybeArtwork.IfNone(string.Empty);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
namespace ErsatzTV.Application.Channels;
|
||||||
|
|
||||||
|
public record RefreshChannelList : IRequest, IBackgroundServiceRequest;
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
using System.Data;
|
||||||
|
using System.Data.Common;
|
||||||
|
using System.Xml;
|
||||||
|
using Dapper;
|
||||||
|
using ErsatzTV.Core;
|
||||||
|
using ErsatzTV.Core.Interfaces.Metadata;
|
||||||
|
using ErsatzTV.Infrastructure.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.IO;
|
||||||
|
|
||||||
|
namespace ErsatzTV.Application.Channels;
|
||||||
|
|
||||||
|
public class RefreshChannelListHandler : IRequestHandler<RefreshChannelList>
|
||||||
|
{
|
||||||
|
private readonly RecyclableMemoryStreamManager _recyclableMemoryStreamManager;
|
||||||
|
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||||
|
private readonly ILocalFileSystem _localFileSystem;
|
||||||
|
|
||||||
|
public RefreshChannelListHandler(
|
||||||
|
RecyclableMemoryStreamManager recyclableMemoryStreamManager,
|
||||||
|
IDbContextFactory<TvContext> dbContextFactory,
|
||||||
|
ILocalFileSystem localFileSystem)
|
||||||
|
{
|
||||||
|
_recyclableMemoryStreamManager = recyclableMemoryStreamManager;
|
||||||
|
_dbContextFactory = dbContextFactory;
|
||||||
|
_localFileSystem = localFileSystem;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task Handle(RefreshChannelList request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
_localFileSystem.EnsureFolderExists(FileSystemLayout.ChannelGuideCacheFolder);
|
||||||
|
|
||||||
|
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
|
||||||
|
using MemoryStream ms = _recyclableMemoryStreamManager.GetStream();
|
||||||
|
await using var xml = XmlWriter.Create(
|
||||||
|
ms,
|
||||||
|
new XmlWriterSettings { Async = true, ConformanceLevel = ConformanceLevel.Fragment });
|
||||||
|
|
||||||
|
await foreach (ChannelResult channel in GetChannels(dbContext).WithCancellation(cancellationToken))
|
||||||
|
{
|
||||||
|
await xml.WriteStartElementAsync(null, "channel", null);
|
||||||
|
await xml.WriteAttributeStringAsync(null, "id", null, $"{channel.Number}.etv");
|
||||||
|
|
||||||
|
await xml.WriteStartElementAsync(null, "display-name", null);
|
||||||
|
await xml.WriteStringAsync($"{channel.Number} {channel.Name}");
|
||||||
|
await xml.WriteEndElementAsync(); // display-name (number and name)
|
||||||
|
|
||||||
|
await xml.WriteStartElementAsync(null, "display-name", null);
|
||||||
|
await xml.WriteStringAsync(channel.Number);
|
||||||
|
await xml.WriteEndElementAsync(); // display-name (number)
|
||||||
|
|
||||||
|
await xml.WriteStartElementAsync(null, "display-name", null);
|
||||||
|
await xml.WriteStringAsync(channel.Name);
|
||||||
|
await xml.WriteEndElementAsync(); // display-name (name)
|
||||||
|
|
||||||
|
foreach (string category in GetCategories(channel.Categories))
|
||||||
|
{
|
||||||
|
await xml.WriteStartElementAsync(null, "category", null);
|
||||||
|
await xml.WriteAttributeStringAsync(null, "lang", null, "en");
|
||||||
|
await xml.WriteStringAsync(category);
|
||||||
|
await xml.WriteEndElementAsync(); // category
|
||||||
|
}
|
||||||
|
|
||||||
|
await xml.WriteStartElementAsync(null, "icon", null);
|
||||||
|
await xml.WriteAttributeStringAsync(null, "src", null, GetIconUrl(channel));
|
||||||
|
await xml.WriteEndElementAsync(); // icon
|
||||||
|
|
||||||
|
await xml.WriteEndElementAsync(); // channel
|
||||||
|
}
|
||||||
|
|
||||||
|
await xml.FlushAsync();
|
||||||
|
|
||||||
|
string tempFile = Path.GetTempFileName();
|
||||||
|
await File.WriteAllBytesAsync(tempFile, ms.ToArray(), cancellationToken);
|
||||||
|
|
||||||
|
string targetFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, "channels.xml");
|
||||||
|
File.Move(tempFile, targetFile, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async IAsyncEnumerable<ChannelResult> GetChannels(TvContext dbContext)
|
||||||
|
{
|
||||||
|
const string QUERY = @"select C.Number, C.Name, C.Categories, A.Path as ArtworkPath
|
||||||
|
from Channel C
|
||||||
|
left outer join Artwork A on C.Id = A.ChannelId and A.ArtworkKind = 2
|
||||||
|
where C.Id in (select ChannelId from Playout)
|
||||||
|
order by CAST(C.Number as real)";
|
||||||
|
|
||||||
|
await using var reader = (DbDataReader)await dbContext.Connection.ExecuteReaderAsync(QUERY);
|
||||||
|
Func<IDataReader, ChannelResult> rowParser = reader.GetRowParser<ChannelResult>();
|
||||||
|
|
||||||
|
while (await reader.ReadAsync()) {
|
||||||
|
yield return rowParser(reader);
|
||||||
|
}
|
||||||
|
|
||||||
|
while (await reader.NextResultAsync()) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<string> GetCategories(string categories) =>
|
||||||
|
(categories ?? string.Empty).Split(',')
|
||||||
|
.Map(s => s.Trim())
|
||||||
|
.Filter(s => !string.IsNullOrWhiteSpace(s))
|
||||||
|
.Distinct()
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
private static string GetIconUrl(ChannelResult channel) =>
|
||||||
|
string.IsNullOrWhiteSpace(channel.ArtworkPath)
|
||||||
|
? "{RequestBase}/iptv/images/ersatztv-500.png{AccessTokenUri}"
|
||||||
|
: $"{{RequestBase}}/iptv/logos/{channel.ArtworkPath}.jpg{{AccessTokenUri}}";
|
||||||
|
|
||||||
|
// ReSharper disable once ClassNeverInstantiated.Local
|
||||||
|
private record ChannelResult(string Number, string Name, string Categories, string ArtworkPath);
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
|
using ErsatzTV.Core;
|
||||||
using ErsatzTV.Core.Iptv;
|
using ErsatzTV.Core.Iptv;
|
||||||
|
|
||||||
namespace ErsatzTV.Application.Channels;
|
namespace ErsatzTV.Application.Channels;
|
||||||
|
|
||||||
public record GetChannelGuide(string Scheme, string Host, string BaseUrl, string AccessToken) : IRequest<ChannelGuide>;
|
public record GetChannelGuide
|
||||||
|
(string Scheme, string Host, string BaseUrl, string AccessToken) : IRequest<Either<BaseError, ChannelGuide>>;
|
||||||
|
|||||||
@@ -1,30 +1,72 @@
|
|||||||
using ErsatzTV.Core.Interfaces.Repositories;
|
using System.Text;
|
||||||
|
using ErsatzTV.Core;
|
||||||
|
using ErsatzTV.Core.Interfaces.Metadata;
|
||||||
using ErsatzTV.Core.Iptv;
|
using ErsatzTV.Core.Iptv;
|
||||||
|
using ErsatzTV.Infrastructure.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.IO;
|
using Microsoft.IO;
|
||||||
|
|
||||||
namespace ErsatzTV.Application.Channels;
|
namespace ErsatzTV.Application.Channels;
|
||||||
|
|
||||||
public class GetChannelGuideHandler : IRequestHandler<GetChannelGuide, ChannelGuide>
|
public class GetChannelGuideHandler : IRequestHandler<GetChannelGuide, Either<BaseError, ChannelGuide>>
|
||||||
{
|
{
|
||||||
private readonly IChannelRepository _channelRepository;
|
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||||
private readonly RecyclableMemoryStreamManager _recyclableMemoryStreamManager;
|
private readonly RecyclableMemoryStreamManager _recyclableMemoryStreamManager;
|
||||||
|
private readonly ILocalFileSystem _localFileSystem;
|
||||||
|
|
||||||
public GetChannelGuideHandler(
|
public GetChannelGuideHandler(
|
||||||
IChannelRepository channelRepository,
|
IDbContextFactory<TvContext> dbContextFactory,
|
||||||
RecyclableMemoryStreamManager recyclableMemoryStreamManager)
|
RecyclableMemoryStreamManager recyclableMemoryStreamManager,
|
||||||
|
ILocalFileSystem localFileSystem)
|
||||||
{
|
{
|
||||||
_channelRepository = channelRepository;
|
_dbContextFactory = dbContextFactory;
|
||||||
_recyclableMemoryStreamManager = recyclableMemoryStreamManager;
|
_recyclableMemoryStreamManager = recyclableMemoryStreamManager;
|
||||||
|
_localFileSystem = localFileSystem;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<ChannelGuide> Handle(GetChannelGuide request, CancellationToken cancellationToken) =>
|
public async Task<Either<BaseError, ChannelGuide>> Handle(
|
||||||
_channelRepository.GetAllForGuide()
|
GetChannelGuide request,
|
||||||
.Map(
|
CancellationToken cancellationToken)
|
||||||
channels => new ChannelGuide(
|
{
|
||||||
_recyclableMemoryStreamManager,
|
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
request.Scheme,
|
|
||||||
request.Host,
|
string channelsFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, "channels.xml");
|
||||||
request.BaseUrl,
|
if (!_localFileSystem.FileExists(channelsFile))
|
||||||
channels,
|
{
|
||||||
request.AccessToken));
|
return BaseError.New($"Required file {channelsFile} is missing");
|
||||||
|
}
|
||||||
|
|
||||||
|
string accessTokenUri = string.Empty;
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.AccessToken))
|
||||||
|
{
|
||||||
|
accessTokenUri = $"?access_token={request.AccessToken}";
|
||||||
|
}
|
||||||
|
|
||||||
|
string channelsFragment = await File.ReadAllTextAsync(channelsFile, Encoding.UTF8, cancellationToken);
|
||||||
|
|
||||||
|
// TODO: is regex faster?
|
||||||
|
channelsFragment = channelsFragment
|
||||||
|
.Replace("{RequestBase}", $"{request.Scheme}://{request.Host}{request.BaseUrl}")
|
||||||
|
.Replace("{AccessTokenUri}", accessTokenUri);
|
||||||
|
|
||||||
|
var channelDataFragments = new Dictionary<string, string>();
|
||||||
|
|
||||||
|
foreach (string fileName in _localFileSystem.ListFiles(FileSystemLayout.ChannelGuideCacheFolder))
|
||||||
|
{
|
||||||
|
if (fileName.Contains("channels"))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
string channelDataFragment = await File.ReadAllTextAsync(fileName, Encoding.UTF8, cancellationToken);
|
||||||
|
|
||||||
|
channelDataFragment = channelDataFragment
|
||||||
|
.Replace("{RequestBase}", $"{request.Scheme}://{request.Host}{request.BaseUrl}")
|
||||||
|
.Replace("{AccessTokenUri}", accessTokenUri);
|
||||||
|
|
||||||
|
channelDataFragments.Add(Path.GetFileNameWithoutExtension(fileName), channelDataFragment);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ChannelGuide(_recyclableMemoryStreamManager, channelsFragment, channelDataFragments);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
using System.Threading.Channels;
|
using System.Threading.Channels;
|
||||||
using Bugsnag;
|
using Bugsnag;
|
||||||
|
using Dapper;
|
||||||
|
using ErsatzTV.Application.Channels;
|
||||||
using ErsatzTV.Application.Subtitles;
|
using ErsatzTV.Application.Subtitles;
|
||||||
using ErsatzTV.Core;
|
using ErsatzTV.Core;
|
||||||
using ErsatzTV.Core.Domain;
|
using ErsatzTV.Core.Domain;
|
||||||
@@ -56,6 +58,23 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
|
|||||||
_ffmpegSegmenterService.PlayoutUpdated(playout.Channel.Number);
|
_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));
|
await _workerChannel.WriteAsync(new ExtractEmbeddedSubtitles(playout.Id));
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Threading.Channels;
|
using System.Threading.Channels;
|
||||||
|
using ErsatzTV.Application.Channels;
|
||||||
using ErsatzTV.Core;
|
using ErsatzTV.Core;
|
||||||
using ErsatzTV.Core.Domain;
|
using ErsatzTV.Core.Domain;
|
||||||
using ErsatzTV.Core.Scheduling;
|
using ErsatzTV.Core.Scheduling;
|
||||||
@@ -28,7 +29,7 @@ public class CreatePlayoutHandler : IRequestHandler<CreatePlayout, Either<BaseEr
|
|||||||
{
|
{
|
||||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
Validation<BaseError, Playout> validation = await Validate(dbContext, request);
|
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)
|
private async Task<CreatePlayoutResponse> PersistPlayout(TvContext dbContext, Playout playout)
|
||||||
@@ -36,6 +37,7 @@ public class CreatePlayoutHandler : IRequestHandler<CreatePlayout, Either<BaseEr
|
|||||||
await dbContext.Playouts.AddAsync(playout);
|
await dbContext.Playouts.AddAsync(playout);
|
||||||
await dbContext.SaveChangesAsync();
|
await dbContext.SaveChangesAsync();
|
||||||
await _channel.WriteAsync(new BuildPlayout(playout.Id, PlayoutBuildMode.Reset));
|
await _channel.WriteAsync(new BuildPlayout(playout.Id, PlayoutBuildMode.Reset));
|
||||||
|
await _channel.WriteAsync(new RefreshChannelList());
|
||||||
return new CreatePlayoutResponse(playout.Id);
|
return new CreatePlayoutResponse(playout.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.Domain;
|
||||||
|
using ErsatzTV.Core.Interfaces.Metadata;
|
||||||
using ErsatzTV.Infrastructure.Data;
|
using ErsatzTV.Infrastructure.Data;
|
||||||
|
using ErsatzTV.Infrastructure.Extensions;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace ErsatzTV.Application.Playouts;
|
namespace ErsatzTV.Application.Playouts;
|
||||||
|
|
||||||
public class DeletePlayoutHandler : IRequestHandler<DeletePlayout, Either<BaseError, Unit>>
|
public class DeletePlayoutHandler : IRequestHandler<DeletePlayout, Either<BaseError, Unit>>
|
||||||
{
|
{
|
||||||
|
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
|
||||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
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;
|
_dbContextFactory = dbContextFactory;
|
||||||
|
_localFileSystem = localFileSystem;
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<Either<BaseError, Unit>> Handle(
|
public async Task<Either<BaseError, Unit>> Handle(
|
||||||
DeletePlayout request,
|
DeletePlayout request,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
|
||||||
Option<Playout> maybePlayout = await dbContext.Playouts
|
Option<Playout> maybePlayout = await dbContext.Playouts
|
||||||
.OrderBy(p => p.Id)
|
.AsNoTracking()
|
||||||
.FirstOrDefaultAsync(p => p.Id == request.PlayoutId, cancellationToken);
|
.Include(p => p.Channel)
|
||||||
|
.SelectOneAsync(p => p.Id, p => p.Id == request.PlayoutId);
|
||||||
|
|
||||||
foreach (Playout playout in maybePlayout)
|
foreach (Playout playout in maybePlayout)
|
||||||
{
|
{
|
||||||
dbContext.Playouts.Remove(playout);
|
dbContext.Playouts.Remove(playout);
|
||||||
await dbContext.SaveChangesAsync(cancellationToken);
|
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
|
return maybePlayout
|
||||||
|
|||||||
@@ -40,8 +40,8 @@ public static class EmbyUrl
|
|||||||
.AppendPathSegment(pathSegment)
|
.AppendPathSegment(pathSegment)
|
||||||
.SetQueryParams(query);
|
.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('?');
|
string[] split = artwork.Replace("emby://", string.Empty).Split('?');
|
||||||
if (split.Length != 2)
|
if (split.Length != 2)
|
||||||
@@ -58,9 +58,12 @@ public static class EmbyUrl
|
|||||||
_ => "posters"
|
_ => "posters"
|
||||||
};
|
};
|
||||||
|
|
||||||
return Url.Parse($"{scheme}://{host}/iptv/artwork/{artworkFolder}/emby")
|
return Url.Parse($"http://not-a-real-host/iptv/artwork/{artworkFolder}/emby")
|
||||||
.AppendPathSegment(pathSegment)
|
.AppendPathSegment(pathSegment)
|
||||||
.SetQueryParams(query);
|
.SetQueryParams(query)
|
||||||
|
.SetQueryParam("maxHeight", height)
|
||||||
|
.ToString()
|
||||||
|
.Replace("http://not-a-real-host", "{RequestBase}");
|
||||||
}
|
}
|
||||||
|
|
||||||
public static Url RelativeProxyForArtwork(string artwork)
|
public static Url RelativeProxyForArtwork(string artwork)
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ public static class FileSystemLayout
|
|||||||
|
|
||||||
public static readonly string LegacyImageCacheFolder = Path.Combine(AppDataFolder, "cache", "images");
|
public static readonly string LegacyImageCacheFolder = Path.Combine(AppDataFolder, "cache", "images");
|
||||||
public static readonly string ResourcesCacheFolder = Path.Combine(AppDataFolder, "cache", "resources");
|
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 PlexSecretsPath = Path.Combine(AppDataFolder, "plex-secrets.json");
|
||||||
public static readonly string JellyfinSecretsPath = Path.Combine(AppDataFolder, "jellyfin-secrets.json");
|
public static readonly string JellyfinSecretsPath = Path.Combine(AppDataFolder, "jellyfin-secrets.json");
|
||||||
|
|||||||
@@ -1,42 +1,23 @@
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Xml;
|
using System.Xml;
|
||||||
using ErsatzTV.Core.Domain;
|
|
||||||
using ErsatzTV.Core.Domain.Filler;
|
|
||||||
using ErsatzTV.Core.Emby;
|
|
||||||
using ErsatzTV.Core.Jellyfin;
|
|
||||||
using Microsoft.IO;
|
using Microsoft.IO;
|
||||||
using Serilog;
|
|
||||||
|
|
||||||
namespace ErsatzTV.Core.Iptv;
|
namespace ErsatzTV.Core.Iptv;
|
||||||
|
|
||||||
public class ChannelGuide
|
public class ChannelGuide
|
||||||
{
|
{
|
||||||
private readonly List<Channel> _channels;
|
private readonly string _channelsFragment;
|
||||||
private readonly string _host;
|
private readonly Dictionary<string, string> _channelDataFragments;
|
||||||
private readonly string _baseUrl;
|
|
||||||
private readonly RecyclableMemoryStreamManager _recyclableMemoryStreamManager;
|
private readonly RecyclableMemoryStreamManager _recyclableMemoryStreamManager;
|
||||||
private readonly string _scheme;
|
|
||||||
private readonly string _accessTokenUri;
|
|
||||||
|
|
||||||
public ChannelGuide(
|
public ChannelGuide(
|
||||||
RecyclableMemoryStreamManager recyclableMemoryStreamManager,
|
RecyclableMemoryStreamManager recyclableMemoryStreamManager,
|
||||||
string scheme,
|
string channelsFragment,
|
||||||
string host,
|
Dictionary<string, string> channelDataFragments)
|
||||||
string baseUrl,
|
|
||||||
List<Channel> channels,
|
|
||||||
string accessToken)
|
|
||||||
{
|
{
|
||||||
_recyclableMemoryStreamManager = recyclableMemoryStreamManager;
|
_recyclableMemoryStreamManager = recyclableMemoryStreamManager;
|
||||||
_scheme = scheme;
|
_channelsFragment = channelsFragment;
|
||||||
_host = host;
|
_channelDataFragments = channelDataFragments;
|
||||||
_baseUrl = baseUrl;
|
|
||||||
_channels = channels;
|
|
||||||
|
|
||||||
_accessTokenUri = string.Empty;
|
|
||||||
if (!string.IsNullOrWhiteSpace(accessToken))
|
|
||||||
{
|
|
||||||
_accessTokenUri = $"?access_token={accessToken}";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public string ToXml()
|
public string ToXml()
|
||||||
@@ -47,314 +28,13 @@ public class ChannelGuide
|
|||||||
|
|
||||||
xml.WriteStartElement("tv");
|
xml.WriteStartElement("tv");
|
||||||
xml.WriteAttributeString("generator-info-name", "ersatztv");
|
xml.WriteAttributeString("generator-info-name", "ersatztv");
|
||||||
|
|
||||||
|
xml.WriteRaw(_channelsFragment);
|
||||||
|
|
||||||
var sortedChannelItems = new Dictionary<Channel, List<PlayoutItem>>();
|
foreach ((string channelNumber, string channelDataFragment) in _channelDataFragments.OrderBy(
|
||||||
|
kvp => decimal.Parse(kvp.Key)))
|
||||||
foreach (Channel channel in _channels.OrderBy(c => decimal.Parse(c.Number)))
|
|
||||||
{
|
{
|
||||||
var sortedItems = channel.Playouts.Collect(p => p.Items).OrderBy(x => x.Start).ToList();
|
xml.WriteRaw(channelDataFragment);
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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<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.WriteEndElement(); // tv
|
xml.WriteEndElement(); // tv
|
||||||
@@ -363,172 +43,4 @@ public class ChannelGuide
|
|||||||
xml.Flush();
|
xml.Flush();
|
||||||
return Encoding.UTF8.GetString(ms.ToArray());
|
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,31 @@ public static class JellyfinUrl
|
|||||||
.SetQueryParams(query);
|
.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)
|
public static Url ProxyForArtwork(string scheme, string host, string artwork, ArtworkKind artworkKind)
|
||||||
{
|
{
|
||||||
string[] split = artwork.Replace("jellyfin://", string.Empty).Split('?');
|
string[] split = artwork.Replace("jellyfin://", string.Empty).Split('?');
|
||||||
|
|||||||
@@ -47,8 +47,9 @@ public class ChannelRepository : IChannelRepository
|
|||||||
|
|
||||||
public async Task<List<Channel>> GetAllForGuide()
|
public async Task<List<Channel>> GetAllForGuide()
|
||||||
{
|
{
|
||||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||||
return await dbContext.Channels
|
return await dbContext.Channels
|
||||||
|
.AsNoTracking()
|
||||||
.Include(c => c.Artwork)
|
.Include(c => c.Artwork)
|
||||||
.Include(c => c.Playouts)
|
.Include(c => c.Playouts)
|
||||||
.ThenInclude(p => p.Items)
|
.ThenInclude(p => p.Items)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ using ErsatzTV.Core.Errors;
|
|||||||
using ErsatzTV.Core.FFmpeg;
|
using ErsatzTV.Core.FFmpeg;
|
||||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||||
using ErsatzTV.Core.Iptv;
|
using ErsatzTV.Core.Iptv;
|
||||||
|
using ErsatzTV.Extensions;
|
||||||
using ErsatzTV.Filters;
|
using ErsatzTV.Filters;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
@@ -55,7 +56,7 @@ public class IptvController : ControllerBase
|
|||||||
Request.Host.ToString(),
|
Request.Host.ToString(),
|
||||||
Request.PathBase,
|
Request.PathBase,
|
||||||
Request.Query["access_token"]))
|
Request.Query["access_token"]))
|
||||||
.Map<ChannelGuide, IActionResult>(Ok);
|
.ToActionResult();
|
||||||
|
|
||||||
[HttpGet("iptv/hdhr/channel/{channelNumber}.ts")]
|
[HttpGet("iptv/hdhr/channel/{channelNumber}.ts")]
|
||||||
public Task<IActionResult> GetHDHRVideo(string channelNumber, [FromQuery] string mode = "ts")
|
public Task<IActionResult> GetHDHRVideo(string channelNumber, [FromQuery] string mode = "ts")
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Threading.Channels;
|
using System.Threading.Channels;
|
||||||
using Bugsnag;
|
using Bugsnag;
|
||||||
using ErsatzTV.Application;
|
using ErsatzTV.Application;
|
||||||
|
using ErsatzTV.Application.Channels;
|
||||||
using ErsatzTV.Application.Emby;
|
using ErsatzTV.Application.Emby;
|
||||||
using ErsatzTV.Application.Jellyfin;
|
using ErsatzTV.Application.Jellyfin;
|
||||||
using ErsatzTV.Application.Maintenance;
|
using ErsatzTV.Application.Maintenance;
|
||||||
@@ -91,6 +92,7 @@ public class SchedulerService : BackgroundService
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
await DeleteOrphanedArtwork(cancellationToken);
|
await DeleteOrphanedArtwork(cancellationToken);
|
||||||
|
await RefreshChannelGuideChannelList(cancellationToken);
|
||||||
await BuildPlayouts(cancellationToken);
|
await BuildPlayouts(cancellationToken);
|
||||||
#if !DEBUG_NO_SYNC
|
#if !DEBUG_NO_SYNC
|
||||||
await ScanLocalMediaSources(cancellationToken);
|
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)
|
private async Task ScanLocalMediaSources(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
using IServiceScope scope = _serviceScopeFactory.CreateScope();
|
using IServiceScope scope = _serviceScopeFactory.CreateScope();
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Threading.Channels;
|
using System.Threading.Channels;
|
||||||
using Bugsnag;
|
using Bugsnag;
|
||||||
using ErsatzTV.Application;
|
using ErsatzTV.Application;
|
||||||
|
using ErsatzTV.Application.Channels;
|
||||||
using ErsatzTV.Application.Maintenance;
|
using ErsatzTV.Application.Maintenance;
|
||||||
using ErsatzTV.Application.MediaCollections;
|
using ErsatzTV.Application.MediaCollections;
|
||||||
using ErsatzTV.Application.Playouts;
|
using ErsatzTV.Application.Playouts;
|
||||||
@@ -47,6 +48,12 @@ public class WorkerService : BackgroundService
|
|||||||
|
|
||||||
switch (request)
|
switch (request)
|
||||||
{
|
{
|
||||||
|
case RefreshChannelList refreshChannelList:
|
||||||
|
await mediator.Send(refreshChannelList, cancellationToken);
|
||||||
|
break;
|
||||||
|
case RefreshChannelData refreshChannelData:
|
||||||
|
await mediator.Send(refreshChannelData, cancellationToken);
|
||||||
|
break;
|
||||||
case BuildPlayout buildPlayout:
|
case BuildPlayout buildPlayout:
|
||||||
Either<BaseError, Unit> buildPlayoutResult = await mediator.Send(
|
Either<BaseError, Unit> buildPlayoutResult = await mediator.Send(
|
||||||
buildPlayout,
|
buildPlayout,
|
||||||
|
|||||||
Reference in New Issue
Block a user