Browse Source

generate xmltv for external json playouts (#1541)

pull/1542/head
Jason Dove 2 years ago committed by GitHub
parent
commit
64502315a3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 1
      CHANGELOG.md
  2. 168
      ErsatzTV.Application/Channels/Commands/RefreshChannelDataHandler.cs
  3. 2
      ErsatzTV.Application/Playouts/Commands/BuildPlayoutHandler.cs
  4. 21
      ErsatzTV.Application/Playouts/Commands/UpdateExternalJsonPlayoutHandler.cs
  5. 4
      ErsatzTV.Core/Streaming/ExternalJsonChannel.cs
  6. 6
      ErsatzTV.Core/Streaming/ExternalJsonProgram.cs

1
CHANGELOG.md

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

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

@ -6,10 +6,12 @@ using ErsatzTV.Core.Domain.Filler; @@ -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> @@ -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> @@ -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> @@ -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> @@ -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);
}

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

@ -81,7 +81,7 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro @@ -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);
}

21
ErsatzTV.Application/Playouts/Commands/UpdateExternalJsonPlayoutHandler.cs

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

4
ErsatzTV.Core/Streaming/ExternalJsonChannel.cs

@ -6,6 +6,10 @@ public class ExternalJsonChannel @@ -6,6 +6,10 @@ public class ExternalJsonChannel
{
[JsonProperty("startTime")]
public string StartTime { get; set; }
[JsonProperty("guideMinimumDurationSeconds")]
public int GuideMinimumDurationSeconds { get; set; }
[JsonProperty("programs")]
public ExternalJsonProgram[] Programs { get; set; }
}

6
ErsatzTV.Core/Streaming/ExternalJsonProgram.cs

@ -22,6 +22,12 @@ public class ExternalJsonProgram @@ -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; }

Loading…
Cancel
Save