Browse Source

add external json playout type for dizquetv interop (#1539)

* add external json playout

* basic local playback works

* fallback to streaming from plex

* update external json file

* update changelog
pull/1540/head
Jason Dove 2 years ago committed by GitHub
parent
commit
0330b9326d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 8
      CHANGELOG.md
  2. 2
      ErsatzTV.Application/Playouts/Commands/BuildPlayoutHandler.cs
  3. 91
      ErsatzTV.Application/Playouts/Commands/CreateExternalJsonPlayoutHandler.cs
  4. 22
      ErsatzTV.Application/Playouts/Commands/CreateFloodPlayoutHandler.cs
  5. 12
      ErsatzTV.Application/Playouts/Commands/CreatePlayout.cs
  6. 6
      ErsatzTV.Application/Playouts/Commands/UpdateExternalJsonPlayout.cs
  7. 54
      ErsatzTV.Application/Playouts/Commands/UpdateExternalJsonPlayoutHandler.cs
  8. 6
      ErsatzTV.Application/Playouts/Commands/UpdatePlayoutHandler.cs
  9. 6
      ErsatzTV.Application/Playouts/PlayoutNameViewModel.cs
  10. 8
      ErsatzTV.Application/Playouts/Queries/GetAllPlayoutsHandler.cs
  11. 17
      ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs
  12. 3
      ErsatzTV.Core/Domain/Playout.cs
  13. 3
      ErsatzTV.Core/Domain/PlayoutItemWithPath.cs
  14. 5
      ErsatzTV.Core/Domain/ProgramSchedulePlayoutType.cs
  15. 4
      ErsatzTV.Core/Interfaces/Plex/IPlexServerApiClient.cs
  16. 12
      ErsatzTV.Core/Interfaces/Streaming/IExternalJsonPlayoutItemProvider.cs
  17. 10
      ErsatzTV.Core/Scheduling/PlayoutBuilder.cs
  18. 11
      ErsatzTV.Core/Streaming/ExternalJsonChannel.cs
  19. 39
      ErsatzTV.Core/Streaming/ExternalJsonProgram.cs
  20. 4520
      ErsatzTV.Infrastructure.Sqlite/Migrations/20240108202019_AddExternalJsonPlayout.Designer.cs
  21. 46
      ErsatzTV.Infrastructure.Sqlite/Migrations/20240108202019_AddExternalJsonPlayout.cs
  22. 11
      ErsatzTV.Infrastructure.Sqlite/Migrations/TvContextModelSnapshot.cs
  23. 3
      ErsatzTV.Infrastructure/Data/Configurations/ProgramScheduleConfiguration.cs
  24. 8
      ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs
  25. 302
      ErsatzTV.Scanner/Application/Streaming/ExternalJsonPlayoutItemProvider.cs
  26. 8
      ErsatzTV.Scanner/Core/Interfaces/Metadata/ILocalStatisticsProvider.cs
  27. 2
      ErsatzTV.Scanner/Core/Metadata/LocalFolderScanner.cs
  28. 70
      ErsatzTV.Scanner/Core/Metadata/LocalStatisticsProvider.cs
  29. 36
      ErsatzTV.Scanner/Core/Metadata/OtherVideoFolderScanner.cs
  30. 4
      ErsatzTV.Scanner/Core/Plex/PlexMovieLibraryScanner.cs
  31. 6
      ErsatzTV.Scanner/Core/Plex/PlexTelevisionLibraryScanner.cs
  32. 2
      ErsatzTV.Scanner/Program.cs
  33. 55
      ErsatzTV/Pages/PlayoutEditor.razor
  34. 90
      ErsatzTV/Pages/Playouts.razor
  35. 45
      ErsatzTV/Shared/EditExternalJsonFileDialog.razor
  36. 6
      ErsatzTV/Startup.cs
  37. 3
      ErsatzTV/Validators/PlayoutEditViewModelValidator.cs
  38. 8
      ErsatzTV/ViewModels/PlayoutEditViewModel.cs

8
CHANGELOG.md

@ -13,6 +13,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). @@ -13,6 +13,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- `None`: no change to scheduling behavior - all groups (shows and artists) will be shuffled/ordered together
- `Ordered Groups`: each time this item is scheduled, the entire `Duration` or `Multiple` will be filled with a single group, and the groups will rotate in a fixed order
- `Shuffled Groups`: each time this item is scheduled, the entire `Duration` or `Multiple` will be filled with a single group, and the groups will rotate in a shuffled order
- Add new playout type `External Json`
- Use this playout type when you want to manage the channel schedule using DizqueTV
- You must point ErsatzTV to the channel number json file from DizqueTV, e.g. `channels/1.json`
- For playback, ErsatzTV will first check for the appropriate media file file locally
- 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

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

