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
|
||||
- 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
|
||||
- **Note: XMLTV GUIDE DATA IS NOT YET SUPPORTED FOR THIS PLAYOUT TYPE**
|
||||
|
||||
### Fixed
|
||||
- Fix error loading path replacements when using MySql
|
||||
|
||||
@@ -6,10 +6,12 @@ using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.Emby;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Jellyfin;
|
||||
using ErsatzTV.Core.Streaming;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.IO;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
@@ -38,7 +40,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
List<PlayoutItem> sorted = await dbContext.Playouts
|
||||
List<Playout> playouts = await dbContext.Playouts
|
||||
.AsNoTracking()
|
||||
.Filter(pi => pi.Channel.Number == request.ChannelNumber)
|
||||
.Include(p => p.Items)
|
||||
@@ -85,8 +87,22 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
.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());
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
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 var xml = XmlWriter.Create(
|
||||
@@ -374,6 +390,10 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
_ => 440
|
||||
};
|
||||
|
||||
if (artworkPath.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || artworkPath.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return artworkPath;
|
||||
}
|
||||
if (artworkPath.StartsWith("jellyfin://", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
artworkPath = JellyfinUrl.PlaceholderProxyForArtwork(artworkPath, artworkKind, height);
|
||||
@@ -521,5 +541,147 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
|
||||
foreach (string channelNumber in maybeChannelNumber)
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using ErsatzTV.Core;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
@@ -9,9 +11,15 @@ namespace ErsatzTV.Application.Playouts;
|
||||
public class UpdateExternalJsonPlayoutHandler : IRequestHandler<UpdateExternalJsonPlayout, Either<BaseError, PlayoutNameViewModel>>
|
||||
{
|
||||
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;
|
||||
_workerChannel = workerChannel;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, PlayoutNameViewModel>> Handle(
|
||||
UpdateExternalJsonPlayout request,
|
||||
@@ -22,14 +30,17 @@ public class UpdateExternalJsonPlayoutHandler : IRequestHandler<UpdateExternalJs
|
||||
return await validation.Apply(playout => ApplyUpdateRequest(dbContext, request, playout));
|
||||
}
|
||||
|
||||
private static async Task<PlayoutNameViewModel> ApplyUpdateRequest(
|
||||
private async Task<PlayoutNameViewModel> ApplyUpdateRequest(
|
||||
TvContext dbContext,
|
||||
UpdateExternalJsonPlayout request,
|
||||
Playout playout)
|
||||
{
|
||||
playout.ExternalJsonFile = request.ExternalJsonFile;
|
||||
|
||||
await dbContext.SaveChangesAsync();
|
||||
if (await dbContext.SaveChangesAsync() > 0)
|
||||
{
|
||||
await _workerChannel.WriteAsync(new RefreshChannelData(playout.Channel.Number));
|
||||
}
|
||||
|
||||
return new PlayoutNameViewModel(
|
||||
playout.Id,
|
||||
|
||||
@@ -7,5 +7,9 @@ public class ExternalJsonChannel
|
||||
[JsonProperty("startTime")]
|
||||
public string StartTime { get; set; }
|
||||
|
||||
[JsonProperty("guideMinimumDurationSeconds")]
|
||||
public int GuideMinimumDurationSeconds { get; set; }
|
||||
|
||||
[JsonProperty("programs")]
|
||||
public ExternalJsonProgram[] Programs { get; set; }
|
||||
}
|
||||
@@ -22,6 +22,12 @@ public class ExternalJsonProgram
|
||||
[JsonProperty("ratingKey")]
|
||||
public string RatingKey { get; set; }
|
||||
|
||||
[JsonProperty("icon")]
|
||||
public string Icon { get; set; }
|
||||
|
||||
[JsonProperty("year")]
|
||||
public int? Year { get; set; }
|
||||
|
||||
[JsonProperty("type")]
|
||||
public string Type { get; set; }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user