generate xmltv for external json playouts (#1541)
This commit is contained in:
@@ -20,7 +20,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
|||||||
- If found, ErsatzTV will run ffprobe to get statistics immediately before streaming from disk
|
- If found, ErsatzTV will run ffprobe to get statistics immediately before streaming from disk
|
||||||
- When local files are unavailable, ErsatzTV must be logged into the same Plex server as DizqueTV
|
- When local files are unavailable, ErsatzTV must be logged into the same Plex server as DizqueTV
|
||||||
- ErsatzTV will ask Plex for statistics immediately before streaming from Plex
|
- ErsatzTV will ask Plex for statistics immediately before streaming from Plex
|
||||||
- **Note: XMLTV GUIDE DATA IS NOT YET SUPPORTED FOR THIS PLAYOUT TYPE**
|
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- Fix error loading path replacements when using MySql
|
- Fix error loading path replacements when using MySql
|
||||||
|
|||||||
@@ -6,10 +6,12 @@ using ErsatzTV.Core.Domain.Filler;
|
|||||||
using ErsatzTV.Core.Emby;
|
using ErsatzTV.Core.Emby;
|
||||||
using ErsatzTV.Core.Interfaces.Metadata;
|
using ErsatzTV.Core.Interfaces.Metadata;
|
||||||
using ErsatzTV.Core.Jellyfin;
|
using ErsatzTV.Core.Jellyfin;
|
||||||
|
using ErsatzTV.Core.Streaming;
|
||||||
using ErsatzTV.Infrastructure.Data;
|
using ErsatzTV.Infrastructure.Data;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.IO;
|
using Microsoft.IO;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
namespace ErsatzTV.Application.Channels;
|
namespace ErsatzTV.Application.Channels;
|
||||||
|
|
||||||
@@ -38,7 +40,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
|||||||
|
|
||||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
|
||||||
List<PlayoutItem> sorted = await dbContext.Playouts
|
List<Playout> playouts = await dbContext.Playouts
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Filter(pi => pi.Channel.Number == request.ChannelNumber)
|
.Filter(pi => pi.Channel.Number == request.ChannelNumber)
|
||||||
.Include(p => p.Items)
|
.Include(p => p.Items)
|
||||||
@@ -85,8 +87,22 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
|||||||
.ThenInclude(i => i.MediaItem)
|
.ThenInclude(i => i.MediaItem)
|
||||||
.ThenInclude(i => (i as Song).SongMetadata)
|
.ThenInclude(i => (i as Song).SongMetadata)
|
||||||
.ThenInclude(vm => vm.Artwork)
|
.ThenInclude(vm => vm.Artwork)
|
||||||
.ToListAsync(cancellationToken)
|
.ToListAsync(cancellationToken);
|
||||||
.Map(list => list.Collect(p => p.Items).OrderBy(pi => pi.Start).ToList());
|
|
||||||
|
List<PlayoutItem> sorted = [];
|
||||||
|
|
||||||
|
foreach (Playout playout in playouts)
|
||||||
|
{
|
||||||
|
switch (playout.ProgramSchedulePlayoutType)
|
||||||
|
{
|
||||||
|
case ProgramSchedulePlayoutType.Flood:
|
||||||
|
sorted.AddRange(playouts.Collect(p => p.Items).OrderBy(pi => pi.Start));
|
||||||
|
break;
|
||||||
|
case ProgramSchedulePlayoutType.ExternalJson:
|
||||||
|
sorted.AddRange(await CollectExternalJsonItems(playout.ExternalJsonFile));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await using RecyclableMemoryStream ms = _recyclableMemoryStreamManager.GetStream();
|
await using RecyclableMemoryStream ms = _recyclableMemoryStreamManager.GetStream();
|
||||||
await using var xml = XmlWriter.Create(
|
await using var xml = XmlWriter.Create(
|
||||||
@@ -374,6 +390,10 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
|||||||
_ => 440
|
_ => 440
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (artworkPath.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || artworkPath.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return artworkPath;
|
||||||
|
}
|
||||||
if (artworkPath.StartsWith("jellyfin://", StringComparison.OrdinalIgnoreCase))
|
if (artworkPath.StartsWith("jellyfin://", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
artworkPath = JellyfinUrl.PlaceholderProxyForArtwork(artworkPath, artworkKind, height);
|
artworkPath = JellyfinUrl.PlaceholderProxyForArtwork(artworkPath, artworkKind, height);
|
||||||
@@ -521,5 +541,147 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
|||||||
return maybeArtwork.IfNone(string.Empty);
|
return maybeArtwork.IfNone(string.Empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<List<PlayoutItem>> CollectExternalJsonItems(string path)
|
||||||
|
{
|
||||||
|
var result = new List<PlayoutItem>();
|
||||||
|
|
||||||
|
if (_localFileSystem.FileExists(path))
|
||||||
|
{
|
||||||
|
Option<ExternalJsonChannel> maybeChannel = JsonConvert.DeserializeObject<ExternalJsonChannel>(
|
||||||
|
await File.ReadAllTextAsync(path));
|
||||||
|
|
||||||
|
// must deserialize channel from json
|
||||||
|
foreach (ExternalJsonChannel channel in maybeChannel)
|
||||||
|
{
|
||||||
|
// TODO: null start time should log and throw
|
||||||
|
|
||||||
|
DateTimeOffset startTime = DateTimeOffset.Parse(
|
||||||
|
channel.StartTime ?? string.Empty,
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
DateTimeStyles.AssumeUniversal).ToLocalTime();
|
||||||
|
|
||||||
|
for (var i = 0; i < channel.Programs.Length; i++)
|
||||||
|
{
|
||||||
|
ExternalJsonProgram program = channel.Programs[i];
|
||||||
|
int milliseconds = program.Duration;
|
||||||
|
DateTimeOffset nextStart = startTime + TimeSpan.FromMilliseconds(milliseconds);
|
||||||
|
if (program.Duration >= channel.GuideMinimumDurationSeconds * 1000)
|
||||||
|
{
|
||||||
|
result.Add(BuildPlayoutItem(startTime, program, i));
|
||||||
|
}
|
||||||
|
|
||||||
|
startTime = nextStart;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PlayoutItem BuildPlayoutItem(DateTimeOffset startTime, ExternalJsonProgram program, int count)
|
||||||
|
{
|
||||||
|
MediaItem mediaItem = program.Type switch
|
||||||
|
{
|
||||||
|
"episode" => BuildEpisode(program),
|
||||||
|
_ => BuildMovie(program)
|
||||||
|
};
|
||||||
|
|
||||||
|
return new PlayoutItem
|
||||||
|
{
|
||||||
|
Start = startTime.UtcDateTime,
|
||||||
|
Finish = startTime.AddMilliseconds(program.Duration).UtcDateTime,
|
||||||
|
FillerKind = FillerKind.None,
|
||||||
|
ChapterTitle = null,
|
||||||
|
GuideFinish = null,
|
||||||
|
GuideGroup = count,
|
||||||
|
CustomTitle = null,
|
||||||
|
InPoint = TimeSpan.Zero,
|
||||||
|
OutPoint = TimeSpan.FromMilliseconds(program.Duration),
|
||||||
|
MediaItem = mediaItem
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Episode BuildEpisode(ExternalJsonProgram program)
|
||||||
|
{
|
||||||
|
var artwork = new List<Artwork>();
|
||||||
|
if (!string.IsNullOrWhiteSpace(program.Icon))
|
||||||
|
{
|
||||||
|
artwork.Add(new Artwork
|
||||||
|
{
|
||||||
|
ArtworkKind = ArtworkKind.Thumbnail,
|
||||||
|
Path = program.Icon,
|
||||||
|
SourcePath = program.Icon
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Episode
|
||||||
|
{
|
||||||
|
MediaVersions =
|
||||||
|
[
|
||||||
|
new MediaVersion
|
||||||
|
{
|
||||||
|
Duration = TimeSpan.FromMilliseconds(program.Duration)
|
||||||
|
}
|
||||||
|
],
|
||||||
|
EpisodeMetadata =
|
||||||
|
[
|
||||||
|
new EpisodeMetadata
|
||||||
|
{
|
||||||
|
EpisodeNumber = program.Episode,
|
||||||
|
Title = program.Title
|
||||||
|
},
|
||||||
|
],
|
||||||
|
Season = new Season
|
||||||
|
{
|
||||||
|
SeasonNumber = program.Season,
|
||||||
|
Show = new Show
|
||||||
|
{
|
||||||
|
ShowMetadata =
|
||||||
|
[
|
||||||
|
new ShowMetadata
|
||||||
|
{
|
||||||
|
Title = program.ShowTitle,
|
||||||
|
Artwork = artwork
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Movie BuildMovie(ExternalJsonProgram program)
|
||||||
|
{
|
||||||
|
var artwork = new List<Artwork>();
|
||||||
|
if (!string.IsNullOrWhiteSpace(program.Icon))
|
||||||
|
{
|
||||||
|
artwork.Add(new Artwork
|
||||||
|
{
|
||||||
|
ArtworkKind = ArtworkKind.Poster,
|
||||||
|
Path = program.Icon,
|
||||||
|
SourcePath = program.Icon
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Movie
|
||||||
|
{
|
||||||
|
MediaVersions =
|
||||||
|
[
|
||||||
|
new MediaVersion
|
||||||
|
{
|
||||||
|
Duration = TimeSpan.FromMilliseconds(program.Duration)
|
||||||
|
}
|
||||||
|
],
|
||||||
|
MovieMetadata =
|
||||||
|
[
|
||||||
|
new MovieMetadata
|
||||||
|
{
|
||||||
|
Title = program.Title,
|
||||||
|
Year = program.Year,
|
||||||
|
Artwork = artwork
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private sealed record ContentRating(Option<string> System, string Value);
|
private sealed record ContentRating(Option<string> System, string Value);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
|
|||||||
foreach (string channelNumber in maybeChannelNumber)
|
foreach (string channelNumber in maybeChannelNumber)
|
||||||
{
|
{
|
||||||
string fileName = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, $"{channelNumber}.xml");
|
string fileName = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, $"{channelNumber}.xml");
|
||||||
if (hasChanges || !File.Exists(fileName))
|
if (hasChanges || !File.Exists(fileName) || playout.ProgramSchedulePlayoutType is ProgramSchedulePlayoutType.ExternalJson)
|
||||||
{
|
{
|
||||||
await _workerChannel.WriteAsync(new RefreshChannelData(channelNumber), cancellationToken);
|
await _workerChannel.WriteAsync(new RefreshChannelData(channelNumber), cancellationToken);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
using ErsatzTV.Core;
|
using System.Threading.Channels;
|
||||||
|
using ErsatzTV.Application.Channels;
|
||||||
|
using ErsatzTV.Core;
|
||||||
using ErsatzTV.Core.Domain;
|
using ErsatzTV.Core.Domain;
|
||||||
using ErsatzTV.Infrastructure.Data;
|
using ErsatzTV.Infrastructure.Data;
|
||||||
using ErsatzTV.Infrastructure.Extensions;
|
using ErsatzTV.Infrastructure.Extensions;
|
||||||
@@ -9,9 +11,15 @@ namespace ErsatzTV.Application.Playouts;
|
|||||||
public class UpdateExternalJsonPlayoutHandler : IRequestHandler<UpdateExternalJsonPlayout, Either<BaseError, PlayoutNameViewModel>>
|
public class UpdateExternalJsonPlayoutHandler : IRequestHandler<UpdateExternalJsonPlayout, Either<BaseError, PlayoutNameViewModel>>
|
||||||
{
|
{
|
||||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||||
|
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
|
||||||
|
|
||||||
public UpdateExternalJsonPlayoutHandler(IDbContextFactory<TvContext> dbContextFactory) =>
|
public UpdateExternalJsonPlayoutHandler(
|
||||||
|
IDbContextFactory<TvContext> dbContextFactory,
|
||||||
|
ChannelWriter<IBackgroundServiceRequest> workerChannel)
|
||||||
|
{
|
||||||
_dbContextFactory = dbContextFactory;
|
_dbContextFactory = dbContextFactory;
|
||||||
|
_workerChannel = workerChannel;
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<Either<BaseError, PlayoutNameViewModel>> Handle(
|
public async Task<Either<BaseError, PlayoutNameViewModel>> Handle(
|
||||||
UpdateExternalJsonPlayout request,
|
UpdateExternalJsonPlayout request,
|
||||||
@@ -22,14 +30,17 @@ public class UpdateExternalJsonPlayoutHandler : IRequestHandler<UpdateExternalJs
|
|||||||
return await validation.Apply(playout => ApplyUpdateRequest(dbContext, request, playout));
|
return await validation.Apply(playout => ApplyUpdateRequest(dbContext, request, playout));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<PlayoutNameViewModel> ApplyUpdateRequest(
|
private async Task<PlayoutNameViewModel> ApplyUpdateRequest(
|
||||||
TvContext dbContext,
|
TvContext dbContext,
|
||||||
UpdateExternalJsonPlayout request,
|
UpdateExternalJsonPlayout request,
|
||||||
Playout playout)
|
Playout playout)
|
||||||
{
|
{
|
||||||
playout.ExternalJsonFile = request.ExternalJsonFile;
|
playout.ExternalJsonFile = request.ExternalJsonFile;
|
||||||
|
|
||||||
await dbContext.SaveChangesAsync();
|
if (await dbContext.SaveChangesAsync() > 0)
|
||||||
|
{
|
||||||
|
await _workerChannel.WriteAsync(new RefreshChannelData(playout.Channel.Number));
|
||||||
|
}
|
||||||
|
|
||||||
return new PlayoutNameViewModel(
|
return new PlayoutNameViewModel(
|
||||||
playout.Id,
|
playout.Id,
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ public class ExternalJsonChannel
|
|||||||
{
|
{
|
||||||
[JsonProperty("startTime")]
|
[JsonProperty("startTime")]
|
||||||
public string StartTime { get; set; }
|
public string StartTime { get; set; }
|
||||||
|
|
||||||
|
[JsonProperty("guideMinimumDurationSeconds")]
|
||||||
|
public int GuideMinimumDurationSeconds { get; set; }
|
||||||
|
|
||||||
|
[JsonProperty("programs")]
|
||||||
public ExternalJsonProgram[] Programs { get; set; }
|
public ExternalJsonProgram[] Programs { get; set; }
|
||||||
}
|
}
|
||||||
@@ -22,6 +22,12 @@ public class ExternalJsonProgram
|
|||||||
[JsonProperty("ratingKey")]
|
[JsonProperty("ratingKey")]
|
||||||
public string RatingKey { get; set; }
|
public string RatingKey { get; set; }
|
||||||
|
|
||||||
|
[JsonProperty("icon")]
|
||||||
|
public string Icon { get; set; }
|
||||||
|
|
||||||
|
[JsonProperty("year")]
|
||||||
|
public int? Year { get; set; }
|
||||||
|
|
||||||
[JsonProperty("type")]
|
[JsonProperty("type")]
|
||||||
public string Type { get; set; }
|
public string Type { get; set; }
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user