@ -115,7 +115,7 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro @@ -115,7 +115,7 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
private static Validation<BaseError, Playout> DiscardAttemptsMustBeValid(Playout playout)
{
foreach (ProgramScheduleItemDuration item in
playout.ProgramSchedule.Items.OfType<ProgramScheduleItemDuration>())
playout.ProgramSchedule?.Items.OfType<ProgramScheduleItemDuration>() ?? [])
{
item.DiscardToFillAttempts = item.PlaybackOrder switch
{

91
ErsatzTV.Application/Playouts/Commands/CreateExternalJsonPlayoutHandler.cs

@ -0,0 +1,91 @@ @@ -0,0 +1,91 @@
using System.Threading.Channels;
using ErsatzTV.Application.Channels;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
using Channel = ErsatzTV.Core.Domain.Channel;
namespace ErsatzTV.Application.Playouts;
public class CreateExternalJsonPlayoutHandler
: IRequestHandler<CreateExternalJsonPlayout, Either<BaseError, CreatePlayoutResponse>>
{
private readonly ILocalFileSystem _localFileSystem;
private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public CreateExternalJsonPlayoutHandler(
ILocalFileSystem localFileSystem,
ChannelWriter<IBackgroundServiceRequest> channel,
IDbContextFactory<TvContext> dbContextFactory)
{
_localFileSystem = localFileSystem;
_channel = channel;
_dbContextFactory = dbContextFactory;
}
public async Task<Either<BaseError, CreatePlayoutResponse>> Handle(
CreateExternalJsonPlayout request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Playout> validation = await Validate(dbContext, request);
return await validation.Apply(playout => PersistPlayout(dbContext, playout));
}
private async Task<CreatePlayoutResponse> PersistPlayout(TvContext dbContext, Playout playout)
{
await dbContext.Playouts.AddAsync(playout);
await dbContext.SaveChangesAsync();
await _channel.WriteAsync(new BuildPlayout(playout.Id, PlayoutBuildMode.Reset));
await _channel.WriteAsync(new RefreshChannelList());
return new CreatePlayoutResponse(playout.Id);
}
private async Task<Validation<BaseError, Playout>> Validate(
TvContext dbContext,
CreateExternalJsonPlayout request) =>
(await ValidateChannel(dbContext, request), ValidateExternalJsonFile(request), ValidatePlayoutType(request))
.Apply(
(channel, externalJsonFile, playoutType) => new Playout
{
ChannelId = channel.Id,
ExternalJsonFile = externalJsonFile,
ProgramSchedulePlayoutType = playoutType
});
private static Task<Validation<BaseError, Channel>> ValidateChannel(
TvContext dbContext,
CreateExternalJsonPlayout createExternalJsonPlayout) =>
dbContext.Channels
.Include(c => c.Playouts)
.SelectOneAsync(c => c.Id, c => c.Id == createExternalJsonPlayout.ChannelId)
.Map(o => o.ToValidation<BaseError>("Channel does not exist"))
.BindT(ChannelMustNotHavePlayouts);
private static Validation<BaseError, Channel> ChannelMustNotHavePlayouts(Channel channel) =>
Optional(channel.Playouts.Count)
.Filter(count => count == 0)
.Map(_ => channel)
.ToValidation<BaseError>("Channel already has one playout");
private Validation<BaseError, string> ValidateExternalJsonFile(CreateExternalJsonPlayout request)
{
if (!_localFileSystem.FileExists(request.ExternalJsonFile))
{
return BaseError.New("External Json File does not exist!");
}
return request.ExternalJsonFile;
}
private static Validation<BaseError, ProgramSchedulePlayoutType> ValidatePlayoutType(
CreateExternalJsonPlayout createExternalJsonPlayout) =>
Optional(createExternalJsonPlayout.ProgramSchedulePlayoutType)
.Filter(playoutType => playoutType == ProgramSchedulePlayoutType.ExternalJson)
.ToValidation<BaseError>("[ProgramSchedulePlayoutType] must be ExternalJson");
}

22
ErsatzTV.Application/Playouts/Commands/CreatePlayoutHandler.cs → ErsatzTV.Application/Playouts/Commands/CreateFloodPlayoutHandler.cs

@ -10,12 +10,12 @@ using Channel = ErsatzTV.Core.Domain.Channel; @@ -10,12 +10,12 @@ using Channel = ErsatzTV.Core.Domain.Channel;
namespace ErsatzTV.Application.Playouts;
public class CreatePlayoutHandler : IRequestHandler<CreatePlayout, Either<BaseError, CreatePlayoutResponse>>
public class CreateFloodPlayoutHandler : IRequestHandler<CreateFloodPlayout, Either<BaseError, CreatePlayoutResponse>>
{
private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public CreatePlayoutHandler(
public CreateFloodPlayoutHandler(
ChannelWriter<IBackgroundServiceRequest> channel,
IDbContextFactory<TvContext> dbContextFactory)
{
@ -24,12 +24,12 @@ public class CreatePlayoutHandler : IRequestHandler<CreatePlayout, Either<BaseEr @@ -24,12 +24,12 @@ public class CreatePlayoutHandler : IRequestHandler<CreatePlayout, Either<BaseEr
}
public async Task<Either<BaseError, CreatePlayoutResponse>> Handle(
CreatePlayout request,
CreateFloodPlayout request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
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)
@ -41,7 +41,7 @@ public class CreatePlayoutHandler : IRequestHandler<CreatePlayout, Either<BaseEr @@ -41,7 +41,7 @@ public class CreatePlayoutHandler : IRequestHandler<CreatePlayout, Either<BaseEr
return new CreatePlayoutResponse(playout.Id);
}
private static async Task<Validation<BaseError, Playout>> Validate(TvContext dbContext, CreatePlayout request) =>
private static async Task<Validation<BaseError, Playout>> Validate(TvContext dbContext, CreateFloodPlayout request) =>
(await ValidateChannel(dbContext, request), await ValidateProgramSchedule(dbContext, request),
ValidatePlayoutType(request))
.Apply(
@ -54,10 +54,10 @@ public class CreatePlayoutHandler : IRequestHandler<CreatePlayout, Either<BaseEr @@ -54,10 +54,10 @@ public class CreatePlayoutHandler : IRequestHandler<CreatePlayout, Either<BaseEr
private static Task<Validation<BaseError, Channel>> ValidateChannel(
TvContext dbContext,
CreatePlayout createPlayout) =>
CreateFloodPlayout createFloodPlayout) =>
dbContext.Channels
.Include(c => c.Playouts)
.SelectOneAsync(c => c.Id, c => c.Id == createPlayout.ChannelId)
.SelectOneAsync(c => c.Id, c => c.Id == createFloodPlayout.ChannelId)
.Map(o => o.ToValidation<BaseError>("Channel does not exist"))
.BindT(ChannelMustNotHavePlayouts);
@ -69,10 +69,10 @@ public class CreatePlayoutHandler : IRequestHandler<CreatePlayout, Either<BaseEr @@ -69,10 +69,10 @@ public class CreatePlayoutHandler : IRequestHandler<CreatePlayout, Either<BaseEr
private static Task<Validation<BaseError, ProgramSchedule>> ValidateProgramSchedule(
TvContext dbContext,
CreatePlayout createPlayout) =>
CreateFloodPlayout createFloodPlayout) =>
dbContext.ProgramSchedules
.Include(ps => ps.Items)
.SelectOneAsync(ps => ps.Id, ps => ps.Id == createPlayout.ProgramScheduleId)
.SelectOneAsync(ps => ps.Id, ps => ps.Id == createFloodPlayout.ProgramScheduleId)
.Map(o => o.ToValidation<BaseError>("Program schedule does not exist"))
.BindT(ProgramScheduleMustHaveItems);
@ -83,8 +83,8 @@ public class CreatePlayoutHandler : IRequestHandler<CreatePlayout, Either<BaseEr @@ -83,8 +83,8 @@ public class CreatePlayoutHandler : IRequestHandler<CreatePlayout, Either<BaseEr
.ToValidation<BaseError>("Program schedule must have items");
private static Validation<BaseError, ProgramSchedulePlayoutType> ValidatePlayoutType(
CreatePlayout createPlayout) =>
Optional(createPlayout.ProgramSchedulePlayoutType)
CreateFloodPlayout createFloodPlayout) =>
Optional(createFloodPlayout.ProgramSchedulePlayoutType)
.Filter(playoutType => playoutType != ProgramSchedulePlayoutType.None)
.ToValidation<BaseError>("[ProgramSchedulePlayoutType] must not be None");
}

12
ErsatzTV.Application/Playouts/Commands/CreatePlayout.cs

@ -3,7 +3,11 @@ using ErsatzTV.Core.Domain; @@ -3,7 +3,11 @@ using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Playouts;
public record CreatePlayout(
int ChannelId,
int ProgramScheduleId,
ProgramSchedulePlayoutType ProgramSchedulePlayoutType) : IRequest<Either<BaseError, CreatePlayoutResponse>>;
public record CreatePlayout(int ChannelId, ProgramSchedulePlayoutType ProgramSchedulePlayoutType)
: IRequest<Either<BaseError, CreatePlayoutResponse>>;
public record CreateFloodPlayout(int ChannelId, int ProgramScheduleId)
: CreatePlayout(ChannelId, ProgramSchedulePlayoutType.Flood);
public record CreateExternalJsonPlayout(int ChannelId, string ExternalJsonFile)
: CreatePlayout(ChannelId, ProgramSchedulePlayoutType.ExternalJson);

6
ErsatzTV.Application/Playouts/Commands/UpdateExternalJsonPlayout.cs

@ -0,0 +1,6 @@ @@ -0,0 +1,6 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.Playouts;
public record UpdateExternalJsonPlayout(int PlayoutId, string ExternalJsonFile)
: IRequest<Either<BaseError, PlayoutNameViewModel>>;

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

@ -0,0 +1,54 @@ @@ -0,0 +1,54 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Playouts;
public class UpdateExternalJsonPlayoutHandler : IRequestHandler<UpdateExternalJsonPlayout, Either<BaseError, PlayoutNameViewModel>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public UpdateExternalJsonPlayoutHandler(IDbContextFactory<TvContext> dbContextFactory) =>
_dbContextFactory = dbContextFactory;
public async Task<Either<BaseError, PlayoutNameViewModel>> Handle(
UpdateExternalJsonPlayout request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Playout> validation = await Validate(dbContext, request);
return await validation.Apply(playout => ApplyUpdateRequest(dbContext, request, playout));
}
private static async Task<PlayoutNameViewModel> ApplyUpdateRequest(
TvContext dbContext,
UpdateExternalJsonPlayout request,
Playout playout)
{
playout.ExternalJsonFile = request.ExternalJsonFile;
await dbContext.SaveChangesAsync();
return new PlayoutNameViewModel(
playout.Id,
playout.ProgramSchedulePlayoutType,
playout.Channel.Name,
playout.Channel.Number,
playout.ProgramSchedule?.Name ?? string.Empty,
playout.ExternalJsonFile,
Optional(playout.DailyRebuildTime));
}
private static Task<Validation<BaseError, Playout>> Validate(TvContext dbContext, UpdateExternalJsonPlayout request) =>
PlayoutMustExist(dbContext, request);
private static Task<Validation<BaseError, Playout>> PlayoutMustExist(
TvContext dbContext,
UpdateExternalJsonPlayout updatePlayout) =>
dbContext.Playouts
.Include(p => p.Channel)
.SelectOneAsync(p => p.Id, p => p.Id == updatePlayout.PlayoutId)
.Map(o => o.ToValidation<BaseError>("Playout does not exist."));
}

