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
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
+11
-11
@@ -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
|
||||
}
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
.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");
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Playouts;
|
||||
|
||||
public record UpdateExternalJsonPlayout(int PlayoutId, string ExternalJsonFile)
|
||||
: IRequest<Either<BaseError, PlayoutNameViewModel>>;
|
||||
@@ -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."));
|
||||
}
|
||||
@@ -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
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+13
-2
@@ -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<
|
||||
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<
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IFFmpegProcessService ffmpegProcessService,
|
||||
ILocalFileSystem localFileSystem,
|
||||
IExternalJsonPlayoutItemProvider externalJsonPlayoutItemProvider,
|
||||
IPlexPathReplacementService plexPathReplacementService,
|
||||
IJellyfinPathReplacementService jellyfinPathReplacementService,
|
||||
IEmbyPathReplacementService embyPathReplacementService,
|
||||
@@ -52,6 +55,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
||||
{
|
||||
_ffmpegProcessService = ffmpegProcessService;
|
||||
_localFileSystem = localFileSystem;
|
||||
_externalJsonPlayoutItemProvider = externalJsonPlayoutItemProvider;
|
||||
_plexPathReplacementService = plexPathReplacementService;
|
||||
_jellyfinPathReplacementService = jellyfinPathReplacementService;
|
||||
_embyPathReplacementService = embyPathReplacementService;
|
||||
@@ -140,6 +144,15 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
||||
.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<
|
||||
_ => path
|
||||
};
|
||||
}
|
||||
|
||||
private sealed record PlayoutItemWithPath(PlayoutItem PlayoutItem, string Path);
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Core.Domain;
|
||||
|
||||
public sealed record PlayoutItemWithPath(PlayoutItem PlayoutItem, string Path);
|
||||
@@ -3,6 +3,7 @@
|
||||
public enum ProgramSchedulePlayoutType
|
||||
{
|
||||
None = 0,
|
||||
Flood,
|
||||
Daily
|
||||
Flood = 1,
|
||||
|
||||
ExternalJson = 20
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
Generated
+4520
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
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
|
||||
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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
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"));
|
||||
},
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -5,13 +5,9 @@ namespace ErsatzTV.Scanner.Core.Interfaces.Metadata;
|
||||
|
||||
public interface ILocalStatisticsProvider
|
||||
{
|
||||
Task<Either<BaseError, bool>> RefreshStatistics(string ffmpegPath, string ffprobePath, MediaItem mediaItem);
|
||||
Task<Either<BaseError, MediaVersion>> GetStatistics(string ffmpegPath, string ffprobePath, string path);
|
||||
|
||||
Task<Either<BaseError, bool>> RefreshStatistics(
|
||||
string ffmpegPath,
|
||||
string ffprobePath,
|
||||
MediaItem mediaItem,
|
||||
string mediaItemPath);
|
||||
Task<Either<BaseError, bool>> RefreshStatistics(string ffmpegPath, string ffprobePath, MediaItem mediaItem);
|
||||
|
||||
Task<Either<BaseError, Dictionary<string, string>>> GetSongTags(string ffprobePath, MediaItem mediaItem);
|
||||
}
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -36,6 +36,14 @@ public class LocalStatisticsProvider : ILocalStatisticsProvider
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, MediaVersion>> GetStatistics(string ffmpegPath, string ffprobePath, string path)
|
||||
{
|
||||
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,
|
||||
@@ -54,37 +62,6 @@ public class LocalStatisticsProvider : ILocalStatisticsProvider
|
||||
}
|
||||
}
|
||||
|
||||
public 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);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Dictionary<string, string>>> GetSongTags(
|
||||
string ffprobePath,
|
||||
MediaItem mediaItem)
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
libraryPath.LibraryId,
|
||||
null,
|
||||
null,
|
||||
new[] { result.Item.Id },
|
||||
[result.Item.Id],
|
||||
Array.Empty<int>()),
|
||||
cancellationToken);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 :
|
||||
|
||||
Either<BaseError, Tuple<MovieMetadata, MediaVersion>> maybeResult =
|
||||
await _plexServerApiClient.GetMovieMetadataAndStatistics(
|
||||
library,
|
||||
library.MediaSourceId,
|
||||
incoming.Key.Split("/").Last(),
|
||||
connectionParameters.Connection,
|
||||
connectionParameters.Token);
|
||||
|
||||
@@ -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 :
|
||||
|
||||
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 :
|
||||
|
||||
Either<BaseError, Tuple<EpisodeMetadata, MediaVersion>> maybeResult =
|
||||
await _plexServerApiClient.GetEpisodeMetadataAndStatistics(
|
||||
library,
|
||||
library.MediaSourceId,
|
||||
incoming.Key.Split("/").Last(),
|
||||
connectionParameters.Connection,
|
||||
connectionParameters.Token);
|
||||
|
||||
@@ -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;
|
||||
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;
|
||||
|
||||
@@ -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 @@
|
||||
@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,6 +35,12 @@
|
||||
</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"
|
||||
@@ -35,6 +50,7 @@
|
||||
<MudSelectItem Value="@schedule">@schedule.Name</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
}
|
||||
</MudCardContent>
|
||||
<MudCardActions>
|
||||
<MudButton ButtonType="ButtonType.Submit" Variant="Variant.Filled" Color="Color.Primary">
|
||||
@@ -50,12 +66,15 @@
|
||||
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()
|
||||
{
|
||||
_cts.Cancel();
|
||||
@@ -64,11 +83,17 @@
|
||||
|
||||
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());
|
||||
|
||||
if (Kind != "externaljson")
|
||||
{
|
||||
_programSchedules = await _mediator.Send(new GetAllProgramSchedules(), _cts.Token)
|
||||
.Map(list => list.OrderBy(vm => vm.Name).ToList());
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
|
||||
@@ -9,9 +9,14 @@
|
||||
@inject IEntityLocker EntityLocker;
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
|
||||
<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,6 +58,8 @@
|
||||
<MudProgressCircular Color="Color.Primary" Size="Size.Small" Indeterminate="true"/>
|
||||
}
|
||||
</div>
|
||||
@if (context.PlayoutType == ProgramSchedulePlayoutType.Flood)
|
||||
{
|
||||
<MudTooltip Text="Edit Alternate Schedules">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.EditCalendar"
|
||||
Disabled="@EntityLocker.IsPlayoutLocked(context.PlayoutId)"
|
||||
@@ -71,6 +78,24 @@
|
||||
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 @@
|
||||
|
||||
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}" } };
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
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
|
||||
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>();
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user