diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e7ebb077..42b3959dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/ErsatzTV.Application/Channels/Commands/RefreshChannelDataHandler.cs b/ErsatzTV.Application/Channels/Commands/RefreshChannelDataHandler.cs index a14548c4c..fcb5c95b1 100644 --- a/ErsatzTV.Application/Channels/Commands/RefreshChannelDataHandler.cs +++ b/ErsatzTV.Application/Channels/Commands/RefreshChannelDataHandler.cs @@ -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 await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - List sorted = await dbContext.Playouts + List playouts = await dbContext.Playouts .AsNoTracking() .Filter(pi => pi.Channel.Number == request.ChannelNumber) .Include(p => p.Items) @@ -85,8 +87,22 @@ public class RefreshChannelDataHandler : IRequestHandler .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 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 _ => 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 return maybeArtwork.IfNone(string.Empty); } + private async Task> CollectExternalJsonItems(string path) + { + var result = new List(); + + if (_localFileSystem.FileExists(path)) + { + Option maybeChannel = JsonConvert.DeserializeObject( + 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(); + 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(); + 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 System, string Value); } diff --git a/ErsatzTV.Application/Playouts/Commands/BuildPlayoutHandler.cs b/ErsatzTV.Application/Playouts/Commands/BuildPlayoutHandler.cs index a36eab917..554545adb 100644 --- a/ErsatzTV.Application/Playouts/Commands/BuildPlayoutHandler.cs +++ b/ErsatzTV.Application/Playouts/Commands/BuildPlayoutHandler.cs @@ -81,7 +81,7 @@ public class BuildPlayoutHandler : IRequestHandler> { private readonly IDbContextFactory _dbContextFactory; + private readonly ChannelWriter _workerChannel; - public UpdateExternalJsonPlayoutHandler(IDbContextFactory dbContextFactory) => + public UpdateExternalJsonPlayoutHandler( + IDbContextFactory dbContextFactory, + ChannelWriter workerChannel) + { _dbContextFactory = dbContextFactory; + _workerChannel = workerChannel; + } public async Task> Handle( UpdateExternalJsonPlayout request, @@ -22,14 +30,17 @@ public class UpdateExternalJsonPlayoutHandler : IRequestHandler ApplyUpdateRequest(dbContext, request, playout)); } - private static async Task ApplyUpdateRequest( + private async Task 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, diff --git a/ErsatzTV.Core/Streaming/ExternalJsonChannel.cs b/ErsatzTV.Core/Streaming/ExternalJsonChannel.cs index 11695c6e1..c54f24d26 100644 --- a/ErsatzTV.Core/Streaming/ExternalJsonChannel.cs +++ b/ErsatzTV.Core/Streaming/ExternalJsonChannel.cs @@ -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; } } \ No newline at end of file diff --git a/ErsatzTV.Core/Streaming/ExternalJsonProgram.cs b/ErsatzTV.Core/Streaming/ExternalJsonProgram.cs index 164e859e5..98ed263ba 100644 --- a/ErsatzTV.Core/Streaming/ExternalJsonProgram.cs +++ b/ErsatzTV.Core/Streaming/ExternalJsonProgram.cs @@ -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; }