6
ErsatzTV.Application/Playouts/Commands/UpdatePlayoutHandler.cs

@ -17,7 +17,7 @@ public class UpdatePlayoutHandler : IRequestHandler<UpdatePlayout, Either<BaseEr @@ -17,7 +17,7 @@ public class UpdatePlayoutHandler : IRequestHandler<UpdatePlayout, Either<BaseEr
UpdatePlayout request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Playout> validation = await Validate(dbContext, request);
return await validation.Apply(playout => ApplyUpdateRequest(dbContext, request, playout));
}
@ -38,9 +38,11 @@ public class UpdatePlayoutHandler : IRequestHandler<UpdatePlayout, Either<BaseEr @@ -38,9 +38,11 @@ public class UpdatePlayoutHandler : IRequestHandler<UpdatePlayout, Either<BaseEr
return new PlayoutNameViewModel(
playout.Id,
playout.ProgramSchedulePlayoutType,
playout.Channel.Name,
playout.Channel.Number,
playout.ProgramSchedule.Name,
playout.ProgramSchedule?.Name ?? string.Empty,
playout.ExternalJsonFile,
Optional(playout.DailyRebuildTime));
}

6
ErsatzTV.Application/Playouts/PlayoutNameViewModel.cs

@ -1,8 +1,12 @@ @@ -1,8 +1,12 @@
namespace ErsatzTV.Application.Playouts;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Playouts;
public record PlayoutNameViewModel(
int PlayoutId,
ProgramSchedulePlayoutType PlayoutType,
string ChannelName,
string ChannelNumber,
string ScheduleName,
string ExternalJsonFile,
Option<TimeSpan> DailyRebuildTime);

8
ErsatzTV.Application/Playouts/Queries/GetAllPlayoutsHandler.cs

@ -16,13 +16,17 @@ public class GetAllPlayoutsHandler : IRequestHandler<GetAllPlayouts, List<Playou @@ -16,13 +16,17 @@ public class GetAllPlayoutsHandler : IRequestHandler<GetAllPlayouts, List<Playou
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
return await dbContext.Playouts
.Filter(p => p.Channel != null && p.ProgramSchedule != null)
.AsNoTracking()
.Include(p => p.ProgramSchedule)
.Filter(p => p.Channel != null)
.Map(
p => new PlayoutNameViewModel(
p.Id,
p.ProgramSchedulePlayoutType,
p.Channel.Name,
p.Channel.Number,
p.ProgramSchedule.Name,
p.ProgramScheduleId == null ? string.Empty : p.ProgramSchedule.Name,
p.ExternalJsonFile,
Optional(p.DailyRebuildTime)))
.ToListAsync(cancellationToken);
}

17
ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs

@ -13,6 +13,7 @@ using ErsatzTV.Core.Interfaces.Jellyfin; @@ -13,6 +13,7 @@ using ErsatzTV.Core.Interfaces.Jellyfin;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Plex;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Streaming;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
@ -28,6 +29,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< @@ -28,6 +29,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
private readonly IFFmpegProcessService _ffmpegProcessService;
private readonly IJellyfinPathReplacementService _jellyfinPathReplacementService;
private readonly ILocalFileSystem _localFileSystem;
private readonly IExternalJsonPlayoutItemProvider _externalJsonPlayoutItemProvider;
private readonly ILogger<GetPlayoutItemProcessByChannelNumberHandler> _logger;
private readonly IMediaCollectionRepository _mediaCollectionRepository;
private readonly IMusicVideoCreditsGenerator _musicVideoCreditsGenerator;
@ -39,6 +41,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< @@ -39,6 +41,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
IDbContextFactory<TvContext> dbContextFactory,
IFFmpegProcessService ffmpegProcessService,
ILocalFileSystem localFileSystem,
IExternalJsonPlayoutItemProvider externalJsonPlayoutItemProvider,
IPlexPathReplacementService plexPathReplacementService,
IJellyfinPathReplacementService jellyfinPathReplacementService,
IEmbyPathReplacementService embyPathReplacementService,
@ -52,6 +55,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< @@ -52,6 +55,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
{
_ffmpegProcessService = ffmpegProcessService;
_localFileSystem = localFileSystem;
_externalJsonPlayoutItemProvider = externalJsonPlayoutItemProvider;
_plexPathReplacementService = plexPathReplacementService;
_jellyfinPathReplacementService = jellyfinPathReplacementService;
_embyPathReplacementService = embyPathReplacementService;
@ -139,7 +143,16 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< @@ -139,7 +143,16 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
.ForChannelAndTime(channel.Id, now)
.Map(o => o.ToEither<BaseError>(new UnableToLocatePlayoutItem()))
.BindT(item => ValidatePlayoutItemPath(dbContext, item));
if (maybePlayoutItem.LeftAsEnumerable().Any(e => e is UnableToLocatePlayoutItem))
{
maybePlayoutItem = await _externalJsonPlayoutItemProvider.CheckForExternalJson(
channel,
now,
ffmpegPath,
ffprobePath);
}
if (maybePlayoutItem.LeftAsEnumerable().Any(e => e is UnableToLocatePlayoutItem))
{
maybePlayoutItem = await CheckForFallbackFiller(dbContext, channel, now);
@ -557,6 +570,4 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< @@ -557,6 +570,4 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
_ => path
};
}
private sealed record PlayoutItemWithPath(PlayoutItem PlayoutItem, string Path);
}

3
ErsatzTV.Core/Domain/Playout.cs

@ -5,8 +5,9 @@ public class Playout @@ -5,8 +5,9 @@ public class Playout
public int Id { get; set; }
public int ChannelId { get; set; }
public Channel Channel { get; set; }
public int ProgramScheduleId { get; set; }
public int? ProgramScheduleId { get; set; }
public ProgramSchedule ProgramSchedule { get; set; }
public string ExternalJsonFile { get; set; }
public List<ProgramScheduleAlternate> ProgramScheduleAlternates { get; set; }
public ProgramSchedulePlayoutType ProgramSchedulePlayoutType { get; set; }
public List<PlayoutItem> Items { get; set; }

3
ErsatzTV.Core/Domain/PlayoutItemWithPath.cs

@ -0,0 +1,3 @@ @@ -0,0 +1,3 @@
namespace ErsatzTV.Core.Domain;
public sealed record PlayoutItemWithPath(PlayoutItem PlayoutItem, string Path);

5
ErsatzTV.Core/Domain/ProgramSchedulePlayoutType.cs

@ -3,6 +3,7 @@ @@ -3,6 +3,7 @@
public enum ProgramSchedulePlayoutType
{
None = 0,
Flood,
Daily
Flood = 1,
ExternalJson = 20
}

4
ErsatzTV.Core/Interfaces/Plex/IPlexServerApiClient.cs

@ -52,13 +52,13 @@ public interface IPlexServerApiClient @@ -52,13 +52,13 @@ public interface IPlexServerApiClient
PlexServerAuthToken token);
Task<Either<BaseError, Tuple<MovieMetadata, MediaVersion>>> GetMovieMetadataAndStatistics(
PlexLibrary library,
int plexMediaSourceId,
string key,
PlexConnection connection,
PlexServerAuthToken token);
Task<Either<BaseError, Tuple<EpisodeMetadata, MediaVersion>>> GetEpisodeMetadataAndStatistics(
PlexLibrary library,
int plexMediaSourceId,
string key,
PlexConnection connection,
PlexServerAuthToken token);

12
ErsatzTV.Core/Interfaces/Streaming/IExternalJsonPlayoutItemProvider.cs

@ -0,0 +1,12 @@ @@ -0,0 +1,12 @@
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Interfaces.Streaming;
public interface IExternalJsonPlayoutItemProvider
{
Task<Either<BaseError, PlayoutItemWithPath>> CheckForExternalJson(
Channel channel,
DateTimeOffset now,
string ffmpegPath,
string ffprobePath);
}

10
ErsatzTV.Core/Scheduling/PlayoutBuilder.cs

@ -43,6 +43,16 @@ public class PlayoutBuilder : IPlayoutBuilder @@ -43,6 +43,16 @@ public class PlayoutBuilder : IPlayoutBuilder
public async Task<Playout> Build(Playout playout, PlayoutBuildMode mode, CancellationToken cancellationToken)
{
if (playout.ProgramSchedulePlayoutType is ProgramSchedulePlayoutType.ExternalJson)
{
_logger.LogDebug(
"Skipping external json playout build on channel {Number} - {Name}",
playout.Channel.Number,
playout.Channel.Name);
return playout;
}
foreach (PlayoutParameters parameters in await Validate(playout))
{
// for testing purposes

11
ErsatzTV.Core/Streaming/ExternalJsonChannel.cs

@ -0,0 +1,11 @@ @@ -0,0 +1,11 @@
using Newtonsoft.Json;
namespace ErsatzTV.Core.Streaming;
public class ExternalJsonChannel
{
[JsonProperty("startTime")]
public string StartTime { get; set; }
public ExternalJsonProgram[] Programs { get; set; }
}

39
ErsatzTV.Core/Streaming/ExternalJsonProgram.cs

@ -0,0 +1,39 @@ @@ -0,0 +1,39 @@
using Newtonsoft.Json;
namespace ErsatzTV.Core.Streaming;
public class ExternalJsonProgram
{
[JsonProperty("title")]
public string Title { get; set; }
[JsonProperty("showTitle")]
public string ShowTitle { get; set; }
[JsonProperty("season")]
public int Season { get; set; }
[JsonProperty("episode")]
public int Episode { get; set; }
[JsonProperty("key")]
public string Key { get; set; }
[JsonProperty("ratingKey")]
public string RatingKey { get; set; }
[JsonProperty("type")]
public string Type { get; set; }
[JsonProperty("duration")]
public int Duration { get; set; }
[JsonProperty("plexFile")]
public string PlexFile { get; set; }
[JsonProperty("file")]
public string File { get; set; }
[JsonProperty("serverKey")]
public string ServerKey { get; set; }
}

4520
ErsatzTV.Infrastructure.Sqlite/Migrations/20240108202019_AddExternalJsonPlayout.Designer.cs generated

File diff suppressed because it is too large Load Diff

46
ErsatzTV.Infrastructure.Sqlite/Migrations/20240108202019_AddExternalJsonPlayout.cs

@ -0,0 +1,46 @@ @@ -0,0 +1,46 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.Sqlite.Migrations
{
/// <inheritdoc />
public partial class AddExternalJsonPlayout : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<int>(
name: "ProgramScheduleId",
table: "Playout",
type: "INTEGER",
nullable: true,
oldClrType: typeof(int),
oldType: "INTEGER");
migrationBuilder.AddColumn<string>(
name: "ExternalJsonFile",
table: "Playout",
type: "TEXT",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ExternalJsonFile",
table: "Playout");
migrationBuilder.AlterColumn<int>(
name: "ProgramScheduleId",
table: "Playout",
type: "INTEGER",
nullable: false,
defaultValue: 0,
oldClrType: typeof(int),
oldType: "INTEGER",
oldNullable: true);
}
}
}

11
ErsatzTV.Infrastructure.Sqlite/Migrations/TvContextModelSnapshot.cs

@ -1411,7 +1411,10 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -1411,7 +1411,10 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.Property<TimeSpan?>("DailyRebuildTime")
.HasColumnType("TEXT");
b.Property<int>("ProgramScheduleId")
b.Property<string>("ExternalJsonFile")
.HasColumnType("TEXT");
b.Property<int?>("ProgramScheduleId")
.HasColumnType("INTEGER");
b.Property<int>("ProgramSchedulePlayoutType")
@ -3321,8 +3324,7 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -3321,8 +3324,7 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.HasOne("ErsatzTV.Core.Domain.ProgramSchedule", "ProgramSchedule")
.WithMany("Playouts")
.HasForeignKey("ProgramScheduleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
.OnDelete(DeleteBehavior.Cascade);
b.OwnsOne("ErsatzTV.Core.Domain.PlayoutAnchor", "Anchor", b1 =>
{
@ -3590,8 +3592,7 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -3590,8 +3592,7 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.HasOne("ErsatzTV.Core.Domain.ProgramSchedule", "ProgramSchedule")
.WithMany("Items")
.HasForeignKey("ProgramScheduleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.SmartCollection", "SmartCollection")
.WithMany()

3
ErsatzTV.Infrastructure/Data/Configurations/ProgramScheduleConfiguration.cs

@ -16,7 +16,8 @@ public class ProgramScheduleConfiguration : IEntityTypeConfiguration<ProgramSche @@ -16,7 +16,8 @@ public class ProgramScheduleConfiguration : IEntityTypeConfiguration<ProgramSche
builder.HasMany(ps => ps.Items)
.WithOne(i => i.ProgramSchedule)
.HasForeignKey(i => i.ProgramScheduleId)
.OnDelete(DeleteBehavior.Cascade);
.OnDelete(DeleteBehavior.Cascade)
.IsRequired(false);
builder.HasMany(ps => ps.Playouts)
.WithOne(p => p.ProgramSchedule)

8
ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs

@ -211,7 +211,7 @@ public class PlexServerApiClient : IPlexServerApiClient @@ -211,7 +211,7 @@ public class PlexServerApiClient : IPlexServerApiClient
}
public async Task<Either<BaseError, Tuple<MovieMetadata, MediaVersion>>> GetMovieMetadataAndStatistics(
PlexLibrary library,
int plexMediaSourceId,
string key,
PlexConnection connection,
PlexServerAuthToken token)
@ -231,7 +231,7 @@ public class PlexServerApiClient : IPlexServerApiClient @@ -231,7 +231,7 @@ public class PlexServerApiClient : IPlexServerApiClient
Option<MediaVersion> maybeVersion = ProjectToMediaVersion(response.Metadata);
return maybeVersion.Match<Either<BaseError, Tuple<MovieMetadata, MediaVersion>>>(
version => Tuple(
ProjectToMovieMetadata(version, response.Metadata, library.MediaSourceId),
ProjectToMovieMetadata(version, response.Metadata, plexMediaSourceId),
version),
() => BaseError.New("Unable to locate metadata"));
},
@ -244,7 +244,7 @@ public class PlexServerApiClient : IPlexServerApiClient @@ -244,7 +244,7 @@ public class PlexServerApiClient : IPlexServerApiClient
}
public async Task<Either<BaseError, Tuple<EpisodeMetadata, MediaVersion>>> GetEpisodeMetadataAndStatistics(
PlexLibrary library,
int plexMediaSourceId,
string key,
PlexConnection connection,
PlexServerAuthToken token)
@ -264,7 +264,7 @@ public class PlexServerApiClient : IPlexServerApiClient @@ -264,7 +264,7 @@ public class PlexServerApiClient : IPlexServerApiClient
Option<MediaVersion> maybeVersion = ProjectToMediaVersion(response.Metadata);
return maybeVersion.Match<Either<BaseError, Tuple<EpisodeMetadata, MediaVersion>>>(
version => Tuple(
ProjectToEpisodeMetadata(version, response.Metadata, library.MediaSourceId),
ProjectToEpisodeMetadata(version, response.Metadata, plexMediaSourceId),
version),
() => BaseError.New("Unable to locate metadata"));
},

302
ErsatzTV.Scanner/Application/Streaming/ExternalJsonPlayoutItemProvider.cs

@ -0,0 +1,302 @@ @@ -0,0 +1,302 @@
using System.Globalization;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Plex;
using ErsatzTV.Core.Interfaces.Streaming;
using ErsatzTV.Core.Plex;
using ErsatzTV.Core.Streaming;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using ErsatzTV.Scanner.Core.Interfaces.Metadata;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
namespace ErsatzTV.Scanner.Application.Streaming;
public class ExternalJsonPlayoutItemProvider : IExternalJsonPlayoutItemProvider
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly ILocalFileSystem _localFileSystem;
private readonly IPlexPathReplacementService _plexPathReplacementService;
private readonly IPlexServerApiClient _plexServerApiClient;
private readonly IPlexSecretStore _plexSecretStore;
private readonly ILocalStatisticsProvider _localStatisticsProvider;
private readonly ILogger<ExternalJsonPlayoutItemProvider> _logger;
public ExternalJsonPlayoutItemProvider(
IDbContextFactory<TvContext> dbContextFactory,
ILocalFileSystem localFileSystem,
IPlexPathReplacementService plexPathReplacementService,
IPlexServerApiClient plexServerApiClient,
IPlexSecretStore plexSecretStore,
ILocalStatisticsProvider localStatisticsProvider,
ILogger<ExternalJsonPlayoutItemProvider> logger)
{
_dbContextFactory = dbContextFactory;
_localFileSystem = localFileSystem;
_plexPathReplacementService = plexPathReplacementService;
_plexServerApiClient = plexServerApiClient;
_plexSecretStore = plexSecretStore;
_localStatisticsProvider = localStatisticsProvider;
_logger = logger;
}
public async Task<Either<BaseError, PlayoutItemWithPath>> CheckForExternalJson(
Channel channel,
DateTimeOffset now,
string ffmpegPath,
string ffprobePath)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
Option<Playout> maybePlayout = await dbContext.Playouts
.AsNoTracking()
.SelectOneAsync(p => p.ChannelId, p => p.ChannelId == channel.Id);
foreach (Playout playout in maybePlayout)
{
// playout must be external json
if (playout.ProgramSchedulePlayoutType == ProgramSchedulePlayoutType.ExternalJson)
{
// json file must exist
if (_localFileSystem.FileExists(playout.ExternalJsonFile))
{
return await GetExternalJsonPlayoutItem(dbContext, playout, now, ffmpegPath, ffprobePath);
}
_logger.LogWarning(
"Unable to locate external json file {File} for channel {Number} - {Name}",
playout.ExternalJsonFile,
channel.Number,
channel.Name);
}
}
return new UnableToLocatePlayoutItem();
}
private async Task<Either<BaseError, PlayoutItemWithPath>> GetExternalJsonPlayoutItem(
TvContext dbContext,
Playout playout,
DateTimeOffset now,
string ffmpegPath,
string ffprobePath)
{
Option<ExternalJsonChannel> maybeChannel = JsonConvert.DeserializeObject<ExternalJsonChannel>(
await File.ReadAllTextAsync(playout.ExternalJsonFile));
// 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();
//_logger.LogDebug("external json start time: {StartTime}", startTime);
foreach (ExternalJsonProgram program in channel.Programs)
{
int milliseconds = program.Duration;
DateTimeOffset nextStart = startTime + TimeSpan.FromMilliseconds(milliseconds);
if (nextStart > now)
{
//_logger.LogDebug("should play program {@Program}", program);
return await BuildPlayoutItem(dbContext, startTime, program, ffmpegPath, ffprobePath);
}
startTime = nextStart;
}
}
return new UnableToLocatePlayoutItem();
}
private async Task<Either<BaseError, PlayoutItemWithPath>> BuildPlayoutItem(
TvContext dbContext,
DateTimeOffset startTime,
ExternalJsonProgram program,
string ffmpegPath,
string ffprobePath)
{
// find any library path from the appropriate plex server
List<LibraryPath> maybeLibraryPath = await dbContext.LibraryPaths
.Filter(lp => ((PlexMediaSource)((PlexLibrary)lp.Library).MediaSource).ServerName == program.ServerKey)
.OrderBy(lp => lp.Id)
.Take(1)
.ToListAsync();
foreach (LibraryPath libraryPath in maybeLibraryPath.HeadOrNone())
{
string localPath = await _plexPathReplacementService.GetReplacementPlexPath(
libraryPath.Id,
program.File);
if (_localFileSystem.FileExists(localPath))
{
return await StreamLocally(startTime, program, ffmpegPath, ffprobePath, localPath);
}
return await StreamRemotely(dbContext, startTime, program);
}
return new UnableToLocatePlayoutItem();
}
private async Task<Either<BaseError, PlayoutItemWithPath>> StreamLocally(
DateTimeOffset startTime,
ExternalJsonProgram program,
string ffmpegPath,
string ffprobePath,
string localPath)
{
// ffprobe on demand
Either<BaseError, MediaVersion> maybeMediaVersion =
await _localStatisticsProvider.GetStatistics(ffmpegPath, ffprobePath, localPath);
foreach (MediaVersion mediaVersion in maybeMediaVersion.RightToSeq())
{
// build playout item
var episode = new Episode
{
MediaVersions = [mediaVersion],
EpisodeMetadata =
[
new EpisodeMetadata
{
EpisodeNumber = program.Episode,
Title = program.Title,
},
],
Season = new Season
{
SeasonNumber = program.Season,
Show = new Show
{
ShowMetadata =
[
new ShowMetadata
{
Title = program.ShowTitle
}
]
}
}
};
return new PlayoutItemWithPath(GetPlayoutItem(startTime, episode, program), localPath);
}
return new UnableToLocatePlayoutItem();
}
private async Task<Either<BaseError, PlayoutItemWithPath>> StreamRemotely(
TvContext dbContext,
DateTimeOffset startTime,
ExternalJsonProgram program)
{
Option<PlexMediaSource> maybeServer = await dbContext.PlexMediaSources
.Include(pms => pms.Connections)
.SelectOneAsync(pms => pms.ServerName, pms => pms.ServerName == program.ServerKey);
foreach (PlexMediaSource server in maybeServer)
{
Option<PlexConnection> maybeConnection = server.Connections.SingleOrDefault(c => c.IsActive);
foreach (PlexConnection connection in maybeConnection)
{
Option<PlexServerAuthToken> maybeToken =
await _plexSecretStore.GetServerAuthToken(server.ClientIdentifier);
foreach (PlexServerAuthToken token in maybeToken)
{
MediaItem mediaItem = program.Type switch
{
"episode" => await GetPlexEpisode(server, connection, token, program),
_ => await GetPlexMovie(server, connection, token, program)
};
return new PlayoutItemWithPath(
GetPlayoutItem(startTime, mediaItem, program),
$"http://localhost:{Settings.ListenPort}/media/plex/{server.Id}/{program.PlexFile}");
}
}
}
// TODO: log errors?
return new UnableToLocatePlayoutItem();
}
private async Task<MediaItem> GetPlexEpisode(
PlexMediaSource plexMediaSource,
PlexConnection connection,
PlexServerAuthToken token,
ExternalJsonProgram program)
{
Either<BaseError, Tuple<EpisodeMetadata, MediaVersion>> maybeStatistics =
await _plexServerApiClient.GetEpisodeMetadataAndStatistics(
plexMediaSource.Id,
program.RatingKey,
connection,
token);
foreach (Tuple<EpisodeMetadata, MediaVersion> result in maybeStatistics.RightToSeq())
{
return new PlexEpisode
{
EpisodeMetadata = [result.Item1],
MediaVersions = [result.Item2]
};
}
throw new NotSupportedException();
}
private async Task<MediaItem> GetPlexMovie(
PlexMediaSource plexMediaSource,
PlexConnection connection,
PlexServerAuthToken token,
ExternalJsonProgram program)
{
Either<BaseError, Tuple<MovieMetadata, MediaVersion>> maybeStatistics =
await _plexServerApiClient.GetMovieMetadataAndStatistics(
plexMediaSource.Id,
program.RatingKey,
connection,
token);
foreach (Tuple<MovieMetadata, MediaVersion> result in maybeStatistics.RightToSeq())
{
return new PlexMovie
{
MovieMetadata = [result.Item1],
MediaVersions = [result.Item2]
};
}
throw new NotSupportedException();
}
private static PlayoutItem GetPlayoutItem(
DateTimeOffset startTime,
MediaItem mediaItem,
ExternalJsonProgram program) =>
new()
{
Start = startTime.UtcDateTime,
Finish = startTime.AddMilliseconds(program.Duration).UtcDateTime,
FillerKind = FillerKind.None,
ChapterTitle = null,
GuideFinish = null,
GuideGroup = 0,
CustomTitle = null,
InPoint = TimeSpan.Zero,
OutPoint = TimeSpan.FromMilliseconds(program.Duration),
MediaItem = mediaItem
};
}

8
ErsatzTV.Scanner/Core/Interfaces/Metadata/ILocalStatisticsProvider.cs

@ -5,13 +5,9 @@ namespace ErsatzTV.Scanner.Core.Interfaces.Metadata; @@ -5,13 +5,9 @@ namespace ErsatzTV.Scanner.Core.Interfaces.Metadata;
public interface ILocalStatisticsProvider
{
Task<Either<BaseError, MediaVersion>> GetStatistics(string ffmpegPath, string ffprobePath, string path);
Task<Either<BaseError, bool>> RefreshStatistics(string ffmpegPath, string ffprobePath, MediaItem mediaItem);
Task<Either<BaseError, bool>> RefreshStatistics(
string ffmpegPath,
string ffprobePath,
MediaItem mediaItem,
string mediaItemPath);
Task<Either<BaseError, Dictionary<string, string>>> GetSongTags(string ffprobePath, MediaItem mediaItem);
}

2
ErsatzTV.Scanner/Core/Metadata/LocalFolderScanner.cs

@ -32,7 +32,7 @@ public abstract class LocalFolderScanner @@ -32,7 +32,7 @@ public abstract class LocalFolderScanner
public static readonly ImmutableHashSet<string> ImageFileExtensions = new[]
{
"jpg", "jpeg", "png", "gif", "tbn"
"jpg", "jpeg", "png", "gif", "tbn", "webp"
}.ToImmutableHashSet(StringComparer.OrdinalIgnoreCase);
public static readonly ImmutableHashSet<string> ExtraFiles = new[]

70
ErsatzTV.Scanner/Core/Metadata/LocalStatisticsProvider.cs

@ -36,46 +36,23 @@ public class LocalStatisticsProvider : ILocalStatisticsProvider @@ -36,46 +36,23 @@ public class LocalStatisticsProvider : ILocalStatisticsProvider
_logger = logger;
}
public async Task<Either<BaseError, bool>> RefreshStatistics(
string ffmpegPath,
string ffprobePath,
MediaItem mediaItem)
public async Task<Either<BaseError, MediaVersion>> GetStatistics(string ffmpegPath, string ffprobePath, string path)
{
try
{
string filePath = mediaItem.GetHeadVersion().MediaFiles.Head().Path;
return await RefreshStatistics(ffmpegPath, ffprobePath, mediaItem, filePath);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to refresh statistics for media item {Id}", mediaItem.Id);
_client.Notify(ex);
return BaseError.New(ex.Message);
}
Either<BaseError, FFprobe> maybeProbe = await GetProbeOutput(ffprobePath, path);
return maybeProbe.Match(
ffprobe => ProjectToMediaVersion(path, ffprobe),
Left<BaseError, MediaVersion>);
}
public async Task<Either<BaseError, bool>> RefreshStatistics(
string ffmpegPath,
string ffprobePath,
MediaItem mediaItem,
string mediaItemPath)
MediaItem mediaItem)
{
try
{
Either<BaseError, FFprobe> maybeProbe = await GetProbeOutput(ffprobePath, mediaItemPath);
return await maybeProbe.Match(
async ffprobe =>
{
MediaVersion version = ProjectToMediaVersion(mediaItemPath, ffprobe);
if (version.Duration.TotalSeconds < 1)
{
await AnalyzeDuration(ffmpegPath, mediaItemPath, version);
}
bool result = await ApplyVersionUpdate(mediaItem, version, mediaItemPath);
return Right<BaseError, bool>(result);
},
error => Task.FromResult(Left<BaseError, bool>(error)));
string filePath = mediaItem.GetHeadVersion().MediaFiles.Head().Path;
return await RefreshStatistics(ffmpegPath, ffprobePath, mediaItem, filePath);
}
catch (Exception ex)
{
@ -201,6 +178,37 @@ public class LocalStatisticsProvider : ILocalStatisticsProvider @@ -201,6 +178,37 @@ public class LocalStatisticsProvider : ILocalStatisticsProvider
return BaseError.New("BUG - this should never happen");
}
private async Task<Either<BaseError, bool>> RefreshStatistics(
string ffmpegPath,
string ffprobePath,
MediaItem mediaItem,
string mediaItemPath)
{
try
{
Either<BaseError, FFprobe> maybeProbe = await GetProbeOutput(ffprobePath, mediaItemPath);
return await maybeProbe.Match(
async ffprobe =>
{
MediaVersion version = ProjectToMediaVersion(mediaItemPath, ffprobe);
if (version.Duration.TotalSeconds < 1)
{
await AnalyzeDuration(ffmpegPath, mediaItemPath, version);
}
bool result = await ApplyVersionUpdate(mediaItem, version, mediaItemPath);
return Right<BaseError, bool>(result);
},
error => Task.FromResult(Left<BaseError, bool>(error)));
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to refresh statistics for media item {Id}", mediaItem.Id);
_client.Notify(ex);
return BaseError.New(ex.Message);
}
}
private async Task<bool> ApplyVersionUpdate(MediaItem mediaItem, MediaVersion version, string filePath)
{
MediaVersion mediaItemVersion = mediaItem.GetHeadVersion();

36
ErsatzTV.Scanner/Core/Metadata/OtherVideoFolderScanner.cs

@ -150,6 +150,7 @@ public class OtherVideoFolderScanner : LocalFolderScanner, IOtherVideoFolderScan @@ -150,6 +150,7 @@ public class OtherVideoFolderScanner : LocalFolderScanner, IOtherVideoFolderScan
.GetOrAdd(libraryPath, file)
.BindT(video => UpdateStatistics(video, ffmpegPath, ffprobePath))
.BindT(UpdateMetadata)
.BindT(video => UpdateThumbnail(video, cancellationToken))
.BindT(UpdateSubtitles)
.BindT(FlagNormal);
@ -168,7 +169,7 @@ public class OtherVideoFolderScanner : LocalFolderScanner, IOtherVideoFolderScan @@ -168,7 +169,7 @@ public class OtherVideoFolderScanner : LocalFolderScanner, IOtherVideoFolderScan
libraryPath.LibraryId,
null,
null,
new[] { result.Item.Id },
[result.Item.Id],
Array.Empty<int>()),
cancellationToken);
}
@ -286,4 +287,37 @@ public class OtherVideoFolderScanner : LocalFolderScanner, IOtherVideoFolderScan @@ -286,4 +287,37 @@ public class OtherVideoFolderScanner : LocalFolderScanner, IOtherVideoFolderScan
return BaseError.New(ex.ToString());
}
}
private async Task<Either<BaseError, MediaItemScanResult<OtherVideo>>> UpdateThumbnail(
MediaItemScanResult<OtherVideo> result,
CancellationToken cancellationToken)
{
try
{
OtherVideo otherVideo = result.Item;
Option<string> maybeThumbnail = LocateThumbnail(otherVideo);
foreach (string thumbnailFile in maybeThumbnail)
{
OtherVideoMetadata metadata = otherVideo.OtherVideoMetadata.Head();
await RefreshArtwork(thumbnailFile, metadata, ArtworkKind.Thumbnail, None, None, cancellationToken);
}
return result;
}
catch (Exception ex)
{
_client.Notify(ex);
return BaseError.New(ex.ToString());
}
}
private Option<string> LocateThumbnail(OtherVideo otherVideo)
{
string path = otherVideo.MediaVersions.Head().MediaFiles.Head().Path;
return ImageFileExtensions
.Map(ext => Path.ChangeExtension(path, ext))
.Filter(f => _localFileSystem.FileExists(f))
.HeadOrNone();
}
}

4
ErsatzTV.Scanner/Core/Plex/PlexMovieLibraryScanner.cs

@ -125,7 +125,7 @@ public class PlexMovieLibraryScanner : @@ -125,7 +125,7 @@ public class PlexMovieLibraryScanner :
Either<BaseError, MediaVersion> maybeVersion =
await _plexServerApiClient.GetMovieMetadataAndStatistics(
library,
library.MediaSourceId,
incoming.Key.Split("/").Last(),
connectionParameters.Connection,
connectionParameters.Token)
@ -149,7 +149,7 @@ public class PlexMovieLibraryScanner : @@ -149,7 +149,7 @@ public class PlexMovieLibraryScanner :
Either<BaseError, Tuple<MovieMetadata, MediaVersion>> maybeResult =
await _plexServerApiClient.GetMovieMetadataAndStatistics(
library,
library.MediaSourceId,
incoming.Key.Split("/").Last(),
connectionParameters.Connection,
connectionParameters.Token);

6
ErsatzTV.Scanner/Core/Plex/PlexTelevisionLibraryScanner.cs

@ -237,7 +237,7 @@ public class PlexTelevisionLibraryScanner : @@ -237,7 +237,7 @@ public class PlexTelevisionLibraryScanner :
{
Either<BaseError, EpisodeMetadata> maybeMetadata =
await _plexServerApiClient.GetEpisodeMetadataAndStatistics(
library,
library.MediaSourceId,
incoming.Key.Split("/").Last(),
connectionParameters.Connection,
connectionParameters.Token)
@ -264,7 +264,7 @@ public class PlexTelevisionLibraryScanner : @@ -264,7 +264,7 @@ public class PlexTelevisionLibraryScanner :
Either<BaseError, MediaVersion> maybeVersion =
await _plexServerApiClient.GetEpisodeMetadataAndStatistics(
library,
library.MediaSourceId,
incoming.Key.Split("/").Last(),
connectionParameters.Connection,
connectionParameters.Token)
@ -288,7 +288,7 @@ public class PlexTelevisionLibraryScanner : @@ -288,7 +288,7 @@ public class PlexTelevisionLibraryScanner :
Either<BaseError, Tuple<EpisodeMetadata, MediaVersion>> maybeResult =
await _plexServerApiClient.GetEpisodeMetadataAndStatistics(
library,
library.MediaSourceId,
incoming.Key.Split("/").Last(),
connectionParameters.Connection,
connectionParameters.Token);

2
ErsatzTV.Scanner/Program.cs

@ -13,6 +13,7 @@ using ErsatzTV.Core.Interfaces.Plex; @@ -13,6 +13,7 @@ using ErsatzTV.Core.Interfaces.Plex;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Repositories.Caching;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Interfaces.Streaming;
using ErsatzTV.Core.Jellyfin;
using ErsatzTV.Core.Metadata;
using ErsatzTV.Core.Plex;
@ -27,6 +28,7 @@ using ErsatzTV.Infrastructure.Plex; @@ -27,6 +28,7 @@ using ErsatzTV.Infrastructure.Plex;
using ErsatzTV.Infrastructure.Runtime;
using ErsatzTV.Infrastructure.Search;
using ErsatzTV.Infrastructure.Sqlite.Data;
using ErsatzTV.Scanner.Application.Streaming;
using ErsatzTV.Scanner.Core.Emby;
using ErsatzTV.Scanner.Core.FFmpeg;
using ErsatzTV.Scanner.Core.Interfaces.FFmpeg;

55
ErsatzTV/Pages/PlayoutEditor.razor

@ -1,4 +1,5 @@ @@ -1,4 +1,5 @@
@page "/playouts/add"
@page "/playouts/add/{kind}"
@using ErsatzTV.Application.Channels
@using ErsatzTV.Application.ProgramSchedules
@implements IDisposable
@ -8,9 +9,17 @@ @@ -8,9 +9,17 @@
@inject IMediator _mediator
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
<MudText Typo="Typo.h4" Class="mb-4">
@if (Kind == "externaljson")
{
<span>Add External Json Playout</span>
}
else
{
<span>Add Playout</span>
}
</MudText>
<div style="max-width: 400px;">
<MudText Typo="Typo.h4" Class="mb-4">Add Playout</MudText>
<EditForm EditContext="_editContext" OnSubmit="@HandleSubmitAsync">
<FluentValidationValidator/>
<MudCard>
@ -26,15 +35,22 @@ @@ -26,15 +35,22 @@
</MudSelectItem>
}
</MudSelect>
<MudSelect Class="mt-3"
T="ProgramScheduleViewModel"
Label="Schedule"
@bind-value="_model.ProgramSchedule">
@foreach (ProgramScheduleViewModel schedule in _programSchedules)
{
<MudSelectItem Value="@schedule">@schedule.Name</MudSelectItem>
}
</MudSelect>
@if (Kind == "externaljson")
{
<MudTextField Label="External Json File" @bind-Value="_model.ExternalJsonFile" For="@(() => _model.ExternalJsonFile)" />
}
else
{
<MudSelect Class="mt-3"
T="ProgramScheduleViewModel"
Label="Schedule"
@bind-value="_model.ProgramSchedule">
@foreach (ProgramScheduleViewModel schedule in _programSchedules)
{
<MudSelectItem Value="@schedule">@schedule.Name</MudSelectItem>
}
</MudSelect>
}
</MudCardContent>
<MudCardActions>
<MudButton ButtonType="ButtonType.Submit" Variant="Variant.Filled" Color="Color.Primary">
@ -50,11 +66,14 @@ @@ -50,11 +66,14 @@
private readonly CancellationTokenSource _cts = new();
private readonly PlayoutEditViewModel _model = new();
private List<ChannelViewModel> _channels = new();
private List<ProgramScheduleViewModel> _programSchedules = new();
private List<ChannelViewModel> _channels = [];
private List<ProgramScheduleViewModel> _programSchedules = [];
private EditContext _editContext;
private ValidationMessageStore _messageStore;
[Parameter]
public string Kind { get; set; }
public void Dispose()
{
@ -64,10 +83,16 @@ @@ -64,10 +83,16 @@
protected override async Task OnParametersSetAsync()
{
_model.Kind = Kind;
_channels = await _mediator.Send(new GetAllChannels(), _cts.Token)
.Map(list => list.OrderBy(vm => decimal.Parse(vm.Number)).ToList());
_programSchedules = await _mediator.Send(new GetAllProgramSchedules(), _cts.Token)
.Map(list => list.OrderBy(vm => vm.Name).ToList());
if (Kind != "externaljson")
{
_programSchedules = await _mediator.Send(new GetAllProgramSchedules(), _cts.Token)
.Map(list => list.OrderBy(vm => vm.Name).ToList());
}
}
protected override void OnInitialized()

90
ErsatzTV/Pages/Playouts.razor

@ -9,9 +9,14 @@ @@ -9,9 +9,14 @@
@inject IEntityLocker EntityLocker;
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
<MudButton Variant="Variant.Filled" Color="Color.Primary" Link="playouts/add">
Add Playout
</MudButton>
<div>
<MudButton Variant="Variant.Filled" Color="Color.Primary" Link="playouts/add">
Add Playout
</MudButton>
<MudButton Class="ml-3" Variant="Variant.Filled" Color="Color.Primary" Link="playouts/add/externaljson">
Add External Json Playout
</MudButton>
</div>
<MudTable Hover="true"
Dense="true"
Class="mt-4"
@ -53,24 +58,44 @@ @@ -53,24 +58,44 @@
<MudProgressCircular Color="Color.Primary" Size="Size.Small" Indeterminate="true"/>
}
</div>
<MudTooltip Text="Edit Alternate Schedules">
<MudIconButton Icon="@Icons.Material.Filled.EditCalendar"
Disabled="@EntityLocker.IsPlayoutLocked(context.PlayoutId)"
Link="@($"playouts/{context.PlayoutId}/alternate-schedules")">
</MudIconButton>
</MudTooltip>
<MudTooltip Text="Reset Playout">
<MudIconButton Icon="@Icons.Material.Filled.Refresh"
Disabled="@EntityLocker.IsPlayoutLocked(context.PlayoutId)"
OnClick="@(_ => ResetPlayout(context))">
</MudIconButton>
</MudTooltip>
<MudTooltip Text="Schedule Reset">
<MudIconButton Icon="@Icons.Material.Filled.Update"
Disabled="@EntityLocker.IsPlayoutLocked(context.PlayoutId)"
OnClick="@(_ => ScheduleReset(context))">
</MudIconButton>
</MudTooltip>
@if (context.PlayoutType == ProgramSchedulePlayoutType.Flood)
{
<MudTooltip Text="Edit Alternate Schedules">
<MudIconButton Icon="@Icons.Material.Filled.EditCalendar"
Disabled="@EntityLocker.IsPlayoutLocked(context.PlayoutId)"
Link="@($"playouts/{context.PlayoutId}/alternate-schedules")">
</MudIconButton>
</MudTooltip>
<MudTooltip Text="Reset Playout">
<MudIconButton Icon="@Icons.Material.Filled.Refresh"
Disabled="@EntityLocker.IsPlayoutLocked(context.PlayoutId)"
OnClick="@(_ => ResetPlayout(context))">
</MudIconButton>
</MudTooltip>
<MudTooltip Text="Schedule Reset">
<MudIconButton Icon="@Icons.Material.Filled.Update"
Disabled="@EntityLocker.IsPlayoutLocked(context.PlayoutId)"
OnClick="@(_ => ScheduleReset(context))">
</MudIconButton>
</MudTooltip>
}
else if (context.PlayoutType == ProgramSchedulePlayoutType.ExternalJson)
{
<MudTooltip Text="Edit External Json File">
<MudIconButton Icon="@Icons.Material.Filled.Edit"
Disabled="@EntityLocker.IsPlayoutLocked(context.PlayoutId)"
OnClick="@(_ => EditExternalJsonFile(context))">
</MudIconButton>
</MudTooltip>
<div style="width: 48px"></div>
<div style="width: 48px"></div>
}
else
{
<div style="width: 48px"></div>
<div style="width: 48px"></div>
<div style="width: 48px"></div>
}
<MudTooltip Text="Delete Playout">
<MudIconButton Icon="@Icons.Material.Filled.Delete"
Disabled="@EntityLocker.IsPlayoutLocked(context.PlayoutId)"
@ -164,13 +189,34 @@ @@ -164,13 +189,34 @@
private async Task PlayoutSelected(PlayoutNameViewModel playout)
{
_selectedPlayoutId = playout.PlayoutId;
// only show details for flood playouts
_selectedPlayoutId = playout.PlayoutType == ProgramSchedulePlayoutType.Flood ? playout.PlayoutId : null;
if (_detailTable != null)
{
await _detailTable.ReloadServerData();
}
}
private async Task EditExternalJsonFile(PlayoutNameViewModel playout)
{
var parameters = new DialogParameters { { "ExternalJsonFile", $"{playout.ExternalJsonFile}" } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.Large };
IDialogReference dialog = await Dialog.ShowAsync<EditExternalJsonFileDialog>("Edit External Json File", parameters, options);
DialogResult result = await dialog.Result;
if (!result.Canceled)
{
await Mediator.Send(new UpdateExternalJsonPlayout(playout.PlayoutId, result.Data as string ?? playout.ExternalJsonFile), _cts.Token);
if (_table != null)
{
await _table.ReloadServerData();
}
_selectedPlayoutId = null;
}
}
private async Task DeletePlayout(PlayoutNameViewModel playout)
{
var parameters = new DialogParameters { { "EntityType", "playout" }, { "EntityName", $"{playout.ScheduleName} on {playout.ChannelNumber} - {playout.ChannelName}" } };

45
ErsatzTV/Shared/EditExternalJsonFileDialog.razor

@ -0,0 +1,45 @@ @@ -0,0 +1,45 @@
@implements IDisposable
<MudDialog>
<DialogContent>
<MudContainer Class="mb-6">
<MudText>
Edit the playout's external json file
</MudText>
</MudContainer>
<MudTextField Label="External Json File" @bind-Value="_externalJsonFile" />
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel" ButtonType="ButtonType.Reset">Cancel</MudButton>
<MudButton Color="Color.Primary" Variant="Variant.Filled" Disabled="@(string.IsNullOrWhiteSpace(_externalJsonFile))" OnClick="Submit">
Save Changes
</MudButton>
</DialogActions>
</MudDialog>
@code {
private readonly CancellationTokenSource _cts = new();
[Parameter]
public string ExternalJsonFile { get; set; }
[CascadingParameter]
MudDialogInstance MudDialog { get; set; }
private string _externalJsonFile;
public void Dispose()
{
_cts.Cancel();
_cts.Dispose();
}
protected override void OnParametersSet()
{
_externalJsonFile = ExternalJsonFile;
}
private void Submit() => MudDialog.Close(DialogResult.Ok(_externalJsonFile));
private void Cancel() => MudDialog.Cancel();
}

6
ErsatzTV/Startup.cs

@ -27,6 +27,7 @@ using ErsatzTV.Core.Interfaces.Repositories.Caching; @@ -27,6 +27,7 @@ using ErsatzTV.Core.Interfaces.Repositories.Caching;
using ErsatzTV.Core.Interfaces.Scheduling;
using ErsatzTV.Core.Interfaces.Scripting;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Interfaces.Streaming;
using ErsatzTV.Core.Interfaces.Trakt;
using ErsatzTV.Core.Jellyfin;
using ErsatzTV.Core.Metadata;
@ -56,6 +57,9 @@ using ErsatzTV.Infrastructure.Scripting; @@ -56,6 +57,9 @@ using ErsatzTV.Infrastructure.Scripting;
using ErsatzTV.Infrastructure.Search;
using ErsatzTV.Infrastructure.Sqlite.Data;
using ErsatzTV.Infrastructure.Trakt;
using ErsatzTV.Scanner.Application.Streaming;
using ErsatzTV.Scanner.Core.Interfaces.Metadata;
using ErsatzTV.Scanner.Core.Metadata;
using ErsatzTV.Serialization;
using ErsatzTV.Services;
using ErsatzTV.Services.RunOnce;
@ -645,6 +649,8 @@ public class Startup @@ -645,6 +649,8 @@ public class Startup
services.AddScoped<IArtworkRepository, ArtworkRepository>();
services.AddScoped<IFFmpegLocator, FFmpegLocator>();
services.AddScoped<IFallbackMetadataProvider, FallbackMetadataProvider>();
services.AddScoped<ILocalStatisticsProvider, LocalStatisticsProvider>();
services.AddScoped<IExternalJsonPlayoutItemProvider, ExternalJsonPlayoutItemProvider>();
services.AddScoped<IPlayoutBuilder, PlayoutBuilder>();
services.AddScoped<IImageCache, ImageCache>();
services.AddScoped<ILocalFileSystem, LocalFileSystem>();

3
ErsatzTV/Validators/PlayoutEditViewModelValidator.cs

@ -8,6 +8,7 @@ public class PlayoutEditViewModelValidator : AbstractValidator<PlayoutEditViewMo @@ -8,6 +8,7 @@ public class PlayoutEditViewModelValidator : AbstractValidator<PlayoutEditViewMo
public PlayoutEditViewModelValidator()
{
RuleFor(p => p.Channel).NotNull();
RuleFor(p => p.ProgramSchedule).NotNull();
RuleFor(p => p.ProgramSchedule).NotNull().When(p => p.Kind != "externaljson");
RuleFor(p => p.ExternalJsonFile).NotNull().When(p => p.Kind == "externaljson");
}
}

8
ErsatzTV/ViewModels/PlayoutEditViewModel.cs

@ -1,15 +1,17 @@ @@ -1,15 +1,17 @@
using ErsatzTV.Application.Channels;
using ErsatzTV.Application.Playouts;
using ErsatzTV.Application.ProgramSchedules;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.ViewModels;
public class PlayoutEditViewModel
{
public string Kind { get; set; }
public ChannelViewModel Channel { get; set; }
public ProgramScheduleViewModel ProgramSchedule { get; set; }
public string ExternalJsonFile { get; set; }
public CreatePlayout ToCreate() =>
new(Channel.Id, ProgramSchedule.Id, ProgramSchedulePlayoutType.Flood);
public CreatePlayout ToCreate() => Kind == "externaljson"
? new CreateExternalJsonPlayout(Channel.Id, ExternalJsonFile)
: new CreateFloodPlayout(Channel.Id, ProgramSchedule.Id);
}

Loading…
Cancel
Save