Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f895ab5304 | ||
|
|
07c54ff45f | ||
|
|
6a29ce2049 | ||
|
|
d19e95fb38 | ||
|
|
d78daf8735 | ||
|
|
4f6522379d | ||
|
|
9e0972fec0 | ||
|
|
6d564233ed | ||
|
|
47252b1243 | ||
|
|
bd5b52922d | ||
|
|
59c793b9be | ||
|
|
3ad1ba01f8 | ||
|
|
ab10f0ed81 | ||
|
|
44dd68fe59 |
+28
-2
@@ -5,6 +5,31 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.5.7-beta] - 2022-05-14
|
||||
### Fixed
|
||||
- Reduce memory use due to library scan operations
|
||||
- Fix some instances of filler getting "stuck" when a filler item is encountered that's too long for the gap
|
||||
- Properly ignore Plex `Other Videos` libraries (`movie` libraries where agent is `com.plexapp.agents.none`)
|
||||
- Fix `Custom Title` for schedule items with `One`, `Multiple` and `Flood` playout modes
|
||||
- Fix scheduling bug where flood items would sometimes fail to continue after midnight
|
||||
|
||||
### Added
|
||||
- Add `metadata_kind` field to search index to allow searching for items with a particular metdata source
|
||||
- Valid metadata kinds are `fallback`, `sidecar` (NFO), `external` (from a media server) and `embedded` (songs)
|
||||
- Add autocomplete functionality to search bar to quickly navigate to channels, ffmpeg profiles, collections and schedules by name
|
||||
- Add global setting to skip missing (file-not-found or unavailable) items when building playouts
|
||||
- Add filler preset option to allow watermarks to overlay on top of filler (disabled by default)
|
||||
- This option is applied when new items are added to a playout; rebuilding is needed if you want the change to take effect immediately
|
||||
- Read `track` field from music video NFO metadata and use it for chronological sorting (after release date)
|
||||
- Add `Random Start Point` option to schedules
|
||||
- When this option is enabled, all `Chronological` or `Shuffle In Order` content groups will have their start points randomized
|
||||
- When this option is disabled, all `Chronological` or `Shuffle In Order` content groups will start with the chronologically earliest item
|
||||
|
||||
### Changed
|
||||
- Replace invalid (control) characters in NFO metadata with replacement character `�` before parsing
|
||||
- Store partial (incomplete) NFO metadata results when invalid XML is encountered
|
||||
- Previously, no metadata would be stored if the XML within the NFO failed to validate
|
||||
|
||||
## [0.5.6-beta] - 2022-05-06
|
||||
### Fixed
|
||||
- Fix processing local movie NFO metadata without a `year` value
|
||||
@@ -1171,8 +1196,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
- Initial release to facilitate testing outside of Docker.
|
||||
|
||||
|
||||
[Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.5.6-beta...HEAD
|
||||
[0.5.5-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.5.5-beta...v0.5.6-beta
|
||||
[Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.5.7-beta...HEAD
|
||||
[0.5.7-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.5.6-beta...v0.5.7-beta
|
||||
[0.5.6-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.5.5-beta...v0.5.6-beta
|
||||
[0.5.5-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.5.4-beta...v0.5.5-beta
|
||||
[0.5.4-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.5.3-beta...v0.5.4-beta
|
||||
[0.5.3-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.5.2-beta...v0.5.3-beta
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Iptv;
|
||||
using Microsoft.IO;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class GetChannelGuideHandler : IRequestHandler<GetChannelGuide, ChannelGuide>
|
||||
{
|
||||
private readonly IChannelRepository _channelRepository;
|
||||
private readonly RecyclableMemoryStreamManager _recyclableMemoryStreamManager;
|
||||
|
||||
public GetChannelGuideHandler(IChannelRepository channelRepository) => _channelRepository = channelRepository;
|
||||
public GetChannelGuideHandler(IChannelRepository channelRepository, RecyclableMemoryStreamManager recyclableMemoryStreamManager)
|
||||
{
|
||||
_channelRepository = channelRepository;
|
||||
_recyclableMemoryStreamManager = recyclableMemoryStreamManager;
|
||||
}
|
||||
|
||||
public Task<ChannelGuide> Handle(GetChannelGuide request, CancellationToken cancellationToken) =>
|
||||
_channelRepository.GetAllForGuide()
|
||||
.Map(channels => new ChannelGuide(request.Scheme, request.Host, channels));
|
||||
.Map(channels => new ChannelGuide(_recyclableMemoryStreamManager, request.Scheme, request.Host, channels));
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public record UpdatePlayoutDaysToBuild(int DaysToBuild) : IRequest<Either<BaseError, Unit>>;
|
||||
@@ -0,0 +1,5 @@
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public record UpdatePlayoutSettings(PlayoutSettingsViewModel PlayoutSettings) : IRequest<Either<BaseError, Unit>>;
|
||||
+12
-9
@@ -9,13 +9,13 @@ using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public class UpdatePlayoutDaysToBuildHandler : IRequestHandler<UpdatePlayoutDaysToBuild, Either<BaseError, Unit>>
|
||||
public class UpdatePlayoutSettingsHandler : IRequestHandler<UpdatePlayoutSettings, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
|
||||
|
||||
public UpdatePlayoutDaysToBuildHandler(
|
||||
public UpdatePlayoutSettingsHandler(
|
||||
IConfigElementRepository configElementRepository,
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ChannelWriter<IBackgroundServiceRequest> workerChannel)
|
||||
@@ -26,17 +26,20 @@ public class UpdatePlayoutDaysToBuildHandler : IRequestHandler<UpdatePlayoutDays
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Unit>> Handle(
|
||||
UpdatePlayoutDaysToBuild request,
|
||||
UpdatePlayoutSettings request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, Unit> validation = await Validate(request);
|
||||
return await validation.Apply<Unit, Unit>(_ => ApplyUpdate(dbContext, request.DaysToBuild));
|
||||
return await validation.Apply<Unit, Unit>(_ => ApplyUpdate(dbContext, request.PlayoutSettings));
|
||||
}
|
||||
|
||||
private async Task<Unit> ApplyUpdate(TvContext dbContext, int daysToBuild)
|
||||
private async Task<Unit> ApplyUpdate(TvContext dbContext, PlayoutSettingsViewModel playoutSettings)
|
||||
{
|
||||
await _configElementRepository.Upsert(ConfigElementKey.PlayoutDaysToBuild, daysToBuild);
|
||||
await _configElementRepository.Upsert(ConfigElementKey.PlayoutDaysToBuild, playoutSettings.DaysToBuild);
|
||||
await _configElementRepository.Upsert(
|
||||
ConfigElementKey.PlayoutSkipMissingItems,
|
||||
playoutSettings.SkipMissingItems);
|
||||
|
||||
// continue all playouts to proper number of days
|
||||
List<Playout> playouts = await dbContext.Playouts
|
||||
@@ -50,8 +53,8 @@ public class UpdatePlayoutDaysToBuildHandler : IRequestHandler<UpdatePlayoutDays
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private static Task<Validation<BaseError, Unit>> Validate(UpdatePlayoutDaysToBuild request) =>
|
||||
Optional(request.DaysToBuild)
|
||||
private static Task<Validation<BaseError, Unit>> Validate(UpdatePlayoutSettings request) =>
|
||||
Optional(request.PlayoutSettings.DaysToBuild)
|
||||
.Where(days => days > 0)
|
||||
.Map(_ => Unit.Default)
|
||||
.ToValidation<BaseError>("Days to build must be greater than zero")
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public class PlayoutSettingsViewModel
|
||||
{
|
||||
public int DaysToBuild { get; set; }
|
||||
public bool SkipMissingItems { get; set; }
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public record GetPlayoutDaysToBuild : IRequest<int>;
|
||||
@@ -1,16 +0,0 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public class GetPlayoutDaysToBuildHandler : IRequestHandler<GetPlayoutDaysToBuild, int>
|
||||
{
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
|
||||
public GetPlayoutDaysToBuildHandler(IConfigElementRepository configElementRepository) =>
|
||||
_configElementRepository = configElementRepository;
|
||||
|
||||
public Task<int> Handle(GetPlayoutDaysToBuild request, CancellationToken cancellationToken) =>
|
||||
_configElementRepository.GetValue<int>(ConfigElementKey.PlayoutDaysToBuild)
|
||||
.Map(result => result.IfNone(2));
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public record GetPlayoutSettings : IRequest<PlayoutSettingsViewModel>;
|
||||
@@ -0,0 +1,26 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public class GetPlayoutSettingsHandler : IRequestHandler<GetPlayoutSettings, PlayoutSettingsViewModel>
|
||||
{
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
|
||||
public GetPlayoutSettingsHandler(IConfigElementRepository configElementRepository) =>
|
||||
_configElementRepository = configElementRepository;
|
||||
|
||||
public async Task<PlayoutSettingsViewModel> Handle(GetPlayoutSettings request, CancellationToken cancellationToken)
|
||||
{
|
||||
Option<int> daysToBuild = await _configElementRepository.GetValue<int>(ConfigElementKey.PlayoutDaysToBuild);
|
||||
|
||||
Option<bool> skipMissingItems =
|
||||
await _configElementRepository.GetValue<bool>(ConfigElementKey.PlayoutSkipMissingItems);
|
||||
|
||||
return new PlayoutSettingsViewModel
|
||||
{
|
||||
DaysToBuild = await daysToBuild.IfNoneAsync(2),
|
||||
SkipMissingItems = await skipMissingItems.IfNoneAsync(false)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@
|
||||
<PackageReference Include="Humanizer.Core" Version="2.14.1" />
|
||||
<PackageReference Include="MediatR" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="6.0.0" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.1.46">
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.2.32">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
|
||||
@@ -11,6 +11,7 @@ public record CreateFillerPreset(
|
||||
TimeSpan? Duration,
|
||||
int? Count,
|
||||
int? PadToNearestMinute,
|
||||
bool AllowWatermarks,
|
||||
ProgramScheduleItemCollectionType CollectionType,
|
||||
int? CollectionId,
|
||||
int? MediaItemId,
|
||||
|
||||
@@ -31,6 +31,7 @@ public class CreateFillerPresetHandler : IRequestHandler<CreateFillerPreset, Eit
|
||||
Duration = request.Duration,
|
||||
Count = request.Count,
|
||||
PadToNearestMinute = request.PadToNearestMinute,
|
||||
AllowWatermarks = request.AllowWatermarks,
|
||||
CollectionType = request.CollectionType,
|
||||
CollectionId = request.CollectionId,
|
||||
MediaItemId = request.MediaItemId,
|
||||
|
||||
@@ -12,6 +12,7 @@ public record UpdateFillerPreset(
|
||||
TimeSpan? Duration,
|
||||
int? Count,
|
||||
int? PadToNearestMinute,
|
||||
bool AllowWatermarks,
|
||||
ProgramScheduleItemCollectionType CollectionType,
|
||||
int? CollectionId,
|
||||
int? MediaItemId,
|
||||
|
||||
@@ -15,8 +15,7 @@ public class UpdateFillerPresetHandler : IRequestHandler<UpdateFillerPreset, Eit
|
||||
|
||||
public async Task<Either<BaseError, Unit>> Handle(UpdateFillerPreset request, CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, FillerPreset> validation = await FillerPresetMustExist(dbContext, request);
|
||||
return await LanguageExtensions.Apply(validation, ps => ApplyUpdateRequest(dbContext, ps, request));
|
||||
}
|
||||
@@ -32,6 +31,7 @@ public class UpdateFillerPresetHandler : IRequestHandler<UpdateFillerPreset, Eit
|
||||
existing.Duration = request.Duration;
|
||||
existing.Count = request.Count;
|
||||
existing.PadToNearestMinute = request.PadToNearestMinute;
|
||||
existing.AllowWatermarks = request.AllowWatermarks;
|
||||
existing.CollectionType = request.CollectionType;
|
||||
existing.CollectionId = request.CollectionId;
|
||||
existing.MediaItemId = request.MediaItemId;
|
||||
|
||||
@@ -11,6 +11,7 @@ public record FillerPresetViewModel(
|
||||
TimeSpan? Duration,
|
||||
int? Count,
|
||||
int? PadToNearestMinute,
|
||||
bool AllowWatermarks,
|
||||
ProgramScheduleItemCollectionType CollectionType,
|
||||
int? CollectionId,
|
||||
int? MediaItemId,
|
||||
|
||||
@@ -13,6 +13,7 @@ internal static class Mapper
|
||||
fillerPreset.Duration,
|
||||
fillerPreset.Count,
|
||||
fillerPreset.PadToNearestMinute,
|
||||
fillerPreset.AllowWatermarks,
|
||||
fillerPreset.CollectionType,
|
||||
fillerPreset.CollectionId,
|
||||
fillerPreset.MediaItemId,
|
||||
|
||||
@@ -6,4 +6,5 @@ public record CreateProgramSchedule(
|
||||
string Name,
|
||||
bool KeepMultiPartEpisodesTogether,
|
||||
bool TreatCollectionsAsShows,
|
||||
bool ShuffleScheduleItems) : IRequest<Either<BaseError, CreateProgramScheduleResult>>;
|
||||
bool ShuffleScheduleItems,
|
||||
bool RandomStartPoint) : IRequest<Either<BaseError, CreateProgramScheduleResult>>;
|
||||
|
||||
@@ -44,7 +44,8 @@ public class CreateProgramScheduleHandler :
|
||||
Name = name,
|
||||
KeepMultiPartEpisodesTogether = keepMultiPartEpisodesTogether,
|
||||
TreatCollectionsAsShows = keepMultiPartEpisodesTogether && request.TreatCollectionsAsShows,
|
||||
ShuffleScheduleItems = request.ShuffleScheduleItems
|
||||
ShuffleScheduleItems = request.ShuffleScheduleItems,
|
||||
RandomStartPoint = request.RandomStartPoint
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -8,4 +8,5 @@ public record UpdateProgramSchedule
|
||||
string Name,
|
||||
bool KeepMultiPartEpisodesTogether,
|
||||
bool TreatCollectionsAsShows,
|
||||
bool ShuffleScheduleItems) : IRequest<Either<BaseError, UpdateProgramScheduleResult>>;
|
||||
bool ShuffleScheduleItems,
|
||||
bool RandomStartPoint) : IRequest<Either<BaseError, UpdateProgramScheduleResult>>;
|
||||
|
||||
@@ -41,13 +41,15 @@ public class UpdateProgramScheduleHandler :
|
||||
bool needToRefreshPlayout =
|
||||
programSchedule.KeepMultiPartEpisodesTogether != request.KeepMultiPartEpisodesTogether ||
|
||||
programSchedule.TreatCollectionsAsShows != request.TreatCollectionsAsShows ||
|
||||
programSchedule.ShuffleScheduleItems != request.ShuffleScheduleItems;
|
||||
programSchedule.ShuffleScheduleItems != request.ShuffleScheduleItems ||
|
||||
programSchedule.RandomStartPoint != request.RandomStartPoint;
|
||||
|
||||
programSchedule.Name = request.Name;
|
||||
programSchedule.KeepMultiPartEpisodesTogether = request.KeepMultiPartEpisodesTogether;
|
||||
programSchedule.TreatCollectionsAsShows = programSchedule.KeepMultiPartEpisodesTogether &&
|
||||
request.TreatCollectionsAsShows;
|
||||
programSchedule.ShuffleScheduleItems = request.ShuffleScheduleItems;
|
||||
programSchedule.RandomStartPoint = request.RandomStartPoint;
|
||||
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
|
||||
@@ -10,7 +10,8 @@ internal static class Mapper
|
||||
programSchedule.Name,
|
||||
programSchedule.KeepMultiPartEpisodesTogether,
|
||||
programSchedule.TreatCollectionsAsShows,
|
||||
programSchedule.ShuffleScheduleItems);
|
||||
programSchedule.ShuffleScheduleItems,
|
||||
programSchedule.RandomStartPoint);
|
||||
|
||||
internal static ProgramScheduleItemViewModel ProjectToViewModel(ProgramScheduleItem programScheduleItem) =>
|
||||
programScheduleItem switch
|
||||
|
||||
@@ -5,4 +5,5 @@ public record ProgramScheduleViewModel(
|
||||
string Name,
|
||||
bool KeepMultiPartEpisodesTogether,
|
||||
bool TreatCollectionsAsShows,
|
||||
bool ShuffleScheduleItems);
|
||||
bool ShuffleScheduleItems,
|
||||
bool RandomStartPoint);
|
||||
|
||||
@@ -22,7 +22,8 @@ public class GetAllProgramSchedulesHandler : IRequestHandler<GetAllProgramSchedu
|
||||
ps.Name,
|
||||
ps.KeepMultiPartEpisodesTogether,
|
||||
ps.TreatCollectionsAsShows,
|
||||
ps.ShuffleScheduleItems))
|
||||
ps.ShuffleScheduleItems,
|
||||
ps.RandomStartPoint))
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.Search;
|
||||
|
||||
public record QuerySearchTargets : IRequest<List<SearchTargetViewModel>>;
|
||||
@@ -0,0 +1,54 @@
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Search;
|
||||
|
||||
public class QuerySearchTargetsHandler : IRequestHandler<QuerySearchTargets, List<SearchTargetViewModel>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
|
||||
public QuerySearchTargetsHandler(IDbContextFactory<TvContext> dbContextFactory) =>
|
||||
_dbContextFactory = dbContextFactory;
|
||||
|
||||
public async Task<List<SearchTargetViewModel>> Handle(
|
||||
QuerySearchTargets request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = new List<SearchTargetViewModel>();
|
||||
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
result.AddRange(
|
||||
dbContext.Channels
|
||||
.Map(c => new SearchTargetViewModel(c.Id, c.Name, SearchTargetKind.Channel)));
|
||||
result.AddRange(
|
||||
dbContext.FFmpegProfiles
|
||||
.Map(f => new SearchTargetViewModel(f.Id, f.Name, SearchTargetKind.FFmpegProfile)));
|
||||
result.AddRange(
|
||||
dbContext.ChannelWatermarks
|
||||
.Map(w => new SearchTargetViewModel(w.Id, w.Name, SearchTargetKind.ChannelWatermark)));
|
||||
result.AddRange(
|
||||
dbContext.Collections
|
||||
.Map(c => new SearchTargetViewModel(c.Id, c.Name, SearchTargetKind.Collection)));
|
||||
result.AddRange(
|
||||
dbContext.MultiCollections
|
||||
.Map(mc => new SearchTargetViewModel(mc.Id, mc.Name, SearchTargetKind.MultiCollection)));
|
||||
result.AddRange(
|
||||
dbContext.SmartCollections
|
||||
.Map(sc => new SmartCollectionSearchTargetViewModel(sc.Id, sc.Name, sc.Query)));
|
||||
|
||||
var schedules = await dbContext.ProgramSchedules
|
||||
.Map(s => new { s.Id, s.Name })
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
result.AddRange(
|
||||
schedules.SelectMany(
|
||||
s => new[]
|
||||
{
|
||||
new SearchTargetViewModel(s.Id, s.Name, SearchTargetKind.Schedule),
|
||||
new SearchTargetViewModel(s.Id, s.Name, SearchTargetKind.ScheduleItems)
|
||||
}));
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace ErsatzTV.Application.Search;
|
||||
|
||||
public record SearchTargetViewModel(int Id, string Name, SearchTargetKind Kind);
|
||||
|
||||
public record SmartCollectionSearchTargetViewModel(int Id, string Name, string Query)
|
||||
: SearchTargetViewModel(Id, Name, SearchTargetKind.SmartCollection);
|
||||
|
||||
public enum SearchTargetKind
|
||||
{
|
||||
Channel = 1,
|
||||
FFmpegProfile = 2,
|
||||
ChannelWatermark = 3,
|
||||
Collection = 4,
|
||||
MultiCollection = 5,
|
||||
SmartCollection = 6,
|
||||
Schedule = 7,
|
||||
ScheduleItems = 8
|
||||
}
|
||||
+4
-2
@@ -182,7 +182,8 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
||||
playoutItemWithPath.PlayoutItem.InPoint,
|
||||
playoutItemWithPath.PlayoutItem.OutPoint,
|
||||
request.PtsOffset,
|
||||
request.TargetFramerate);
|
||||
request.TargetFramerate,
|
||||
playoutItemWithPath.PlayoutItem.DisableWatermarks);
|
||||
|
||||
var result = new PlayoutItemProcessModel(
|
||||
process,
|
||||
@@ -371,7 +372,8 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
||||
Finish = finish.UtcDateTime,
|
||||
FillerKind = FillerKind.Fallback,
|
||||
InPoint = TimeSpan.Zero,
|
||||
OutPoint = version.Duration
|
||||
OutPoint = version.Duration,
|
||||
DisableWatermarks = !fallbackPreset.AllowWatermarks
|
||||
};
|
||||
|
||||
return await ValidatePlayoutItemPath(playoutItem);
|
||||
|
||||
@@ -10,18 +10,18 @@
|
||||
<PackageReference Include="Bugsnag" Version="3.0.1" />
|
||||
<PackageReference Include="CliWrap" Version="3.4.4" />
|
||||
<PackageReference Include="FluentAssertions" Version="6.6.0" />
|
||||
<PackageReference Include="LanguageExt.Core" Version="4.0.4" />
|
||||
<PackageReference Include="LanguageExt.Core" Version="4.1.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="6.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="6.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging" Version="6.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="6.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="6.0.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.1.0" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.1.46">
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.2.0" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.2.32">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Moq" Version="4.17.2" />
|
||||
<PackageReference Include="Moq" Version="4.18.0" />
|
||||
<PackageReference Include="NUnit" Version="3.13.3" />
|
||||
<PackageReference Include="NUnit3TestAdapter" Version="4.2.1" />
|
||||
<PackageReference Include="Serilog" Version="2.11.0" />
|
||||
@@ -37,12 +37,21 @@
|
||||
<Content Include="Resources\ErsatzTV.png">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Include="Resources\Nfo\ArtistInvalidCharacters1.nfo">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Include="Resources\Nfo\ArtistInvalidCharacters2.nfo">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Include="Resources\test.sup">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Include="Resources\test.srt">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Include="Resources\Nfo\EpisodeInvalidCharacters.nfo">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -489,7 +489,8 @@ public class TranscodingTests
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.FromSeconds(5),
|
||||
0,
|
||||
None);
|
||||
None,
|
||||
false);
|
||||
|
||||
// Console.WriteLine($"ffmpeg arguments {string.Join(" ", process.StartInfo.ArgumentList)}");
|
||||
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
using Bugsnag;
|
||||
using ErsatzTV.Core.Metadata.Nfo;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.IO;
|
||||
using Moq;
|
||||
using NUnit.Framework;
|
||||
using Serilog;
|
||||
|
||||
namespace ErsatzTV.Core.Tests.Metadata.Nfo;
|
||||
|
||||
@@ -11,7 +14,24 @@ namespace ErsatzTV.Core.Tests.Metadata.Nfo;
|
||||
public class ArtistNfoReaderTests
|
||||
{
|
||||
[SetUp]
|
||||
public void SetUp() => _artistNfoReader = new ArtistNfoReader(new Mock<IClient>().Object);
|
||||
public void SetUp() => _artistNfoReader = new ArtistNfoReader(
|
||||
new RecyclableMemoryStreamManager(),
|
||||
new Mock<IClient>().Object,
|
||||
_logger);
|
||||
|
||||
private readonly ILogger<ArtistNfoReader> _logger;
|
||||
|
||||
public ArtistNfoReaderTests()
|
||||
{
|
||||
Log.Logger = new LoggerConfiguration()
|
||||
.MinimumLevel.Debug()
|
||||
.WriteTo.Console()
|
||||
.CreateLogger();
|
||||
|
||||
ILoggerFactory loggerFactory = new LoggerFactory().AddSerilog(Log.Logger);
|
||||
|
||||
_logger = loggerFactory.CreateLogger<ArtistNfoReader>();
|
||||
}
|
||||
|
||||
private ArtistNfoReader _artistNfoReader;
|
||||
|
||||
@@ -153,6 +173,42 @@ Joel attended Hicksville High School in 1967, but he did not graduate with his c
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Invalid_Characters_End_Should_Abort_And_Return_Nfo()
|
||||
{
|
||||
string sourceFile = Path.Combine(
|
||||
TestContext.CurrentContext.TestDirectory,
|
||||
"Resources",
|
||||
"Nfo",
|
||||
"ArtistInvalidCharacters1.nfo");
|
||||
Either<BaseError, ArtistNfo> result = await _artistNfoReader.ReadFromFile(sourceFile);
|
||||
|
||||
result.IsRight.Should().BeTrue();
|
||||
foreach (ArtistNfo nfo in result.RightToSeq())
|
||||
{
|
||||
nfo.Name.Should().Be("Test Name");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Invalid_Characters_Middle_Should_Continue_And_Return_Nfo()
|
||||
{
|
||||
string sourceFile = Path.Combine(
|
||||
TestContext.CurrentContext.TestDirectory,
|
||||
"Resources",
|
||||
"Nfo",
|
||||
"ArtistInvalidCharacters2.nfo");
|
||||
Either<BaseError, ArtistNfo> result = await _artistNfoReader.ReadFromFile(sourceFile);
|
||||
|
||||
result.IsRight.Should().BeTrue();
|
||||
foreach (ArtistNfo nfo in result.RightToSeq())
|
||||
{
|
||||
nfo.Name.Should().Be("Test Name");
|
||||
nfo.Moods.Should().BeEquivalentTo(new List<string> { "Test Mood" });
|
||||
nfo.Styles.Count.Should().Be(1);
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizeLineEndingsLF(string str) =>
|
||||
str
|
||||
.Replace("\r\n", "\n")
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
using Bugsnag;
|
||||
using ErsatzTV.Core.Metadata.Nfo;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.IO;
|
||||
using Moq;
|
||||
using NUnit.Framework;
|
||||
using Serilog;
|
||||
|
||||
namespace ErsatzTV.Core.Tests.Metadata.Nfo;
|
||||
|
||||
@@ -11,7 +14,24 @@ namespace ErsatzTV.Core.Tests.Metadata.Nfo;
|
||||
public class EpisodeNfoReaderTests
|
||||
{
|
||||
[SetUp]
|
||||
public void SetUp() => _episodeNfoReader = new EpisodeNfoReader(new Mock<IClient>().Object);
|
||||
public void SetUp() => _episodeNfoReader = new EpisodeNfoReader(
|
||||
new RecyclableMemoryStreamManager(),
|
||||
new Mock<IClient>().Object,
|
||||
_logger);
|
||||
|
||||
private readonly ILogger<EpisodeNfoReader> _logger;
|
||||
|
||||
public EpisodeNfoReaderTests()
|
||||
{
|
||||
Log.Logger = new LoggerConfiguration()
|
||||
.MinimumLevel.Debug()
|
||||
.WriteTo.Console()
|
||||
.CreateLogger();
|
||||
|
||||
ILoggerFactory loggerFactory = new LoggerFactory().AddSerilog(Log.Logger);
|
||||
|
||||
_logger = loggerFactory.CreateLogger<EpisodeNfoReader>();
|
||||
}
|
||||
|
||||
private EpisodeNfoReader _episodeNfoReader;
|
||||
|
||||
@@ -404,4 +424,22 @@ public class EpisodeNfoReaderTests
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Invalid_Characters_Should_Abort_And_Return_Nfo()
|
||||
{
|
||||
string sourceFile = Path.Combine(
|
||||
TestContext.CurrentContext.TestDirectory,
|
||||
"Resources",
|
||||
"Nfo",
|
||||
"EpisodeInvalidCharacters.nfo");
|
||||
Either<BaseError, List<TvShowEpisodeNfo>> result = await _episodeNfoReader.ReadFromFile(sourceFile);
|
||||
|
||||
result.IsRight.Should().BeTrue();
|
||||
foreach (List<TvShowEpisodeNfo> list in result.RightToSeq())
|
||||
{
|
||||
list.Count.Should().Be(1);
|
||||
list[0].Title.Should().Be("Test Title");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
using Bugsnag;
|
||||
using ErsatzTV.Core.Metadata.Nfo;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.IO;
|
||||
using Moq;
|
||||
using NUnit.Framework;
|
||||
|
||||
@@ -11,7 +13,10 @@ namespace ErsatzTV.Core.Tests.Metadata.Nfo;
|
||||
public class MovieNfoReaderTests
|
||||
{
|
||||
[SetUp]
|
||||
public void SetUp() => _movieNfoReader = new MovieNfoReader(new Mock<IClient>().Object);
|
||||
public void SetUp() => _movieNfoReader = new MovieNfoReader(
|
||||
new RecyclableMemoryStreamManager(),
|
||||
new Mock<IClient>().Object,
|
||||
new NullLogger<MovieNfoReader>());
|
||||
|
||||
private MovieNfoReader _movieNfoReader;
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
using Bugsnag;
|
||||
using ErsatzTV.Core.Metadata.Nfo;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.IO;
|
||||
using Moq;
|
||||
using NUnit.Framework;
|
||||
|
||||
@@ -11,7 +13,10 @@ namespace ErsatzTV.Core.Tests.Metadata.Nfo;
|
||||
public class MusicVideoNfoReaderTests
|
||||
{
|
||||
[SetUp]
|
||||
public void SetUp() => _musicVideoNfoReader = new MusicVideoNfoReader(new Mock<IClient>().Object);
|
||||
public void SetUp() => _musicVideoNfoReader = new MusicVideoNfoReader(
|
||||
new RecyclableMemoryStreamManager(),
|
||||
new Mock<IClient>().Object,
|
||||
new NullLogger<MusicVideoNfoReader>());
|
||||
|
||||
private MusicVideoNfoReader _musicVideoNfoReader;
|
||||
|
||||
@@ -124,6 +129,7 @@ Le groupe a également enregistré une version espagnole de ce titre, La reina d
|
||||
nfo.Year.Should().Be(1976);
|
||||
nfo.Aired.IsNone.Should().BeTrue();
|
||||
nfo.Genres.Should().BeEquivalentTo(new List<string> { "Pop" });
|
||||
nfo.Track.Should().Be(-1);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
using Bugsnag;
|
||||
using ErsatzTV.Core.Metadata.Nfo;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.IO;
|
||||
using Moq;
|
||||
using NUnit.Framework;
|
||||
|
||||
@@ -11,7 +13,10 @@ namespace ErsatzTV.Core.Tests.Metadata.Nfo;
|
||||
public class OtherVideoNfoReaderTests
|
||||
{
|
||||
[SetUp]
|
||||
public void SetUp() => _otherVideoNfoReader = new OtherVideoNfoReader(new Mock<IClient>().Object);
|
||||
public void SetUp() => _otherVideoNfoReader = new OtherVideoNfoReader(
|
||||
new RecyclableMemoryStreamManager(),
|
||||
new Mock<IClient>().Object,
|
||||
new NullLogger<OtherVideoNfoReader>());
|
||||
|
||||
private OtherVideoNfoReader _otherVideoNfoReader;
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
using Bugsnag;
|
||||
using ErsatzTV.Core.Metadata.Nfo;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.IO;
|
||||
using Moq;
|
||||
using NUnit.Framework;
|
||||
|
||||
@@ -11,7 +13,10 @@ namespace ErsatzTV.Core.Tests.Metadata.Nfo;
|
||||
public class TvShowNfoReaderTests
|
||||
{
|
||||
[SetUp]
|
||||
public void SetUp() => _tvShowNfoReader = new TvShowNfoReader(new Mock<IClient>().Object);
|
||||
public void SetUp() => _tvShowNfoReader = new TvShowNfoReader(
|
||||
new RecyclableMemoryStreamManager(),
|
||||
new Mock<IClient>().Object,
|
||||
new NullLogger<TvShowNfoReader>());
|
||||
|
||||
private TvShowNfoReader _tvShowNfoReader;
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<!--created on whatever - comment-->
|
||||
<artist>
|
||||
<name>Test Name</name>
|
||||
</artist>
|
||||
ÐPS½NÞ5Þ*¡¡ã·Ýq×ÍâeVk¯¬}É
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<!--created on whatever - comment-->
|
||||
<artist>
|
||||
<name>Test Name</name>
|
||||
<style>ÐPS½NÞ5Þ*¡¡ã·Ýq×ÍâeVk¯¬}É</style>
|
||||
<mood>Test Mood</mood>
|
||||
</artist>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<!--created on whatever - comment-->
|
||||
<episodedetails>
|
||||
<title>Test Title</title>
|
||||
</episodedetails>
|
||||
ÐPS½NÞ5Þ*¡¡ã·Ýq×ÍâeVk¯¬}É
|
||||
@@ -70,6 +70,180 @@ public class PlayoutBuilderTests
|
||||
result.Items.Head().FinishOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(6));
|
||||
}
|
||||
|
||||
[Test]
|
||||
[Timeout(2000)]
|
||||
public async Task OnlyFileNotFoundItem_Should_Abort()
|
||||
{
|
||||
var mediaItems = new List<MediaItem>
|
||||
{
|
||||
TestMovie(1, TimeSpan.FromHours(1), DateTime.Today)
|
||||
};
|
||||
|
||||
mediaItems[0].State = MediaItemState.FileNotFound;
|
||||
|
||||
var configRepo = new Mock<IConfigElementRepository>();
|
||||
configRepo.Setup(
|
||||
repo => repo.GetValue<bool>(
|
||||
It.Is<ConfigElementKey>(k => k.Key == ConfigElementKey.PlayoutSkipMissingItems.Key)))
|
||||
.ReturnsAsync(Some(true));
|
||||
|
||||
(PlayoutBuilder builder, Playout playout) =
|
||||
TestDataFloodForItems(mediaItems, PlaybackOrder.Random, configRepo);
|
||||
|
||||
Playout result = await builder.Build(playout, PlayoutBuildMode.Reset);
|
||||
|
||||
configRepo.Verify();
|
||||
|
||||
result.Items.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task FileNotFoundItem_Should_BeSkipped()
|
||||
{
|
||||
var mediaItems = new List<MediaItem>
|
||||
{
|
||||
TestMovie(1, TimeSpan.FromHours(1), DateTime.Today),
|
||||
TestMovie(2, TimeSpan.FromHours(6), DateTime.Today)
|
||||
};
|
||||
|
||||
mediaItems[0].State = MediaItemState.FileNotFound;
|
||||
|
||||
var configRepo = new Mock<IConfigElementRepository>();
|
||||
configRepo.Setup(
|
||||
repo => repo.GetValue<bool>(
|
||||
It.Is<ConfigElementKey>(k => k.Key == ConfigElementKey.PlayoutSkipMissingItems.Key)))
|
||||
.ReturnsAsync(Some(true));
|
||||
|
||||
(PlayoutBuilder builder, Playout playout) =
|
||||
TestDataFloodForItems(mediaItems, PlaybackOrder.Random, configRepo);
|
||||
DateTimeOffset start = HoursAfterMidnight(0);
|
||||
DateTimeOffset finish = start + TimeSpan.FromHours(6);
|
||||
|
||||
Playout result = await builder.Build(playout, PlayoutBuildMode.Reset, start, finish);
|
||||
|
||||
configRepo.Verify();
|
||||
|
||||
result.Items.Count.Should().Be(1);
|
||||
result.Items.Head().MediaItemId.Should().Be(2);
|
||||
result.Items.Head().StartOffset.TimeOfDay.Should().Be(TimeSpan.Zero);
|
||||
result.Items.Head().FinishOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(6));
|
||||
}
|
||||
|
||||
[Test]
|
||||
[Timeout(2000)]
|
||||
public async Task OnlyUnavailableItem_Should_Abort()
|
||||
{
|
||||
var mediaItems = new List<MediaItem>
|
||||
{
|
||||
TestMovie(1, TimeSpan.FromHours(1), DateTime.Today)
|
||||
};
|
||||
|
||||
mediaItems[0].State = MediaItemState.Unavailable;
|
||||
|
||||
var configRepo = new Mock<IConfigElementRepository>();
|
||||
configRepo.Setup(
|
||||
repo => repo.GetValue<bool>(
|
||||
It.Is<ConfigElementKey>(k => k.Key == ConfigElementKey.PlayoutSkipMissingItems.Key)))
|
||||
.ReturnsAsync(Some(true));
|
||||
|
||||
(PlayoutBuilder builder, Playout playout) =
|
||||
TestDataFloodForItems(mediaItems, PlaybackOrder.Random, configRepo);
|
||||
|
||||
Playout result = await builder.Build(playout, PlayoutBuildMode.Reset);
|
||||
|
||||
configRepo.Verify();
|
||||
|
||||
result.Items.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task UnavailableItem_Should_BeSkipped()
|
||||
{
|
||||
var mediaItems = new List<MediaItem>
|
||||
{
|
||||
TestMovie(1, TimeSpan.FromHours(1), DateTime.Today),
|
||||
TestMovie(2, TimeSpan.FromHours(6), DateTime.Today)
|
||||
};
|
||||
|
||||
mediaItems[0].State = MediaItemState.Unavailable;
|
||||
|
||||
var configRepo = new Mock<IConfigElementRepository>();
|
||||
configRepo.Setup(
|
||||
repo => repo.GetValue<bool>(
|
||||
It.Is<ConfigElementKey>(k => k.Key == ConfigElementKey.PlayoutSkipMissingItems.Key)))
|
||||
.ReturnsAsync(Some(true));
|
||||
|
||||
(PlayoutBuilder builder, Playout playout) =
|
||||
TestDataFloodForItems(mediaItems, PlaybackOrder.Random, configRepo);
|
||||
DateTimeOffset start = HoursAfterMidnight(0);
|
||||
DateTimeOffset finish = start + TimeSpan.FromHours(6);
|
||||
|
||||
Playout result = await builder.Build(playout, PlayoutBuildMode.Reset, start, finish);
|
||||
|
||||
configRepo.Verify();
|
||||
|
||||
result.Items.Count.Should().Be(1);
|
||||
result.Items.Head().MediaItemId.Should().Be(2);
|
||||
result.Items.Head().StartOffset.TimeOfDay.Should().Be(TimeSpan.Zero);
|
||||
result.Items.Head().FinishOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(6));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task FileNotFound_Should_NotBeSkippedIfConfigured()
|
||||
{
|
||||
var mediaItems = new List<MediaItem>
|
||||
{
|
||||
TestMovie(1, TimeSpan.FromHours(6), DateTime.Today)
|
||||
};
|
||||
|
||||
mediaItems[0].State = MediaItemState.FileNotFound;
|
||||
|
||||
var configRepo = new Mock<IConfigElementRepository>();
|
||||
configRepo.Setup(
|
||||
repo => repo.GetValue<bool>(
|
||||
It.Is<ConfigElementKey>(k => k.Key == ConfigElementKey.PlayoutSkipMissingItems.Key)))
|
||||
.ReturnsAsync(Some(false));
|
||||
|
||||
(PlayoutBuilder builder, Playout playout) =
|
||||
TestDataFloodForItems(mediaItems, PlaybackOrder.Random, configRepo);
|
||||
DateTimeOffset start = HoursAfterMidnight(0);
|
||||
DateTimeOffset finish = start + TimeSpan.FromHours(6);
|
||||
|
||||
Playout result = await builder.Build(playout, PlayoutBuildMode.Reset, start, finish);
|
||||
|
||||
result.Items.Count.Should().Be(1);
|
||||
result.Items.Head().StartOffset.TimeOfDay.Should().Be(TimeSpan.Zero);
|
||||
result.Items.Head().FinishOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(6));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Unavailable_Should_NotBeSkippedIfConfigured()
|
||||
{
|
||||
var mediaItems = new List<MediaItem>
|
||||
{
|
||||
TestMovie(1, TimeSpan.FromHours(6), DateTime.Today)
|
||||
};
|
||||
|
||||
mediaItems[0].State = MediaItemState.Unavailable;
|
||||
|
||||
var configRepo = new Mock<IConfigElementRepository>();
|
||||
configRepo.Setup(
|
||||
repo => repo.GetValue<bool>(
|
||||
It.Is<ConfigElementKey>(k => k.Key == ConfigElementKey.PlayoutSkipMissingItems.Key)))
|
||||
.ReturnsAsync(Some(false));
|
||||
|
||||
(PlayoutBuilder builder, Playout playout) =
|
||||
TestDataFloodForItems(mediaItems, PlaybackOrder.Random, configRepo);
|
||||
DateTimeOffset start = HoursAfterMidnight(0);
|
||||
DateTimeOffset finish = start + TimeSpan.FromHours(6);
|
||||
|
||||
Playout result = await builder.Build(playout, PlayoutBuildMode.Reset, start, finish);
|
||||
|
||||
result.Items.Count.Should().Be(1);
|
||||
result.Items.Head().StartOffset.TimeOfDay.Should().Be(TimeSpan.Zero);
|
||||
result.Items.Head().FinishOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(6));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task InitialFlood_Should_StartAtMidnight()
|
||||
{
|
||||
@@ -376,6 +550,140 @@ public class PlayoutBuilderTests
|
||||
result.Items[4].MediaItemId.Should().Be(2);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task FloodContent_Should_FloodAroundFixedContent_One_Multiple_Days()
|
||||
{
|
||||
var floodCollection = new Collection
|
||||
{
|
||||
Id = 1,
|
||||
Name = "Flood Items",
|
||||
MediaItems = new List<MediaItem>
|
||||
{
|
||||
TestMovie(1, TimeSpan.FromHours(1), new DateTime(2020, 1, 1)),
|
||||
TestMovie(2, TimeSpan.FromHours(1), new DateTime(2020, 2, 1))
|
||||
}
|
||||
};
|
||||
|
||||
var fixedCollection = new Collection
|
||||
{
|
||||
Id = 2,
|
||||
Name = "Fixed Items",
|
||||
MediaItems = new List<MediaItem>
|
||||
{
|
||||
TestMovie(3, TimeSpan.FromHours(2), new DateTime(2020, 1, 1))
|
||||
}
|
||||
};
|
||||
|
||||
var fakeRepository = new FakeMediaCollectionRepository(
|
||||
Map(
|
||||
(floodCollection.Id, floodCollection.MediaItems.ToList()),
|
||||
(fixedCollection.Id, fixedCollection.MediaItems.ToList())));
|
||||
|
||||
var items = new List<ProgramScheduleItem>
|
||||
{
|
||||
new ProgramScheduleItemFlood
|
||||
{
|
||||
Index = 1,
|
||||
Collection = floodCollection,
|
||||
CollectionId = floodCollection.Id,
|
||||
StartTime = null,
|
||||
PlaybackOrder = PlaybackOrder.Chronological
|
||||
},
|
||||
new ProgramScheduleItemOne
|
||||
{
|
||||
Index = 2,
|
||||
Collection = fixedCollection,
|
||||
CollectionId = fixedCollection.Id,
|
||||
StartTime = TimeSpan.FromHours(3),
|
||||
PlaybackOrder = PlaybackOrder.Chronological
|
||||
}
|
||||
};
|
||||
|
||||
var playout = new Playout
|
||||
{
|
||||
ProgramSchedule = new ProgramSchedule
|
||||
{
|
||||
Items = items
|
||||
},
|
||||
Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" },
|
||||
ProgramScheduleAnchors = new List<PlayoutProgramScheduleAnchor>(),
|
||||
Items = new List<PlayoutItem>()
|
||||
};
|
||||
|
||||
var configRepo = new Mock<IConfigElementRepository>();
|
||||
var televisionRepo = new FakeTelevisionRepository();
|
||||
var artistRepo = new Mock<IArtistRepository>();
|
||||
var builder = new PlayoutBuilder(
|
||||
configRepo.Object,
|
||||
fakeRepository,
|
||||
televisionRepo,
|
||||
artistRepo.Object,
|
||||
_logger);
|
||||
|
||||
DateTimeOffset start = HoursAfterMidnight(0);
|
||||
DateTimeOffset finish = start + TimeSpan.FromHours(30);
|
||||
|
||||
Playout result = await builder.Build(playout, PlayoutBuildMode.Reset, start, finish);
|
||||
|
||||
result.Items.Count.Should().Be(28);
|
||||
result.Items[0].StartOffset.TimeOfDay.Should().Be(TimeSpan.Zero);
|
||||
result.Items[0].MediaItemId.Should().Be(1);
|
||||
result.Items[1].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(1));
|
||||
result.Items[1].MediaItemId.Should().Be(2);
|
||||
result.Items[2].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(2));
|
||||
result.Items[2].MediaItemId.Should().Be(1);
|
||||
result.Items[3].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(3));
|
||||
result.Items[3].MediaItemId.Should().Be(3);
|
||||
result.Items[4].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(5));
|
||||
result.Items[4].MediaItemId.Should().Be(2);
|
||||
result.Items[5].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(6));
|
||||
result.Items[5].MediaItemId.Should().Be(1);
|
||||
result.Items[6].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(7));
|
||||
result.Items[6].MediaItemId.Should().Be(2);
|
||||
result.Items[7].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(8));
|
||||
result.Items[7].MediaItemId.Should().Be(1);
|
||||
result.Items[8].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(9));
|
||||
result.Items[8].MediaItemId.Should().Be(2);
|
||||
result.Items[9].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(10));
|
||||
result.Items[9].MediaItemId.Should().Be(1);
|
||||
result.Items[10].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(11));
|
||||
result.Items[10].MediaItemId.Should().Be(2);
|
||||
result.Items[11].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(12));
|
||||
result.Items[11].MediaItemId.Should().Be(1);
|
||||
result.Items[12].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(13));
|
||||
result.Items[12].MediaItemId.Should().Be(2);
|
||||
result.Items[13].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(14));
|
||||
result.Items[13].MediaItemId.Should().Be(1);
|
||||
result.Items[14].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(15));
|
||||
result.Items[14].MediaItemId.Should().Be(2);
|
||||
result.Items[15].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(16));
|
||||
result.Items[15].MediaItemId.Should().Be(1);
|
||||
result.Items[16].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(17));
|
||||
result.Items[16].MediaItemId.Should().Be(2);
|
||||
result.Items[17].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(18));
|
||||
result.Items[17].MediaItemId.Should().Be(1);
|
||||
result.Items[18].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(19));
|
||||
result.Items[18].MediaItemId.Should().Be(2);
|
||||
result.Items[19].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(20));
|
||||
result.Items[19].MediaItemId.Should().Be(1);
|
||||
result.Items[20].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(21));
|
||||
result.Items[20].MediaItemId.Should().Be(2);
|
||||
result.Items[21].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(22));
|
||||
result.Items[21].MediaItemId.Should().Be(1);
|
||||
result.Items[22].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(23));
|
||||
result.Items[22].MediaItemId.Should().Be(2);
|
||||
result.Items[23].StartOffset.TimeOfDay.Should().Be(TimeSpan.Zero);
|
||||
result.Items[23].MediaItemId.Should().Be(1);
|
||||
result.Items[24].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(1));
|
||||
result.Items[24].MediaItemId.Should().Be(2);
|
||||
result.Items[25].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(2));
|
||||
result.Items[25].MediaItemId.Should().Be(1);
|
||||
result.Items[26].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(3));
|
||||
result.Items[26].MediaItemId.Should().Be(3);
|
||||
result.Items[27].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(5));
|
||||
result.Items[27].MediaItemId.Should().Be(2);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task FloodContent_Should_FloodAroundFixedContent_Multiple()
|
||||
{
|
||||
@@ -2142,11 +2450,20 @@ public class PlayoutBuilderTests
|
||||
MovieMetadata = new List<MovieMetadata> { new() { ReleaseDate = aired } },
|
||||
MediaVersions = new List<MediaVersion>
|
||||
{
|
||||
new() { Duration = duration }
|
||||
new()
|
||||
{
|
||||
Duration = duration, MediaFiles = new List<MediaFile>
|
||||
{
|
||||
new() { Path = $"/fake/path/{id}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private TestData TestDataFloodForItems(List<MediaItem> mediaItems, PlaybackOrder playbackOrder)
|
||||
private TestData TestDataFloodForItems(
|
||||
List<MediaItem> mediaItems,
|
||||
PlaybackOrder playbackOrder,
|
||||
Mock<IConfigElementRepository> configMock = null)
|
||||
{
|
||||
var mediaCollection = new Collection
|
||||
{
|
||||
@@ -2154,7 +2471,8 @@ public class PlayoutBuilderTests
|
||||
MediaItems = mediaItems
|
||||
};
|
||||
|
||||
var configRepo = new Mock<IConfigElementRepository>();
|
||||
Mock<IConfigElementRepository> configRepo = configMock ?? new Mock<IConfigElementRepository>();
|
||||
|
||||
var collectionRepo = new FakeMediaCollectionRepository(Map((mediaCollection.Id, mediaItems)));
|
||||
var televisionRepo = new FakeTelevisionRepository();
|
||||
var artistRepo = new Mock<IArtistRepository>();
|
||||
|
||||
@@ -25,7 +25,8 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
|
||||
StartTime = null,
|
||||
PlayoutDuration = TimeSpan.FromHours(3),
|
||||
TailMode = TailMode.None,
|
||||
PlaybackOrder = PlaybackOrder.Chronological
|
||||
PlaybackOrder = PlaybackOrder.Chronological,
|
||||
CustomTitle = "CustomTitle"
|
||||
};
|
||||
|
||||
var scheduleItemsEnumerator = new OrderedScheduleItemsEnumerator(
|
||||
@@ -49,7 +50,7 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
|
||||
playoutBuilderState.CurrentTime.Should().Be(startState.CurrentTime.AddHours(3));
|
||||
playoutItems.Last().FinishOffset.Should().Be(playoutBuilderState.CurrentTime);
|
||||
|
||||
playoutBuilderState.NextGuideGroup.Should().Be(4);
|
||||
playoutBuilderState.NextGuideGroup.Should().Be(2); // one guide group here because of custom title
|
||||
playoutBuilderState.DurationFinish.IsNone.Should().BeTrue();
|
||||
playoutBuilderState.InFlood.Should().BeFalse();
|
||||
playoutBuilderState.MultipleRemaining.IsNone.Should().BeTrue();
|
||||
@@ -65,18 +66,21 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
|
||||
playoutItems[0].GuideGroup.Should().Be(1);
|
||||
playoutItems[0].FillerKind.Should().Be(FillerKind.None);
|
||||
playoutItems[0].GuideFinish.HasValue.Should().BeFalse();
|
||||
playoutItems[0].CustomTitle.Should().Be("CustomTitle");
|
||||
|
||||
playoutItems[1].MediaItemId.Should().Be(2);
|
||||
playoutItems[1].StartOffset.Should().Be(startState.CurrentTime.AddHours(1));
|
||||
playoutItems[1].GuideGroup.Should().Be(2);
|
||||
playoutItems[1].GuideGroup.Should().Be(1);
|
||||
playoutItems[1].FillerKind.Should().Be(FillerKind.None);
|
||||
playoutItems[1].GuideFinish.HasValue.Should().BeFalse();
|
||||
playoutItems[1].CustomTitle.Should().Be("CustomTitle");
|
||||
|
||||
playoutItems[2].MediaItemId.Should().Be(1);
|
||||
playoutItems[2].StartOffset.Should().Be(startState.CurrentTime.AddHours(2));
|
||||
playoutItems[2].GuideGroup.Should().Be(3);
|
||||
playoutItems[2].GuideGroup.Should().Be(1);
|
||||
playoutItems[2].FillerKind.Should().Be(FillerKind.None);
|
||||
playoutItems[2].GuideFinish.HasValue.Should().BeTrue();
|
||||
playoutItems[2].CustomTitle.Should().Be("CustomTitle");
|
||||
}
|
||||
|
||||
[Test]
|
||||
|
||||
@@ -25,7 +25,8 @@ public class PlayoutModeSchedulerFloodTests : SchedulerTestBase
|
||||
StartTime = null,
|
||||
PlaybackOrder = PlaybackOrder.Chronological,
|
||||
TailFiller = null,
|
||||
FallbackFiller = null
|
||||
FallbackFiller = null,
|
||||
CustomTitle = "CustomTitle"
|
||||
};
|
||||
|
||||
var enumerator = new ChronologicalMediaCollectionEnumerator(
|
||||
@@ -55,7 +56,7 @@ public class PlayoutModeSchedulerFloodTests : SchedulerTestBase
|
||||
playoutBuilderState.CurrentTime.Should().Be(startState.CurrentTime.AddHours(3));
|
||||
playoutItems.Last().FinishOffset.Should().Be(playoutBuilderState.CurrentTime);
|
||||
|
||||
playoutBuilderState.NextGuideGroup.Should().Be(4);
|
||||
playoutBuilderState.NextGuideGroup.Should().Be(2); // one guide group here because of custom title
|
||||
playoutBuilderState.DurationFinish.IsNone.Should().BeTrue();
|
||||
playoutBuilderState.InFlood.Should().BeFalse();
|
||||
playoutBuilderState.MultipleRemaining.IsNone.Should().BeTrue();
|
||||
@@ -70,16 +71,19 @@ public class PlayoutModeSchedulerFloodTests : SchedulerTestBase
|
||||
playoutItems[0].StartOffset.Should().Be(startState.CurrentTime);
|
||||
playoutItems[0].GuideGroup.Should().Be(1);
|
||||
playoutItems[0].FillerKind.Should().Be(FillerKind.None);
|
||||
playoutItems[0].CustomTitle.Should().Be("CustomTitle");
|
||||
|
||||
playoutItems[1].MediaItemId.Should().Be(2);
|
||||
playoutItems[1].StartOffset.Should().Be(startState.CurrentTime.AddHours(1));
|
||||
playoutItems[1].GuideGroup.Should().Be(2);
|
||||
playoutItems[1].GuideGroup.Should().Be(1);
|
||||
playoutItems[1].FillerKind.Should().Be(FillerKind.None);
|
||||
playoutItems[1].CustomTitle.Should().Be("CustomTitle");
|
||||
|
||||
playoutItems[2].MediaItemId.Should().Be(1);
|
||||
playoutItems[2].StartOffset.Should().Be(startState.CurrentTime.AddHours(2));
|
||||
playoutItems[2].GuideGroup.Should().Be(3);
|
||||
playoutItems[2].GuideGroup.Should().Be(1);
|
||||
playoutItems[2].FillerKind.Should().Be(FillerKind.None);
|
||||
playoutItems[2].CustomTitle.Should().Be("CustomTitle");
|
||||
}
|
||||
|
||||
[Test]
|
||||
|
||||
@@ -27,7 +27,8 @@ public class PlayoutModeSchedulerMultipleTests : SchedulerTestBase
|
||||
PlaybackOrder = PlaybackOrder.Chronological,
|
||||
TailFiller = null,
|
||||
FallbackFiller = null,
|
||||
Count = 3
|
||||
Count = 3,
|
||||
CustomTitle = "CustomTitle"
|
||||
};
|
||||
|
||||
var scheduleItemsEnumerator = new OrderedScheduleItemsEnumerator(
|
||||
@@ -56,7 +57,7 @@ public class PlayoutModeSchedulerMultipleTests : SchedulerTestBase
|
||||
playoutBuilderState.CurrentTime.Should().Be(startState.CurrentTime.AddHours(3));
|
||||
playoutItems.Last().FinishOffset.Should().Be(playoutBuilderState.CurrentTime);
|
||||
|
||||
playoutBuilderState.NextGuideGroup.Should().Be(4);
|
||||
playoutBuilderState.NextGuideGroup.Should().Be(2); // one guide group here because of custom title
|
||||
playoutBuilderState.DurationFinish.IsNone.Should().BeTrue();
|
||||
playoutBuilderState.InFlood.Should().BeFalse();
|
||||
playoutBuilderState.MultipleRemaining.IsNone.Should().BeTrue();
|
||||
@@ -71,16 +72,19 @@ public class PlayoutModeSchedulerMultipleTests : SchedulerTestBase
|
||||
playoutItems[0].StartOffset.Should().Be(startState.CurrentTime);
|
||||
playoutItems[0].GuideGroup.Should().Be(1);
|
||||
playoutItems[0].FillerKind.Should().Be(FillerKind.None);
|
||||
playoutItems[0].CustomTitle.Should().Be("CustomTitle");
|
||||
|
||||
playoutItems[1].MediaItemId.Should().Be(2);
|
||||
playoutItems[1].StartOffset.Should().Be(startState.CurrentTime.AddHours(1));
|
||||
playoutItems[1].GuideGroup.Should().Be(2);
|
||||
playoutItems[1].GuideGroup.Should().Be(1);
|
||||
playoutItems[1].FillerKind.Should().Be(FillerKind.None);
|
||||
playoutItems[1].CustomTitle.Should().Be("CustomTitle");
|
||||
|
||||
playoutItems[2].MediaItemId.Should().Be(1);
|
||||
playoutItems[2].StartOffset.Should().Be(startState.CurrentTime.AddHours(2));
|
||||
playoutItems[2].GuideGroup.Should().Be(3);
|
||||
playoutItems[2].GuideGroup.Should().Be(1);
|
||||
playoutItems[2].FillerKind.Should().Be(FillerKind.None);
|
||||
playoutItems[2].CustomTitle.Should().Be("CustomTitle");
|
||||
}
|
||||
|
||||
[Test]
|
||||
|
||||
@@ -25,7 +25,8 @@ public class PlayoutModeSchedulerOneTests : SchedulerTestBase
|
||||
StartTime = null,
|
||||
PlaybackOrder = PlaybackOrder.Chronological,
|
||||
TailFiller = null,
|
||||
FallbackFiller = null
|
||||
FallbackFiller = null,
|
||||
CustomTitle = "CustomTitle"
|
||||
};
|
||||
|
||||
var scheduleItemsEnumerator = new OrderedScheduleItemsEnumerator(
|
||||
@@ -64,6 +65,7 @@ public class PlayoutModeSchedulerOneTests : SchedulerTestBase
|
||||
playoutItems[0].StartOffset.Should().Be(startState.CurrentTime);
|
||||
playoutItems[0].GuideGroup.Should().Be(1);
|
||||
playoutItems[0].FillerKind.Should().Be(FillerKind.None);
|
||||
playoutItems[0].CustomTitle.Should().Be("CustomTitle");
|
||||
}
|
||||
|
||||
[Test]
|
||||
|
||||
@@ -33,4 +33,5 @@ public class ConfigElementKey
|
||||
public static ConfigElementKey FillerPresetsPageSize => new("pages.filler_presets.page_size");
|
||||
public static ConfigElementKey LibraryRefreshInterval => new("scanner.library_refresh_interval");
|
||||
public static ConfigElementKey PlayoutDaysToBuild => new("playout.days_to_build");
|
||||
public static ConfigElementKey PlayoutSkipMissingItems => new("playout.skip_missing_items");
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ public class FillerPreset
|
||||
public TimeSpan? Duration { get; set; }
|
||||
public int? Count { get; set; }
|
||||
public int? PadToNearestMinute { get; set; }
|
||||
public bool AllowWatermarks { get; set; }
|
||||
public ProgramScheduleItemCollectionType CollectionType { get; set; }
|
||||
public int? CollectionId { get; set; }
|
||||
public Collection Collection { get; set; }
|
||||
|
||||
@@ -4,6 +4,7 @@ public class MusicVideoMetadata : Metadata
|
||||
{
|
||||
public string Album { get; set; }
|
||||
public string Plot { get; set; }
|
||||
public int? Track { get; set; }
|
||||
public int MusicVideoId { get; set; }
|
||||
public MusicVideo MusicVideo { get; set; }
|
||||
public List<MusicVideoArtist> Artists { get; set; }
|
||||
|
||||
@@ -22,6 +22,7 @@ public class PlayoutItem
|
||||
public string ChapterTitle { get; set; }
|
||||
public ChannelWatermark Watermark { get; set; }
|
||||
public int? WatermarkId { get; set; }
|
||||
public bool DisableWatermarks { get; set; }
|
||||
public string PreferredAudioLanguageCode { get; set; }
|
||||
public string PreferredSubtitleLanguageCode { get; set; }
|
||||
public ChannelSubtitleMode? SubtitleMode { get; set; }
|
||||
|
||||
@@ -7,6 +7,7 @@ public class ProgramSchedule
|
||||
public bool KeepMultiPartEpisodesTogether { get; set; }
|
||||
public bool TreatCollectionsAsShows { get; set; }
|
||||
public bool ShuffleScheduleItems { get; set; }
|
||||
public bool RandomStartPoint { get; set; }
|
||||
public List<ProgramScheduleItem> Items { get; set; }
|
||||
public List<Playout> Playouts { get; set; }
|
||||
}
|
||||
|
||||
@@ -10,13 +10,14 @@
|
||||
<PackageReference Include="Bugsnag" Version="3.0.1" />
|
||||
<PackageReference Include="Destructurama.Attributed" Version="3.0.0" />
|
||||
<PackageReference Include="Flurl" Version="3.0.5" />
|
||||
<PackageReference Include="LanguageExt.Core" Version="4.0.4" />
|
||||
<PackageReference Include="LanguageExt.Core" Version="4.1.0" />
|
||||
<PackageReference Include="MediatR" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="6.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="6.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="6.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="6.0.1" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.1.46">
|
||||
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="2.2.0" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.2.32">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
|
||||
@@ -59,7 +59,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
TimeSpan inPoint,
|
||||
TimeSpan outPoint,
|
||||
long ptsOffset,
|
||||
Option<int> targetFramerate)
|
||||
Option<int> targetFramerate,
|
||||
bool disableWatermarks)
|
||||
{
|
||||
MediaStream videoStream = await _ffmpegStreamSelector.SelectVideoStream(videoVersion);
|
||||
Option<MediaStream> maybeAudioStream =
|
||||
@@ -90,8 +91,9 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
hlsRealtime,
|
||||
targetFramerate);
|
||||
|
||||
Option<WatermarkOptions> watermarkOptions =
|
||||
await _ffmpegProcessService.GetWatermarkOptions(
|
||||
Option<WatermarkOptions> watermarkOptions = disableWatermarks
|
||||
? None
|
||||
: await _ffmpegProcessService.GetWatermarkOptions(
|
||||
ffprobePath,
|
||||
channel,
|
||||
playoutItemWatermark,
|
||||
|
||||
@@ -33,7 +33,8 @@ public interface IFFmpegProcessService
|
||||
TimeSpan inPoint,
|
||||
TimeSpan outPoint,
|
||||
long ptsOffset,
|
||||
Option<int> targetFramerate);
|
||||
Option<int> targetFramerate,
|
||||
bool disableWatermarks);
|
||||
|
||||
Task<Command> ForError(
|
||||
string ffmpegPath,
|
||||
|
||||
@@ -4,5 +4,5 @@ namespace ErsatzTV.Core.Interfaces.Metadata.Nfo;
|
||||
|
||||
public interface IArtistNfoReader
|
||||
{
|
||||
Task<Either<BaseError, ArtistNfo>> Read(Stream input);
|
||||
Task<Either<BaseError, ArtistNfo>> ReadFromFile(string fileName);
|
||||
}
|
||||
|
||||
@@ -4,5 +4,5 @@ namespace ErsatzTV.Core.Interfaces.Metadata.Nfo;
|
||||
|
||||
public interface IEpisodeNfoReader
|
||||
{
|
||||
Task<Either<BaseError, List<TvShowEpisodeNfo>>> Read(Stream input);
|
||||
Task<Either<BaseError, List<TvShowEpisodeNfo>>> ReadFromFile(string fileName);
|
||||
}
|
||||
|
||||
@@ -4,5 +4,5 @@ namespace ErsatzTV.Core.Interfaces.Metadata.Nfo;
|
||||
|
||||
public interface IMovieNfoReader
|
||||
{
|
||||
Task<Either<BaseError, MovieNfo>> Read(Stream input);
|
||||
Task<Either<BaseError, MovieNfo>> ReadFromFile(string fileName);
|
||||
}
|
||||
|
||||
@@ -4,5 +4,5 @@ namespace ErsatzTV.Core.Interfaces.Metadata.Nfo;
|
||||
|
||||
public interface IMusicVideoNfoReader
|
||||
{
|
||||
Task<Either<BaseError, MusicVideoNfo>> Read(Stream input);
|
||||
Task<Either<BaseError, MusicVideoNfo>> ReadFromFile(string fileName);
|
||||
}
|
||||
|
||||
@@ -4,5 +4,5 @@ namespace ErsatzTV.Core.Interfaces.Metadata.Nfo;
|
||||
|
||||
public interface IOtherVideoNfoReader
|
||||
{
|
||||
Task<Either<BaseError, OtherVideoNfo>> Read(Stream input);
|
||||
Task<Either<BaseError, OtherVideoNfo>> ReadFromFile(string fileName);
|
||||
}
|
||||
|
||||
@@ -4,5 +4,5 @@ namespace ErsatzTV.Core.Interfaces.Metadata.Nfo;
|
||||
|
||||
public interface ITvShowNfoReader
|
||||
{
|
||||
Task<Either<BaseError, TvShowNfo>> Read(Stream input);
|
||||
Task<Either<BaseError, TvShowNfo>> ReadFromFile(string fileName);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.Emby;
|
||||
using ErsatzTV.Core.Jellyfin;
|
||||
using Microsoft.IO;
|
||||
using Serilog;
|
||||
|
||||
namespace ErsatzTV.Core.Iptv;
|
||||
@@ -12,10 +13,16 @@ public class ChannelGuide
|
||||
{
|
||||
private readonly List<Channel> _channels;
|
||||
private readonly string _host;
|
||||
private readonly RecyclableMemoryStreamManager _recyclableMemoryStreamManager;
|
||||
private readonly string _scheme;
|
||||
|
||||
public ChannelGuide(string scheme, string host, List<Channel> channels)
|
||||
public ChannelGuide(
|
||||
RecyclableMemoryStreamManager recyclableMemoryStreamManager,
|
||||
string scheme,
|
||||
string host,
|
||||
List<Channel> channels)
|
||||
{
|
||||
_recyclableMemoryStreamManager = recyclableMemoryStreamManager;
|
||||
_scheme = scheme;
|
||||
_host = host;
|
||||
_channels = channels;
|
||||
@@ -23,7 +30,7 @@ public class ChannelGuide
|
||||
|
||||
public string ToXml()
|
||||
{
|
||||
using var ms = new MemoryStream();
|
||||
using MemoryStream ms = _recyclableMemoryStreamManager.GetStream();
|
||||
using var xml = XmlWriter.Create(ms);
|
||||
xml.WriteStartDocument();
|
||||
|
||||
|
||||
@@ -231,8 +231,7 @@ public class LocalMetadataProvider : ILocalMetadataProvider
|
||||
{
|
||||
try
|
||||
{
|
||||
await using FileStream fileStream = File.Open(nfoFileName, FileMode.Open, FileAccess.Read);
|
||||
Either<BaseError, MusicVideoNfo> maybeNfo = await _musicVideoNfoReader.Read(fileStream);
|
||||
Either<BaseError, MusicVideoNfo> maybeNfo = await _musicVideoNfoReader.ReadFromFile(nfoFileName);
|
||||
foreach (BaseError error in maybeNfo.LeftToSeq())
|
||||
{
|
||||
_logger.LogInformation(
|
||||
@@ -251,6 +250,7 @@ public class LocalMetadataProvider : ILocalMetadataProvider
|
||||
Album = nfo.Album,
|
||||
Title = nfo.Title,
|
||||
Plot = nfo.Plot,
|
||||
Track = nfo.Track,
|
||||
Year = GetYear(nfo.Year, nfo.Aired),
|
||||
ReleaseDate = GetAired(nfo.Year, nfo.Aired),
|
||||
Artists = nfo.Artists.Map(a => new MusicVideoArtist { Name = a }).ToList(),
|
||||
@@ -765,6 +765,7 @@ public class LocalMetadataProvider : ILocalMetadataProvider
|
||||
existing.Title = metadata.Title;
|
||||
existing.Year = metadata.Year;
|
||||
existing.Plot = metadata.Plot;
|
||||
existing.Track = metadata.Track;
|
||||
existing.Album = metadata.Album;
|
||||
|
||||
if (existing.DateAdded == SystemTime.MinValueUtc)
|
||||
@@ -976,8 +977,7 @@ public class LocalMetadataProvider : ILocalMetadataProvider
|
||||
{
|
||||
try
|
||||
{
|
||||
await using FileStream fileStream = File.Open(nfoFileName, FileMode.Open, FileAccess.Read);
|
||||
Either<BaseError, TvShowNfo> maybeNfo = await _tvShowNfoReader.Read(fileStream);
|
||||
Either<BaseError, TvShowNfo> maybeNfo = await _tvShowNfoReader.ReadFromFile(nfoFileName);
|
||||
foreach (BaseError error in maybeNfo.LeftToSeq())
|
||||
{
|
||||
_logger.LogInformation(
|
||||
@@ -1027,8 +1027,7 @@ public class LocalMetadataProvider : ILocalMetadataProvider
|
||||
{
|
||||
try
|
||||
{
|
||||
await using FileStream fileStream = File.Open(nfoFileName, FileMode.Open, FileAccess.Read);
|
||||
Either<BaseError, ArtistNfo> maybeNfo = await _artistNfoReader.Read(fileStream);
|
||||
Either<BaseError, ArtistNfo> maybeNfo = await _artistNfoReader.ReadFromFile(nfoFileName);
|
||||
foreach (BaseError error in maybeNfo.LeftToSeq())
|
||||
{
|
||||
_logger.LogInformation(
|
||||
@@ -1067,8 +1066,7 @@ public class LocalMetadataProvider : ILocalMetadataProvider
|
||||
{
|
||||
try
|
||||
{
|
||||
await using FileStream fileStream = File.Open(nfoFileName, FileMode.Open, FileAccess.Read);
|
||||
Either<BaseError, List<TvShowEpisodeNfo>> maybeNfo = await _episodeNfoReader.Read(fileStream);
|
||||
Either<BaseError, List<TvShowEpisodeNfo>> maybeNfo = await _episodeNfoReader.ReadFromFile(nfoFileName);
|
||||
foreach (BaseError error in maybeNfo.LeftToSeq())
|
||||
{
|
||||
_logger.LogInformation(
|
||||
@@ -1123,8 +1121,7 @@ public class LocalMetadataProvider : ILocalMetadataProvider
|
||||
{
|
||||
try
|
||||
{
|
||||
await using FileStream fileStream = File.Open(nfoFileName, FileMode.Open, FileAccess.Read);
|
||||
Either<BaseError, MovieNfo> maybeNfo = await _movieNfoReader.Read(fileStream);
|
||||
Either<BaseError, MovieNfo> maybeNfo = await _movieNfoReader.ReadFromFile(nfoFileName);
|
||||
foreach (BaseError error in maybeNfo.LeftToSeq())
|
||||
{
|
||||
_logger.LogInformation(
|
||||
@@ -1199,8 +1196,7 @@ public class LocalMetadataProvider : ILocalMetadataProvider
|
||||
{
|
||||
try
|
||||
{
|
||||
await using FileStream fileStream = File.Open(nfoFileName, FileMode.Open, FileAccess.Read);
|
||||
Either<BaseError, OtherVideoNfo> maybeNfo = await _otherVideoNfoReader.Read(fileStream);
|
||||
Either<BaseError, OtherVideoNfo> maybeNfo = await _otherVideoNfoReader.ReadFromFile(nfoFileName);
|
||||
foreach (BaseError error in maybeNfo.LeftToSeq())
|
||||
{
|
||||
_logger.LogInformation(
|
||||
|
||||
@@ -2,22 +2,43 @@
|
||||
using Bugsnag;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Metadata.Nfo;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.IO;
|
||||
|
||||
namespace ErsatzTV.Core.Metadata.Nfo;
|
||||
|
||||
public class ArtistNfoReader : NfoReader<ArtistNfo>, IArtistNfoReader
|
||||
{
|
||||
private readonly IClient _client;
|
||||
private readonly ILogger<ArtistNfoReader> _logger;
|
||||
|
||||
public ArtistNfoReader(IClient client) => _client = client;
|
||||
|
||||
public async Task<Either<BaseError, ArtistNfo>> Read(Stream input)
|
||||
public ArtistNfoReader(
|
||||
RecyclableMemoryStreamManager recyclableMemoryStreamManager,
|
||||
IClient client,
|
||||
ILogger<ArtistNfoReader> logger)
|
||||
: base(recyclableMemoryStreamManager, logger)
|
||||
{
|
||||
_client = client;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, ArtistNfo>> ReadFromFile(string fileName)
|
||||
{
|
||||
// ReSharper disable once ConvertToUsingDeclaration
|
||||
await using (Stream s = await SanitizedStreamForFile(fileName))
|
||||
{
|
||||
return await Read(s);
|
||||
}
|
||||
}
|
||||
|
||||
internal async Task<Either<BaseError, ArtistNfo>> Read(Stream input)
|
||||
{
|
||||
ArtistNfo nfo = null;
|
||||
|
||||
try
|
||||
{
|
||||
var settings = new XmlReaderSettings { Async = true, ConformanceLevel = ConformanceLevel.Fragment };
|
||||
using var reader = XmlReader.Create(input, settings);
|
||||
ArtistNfo nfo = null;
|
||||
var done = false;
|
||||
|
||||
while (!done && await reader.ReadAsync())
|
||||
@@ -74,6 +95,11 @@ public class ArtistNfoReader : NfoReader<ArtistNfo>, IArtistNfoReader
|
||||
|
||||
return Optional(nfo).ToEither((BaseError)new FailedToReadNfo());
|
||||
}
|
||||
catch (XmlException)
|
||||
{
|
||||
_logger.LogWarning("Invalid XML detected; returning incomplete metadata");
|
||||
return Optional(nfo).ToEither((BaseError)new FailedToReadNfo());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_client.Notify(ex);
|
||||
|
||||
@@ -2,23 +2,42 @@
|
||||
using Bugsnag;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Metadata.Nfo;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.IO;
|
||||
|
||||
namespace ErsatzTV.Core.Metadata.Nfo;
|
||||
|
||||
public class EpisodeNfoReader : NfoReader<TvShowEpisodeNfo>, IEpisodeNfoReader
|
||||
{
|
||||
private readonly IClient _client;
|
||||
private readonly ILogger<EpisodeNfoReader> _logger;
|
||||
|
||||
public EpisodeNfoReader(IClient client) => _client = client;
|
||||
|
||||
public async Task<Either<BaseError, List<TvShowEpisodeNfo>>> Read(Stream input)
|
||||
public EpisodeNfoReader(
|
||||
RecyclableMemoryStreamManager recyclableMemoryStreamManager,
|
||||
IClient client,
|
||||
ILogger<EpisodeNfoReader> logger)
|
||||
: base(recyclableMemoryStreamManager, logger)
|
||||
{
|
||||
_client = client;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, List<TvShowEpisodeNfo>>> ReadFromFile(string fileName)
|
||||
{
|
||||
// ReSharper disable once ConvertToUsingDeclaration
|
||||
await using (Stream s = await SanitizedStreamForFile(fileName))
|
||||
{
|
||||
return await Read(s);
|
||||
}
|
||||
}
|
||||
|
||||
internal async Task<Either<BaseError, List<TvShowEpisodeNfo>>> Read(Stream input)
|
||||
{
|
||||
var result = new List<TvShowEpisodeNfo>();
|
||||
|
||||
try
|
||||
{
|
||||
var result = new List<TvShowEpisodeNfo>();
|
||||
|
||||
var settings = new XmlReaderSettings { Async = true, ConformanceLevel = ConformanceLevel.Fragment };
|
||||
using var reader = XmlReader.Create(input, settings);
|
||||
using var reader = XmlReader.Create(input, Settings);
|
||||
TvShowEpisodeNfo nfo = null;
|
||||
|
||||
while (await reader.ReadAsync())
|
||||
@@ -36,6 +55,8 @@ public class EpisodeNfoReader : NfoReader<TvShowEpisodeNfo>, IEpisodeNfoReader
|
||||
Writers = new List<string>(),
|
||||
Directors = new List<string>()
|
||||
};
|
||||
// immediately add so we have something to return if we encounter invalid characters
|
||||
result.Add(nfo);
|
||||
break;
|
||||
case "title":
|
||||
await ReadStringContent(reader, nfo, (episode, title) => episode.Title = title);
|
||||
@@ -87,25 +108,17 @@ public class EpisodeNfoReader : NfoReader<TvShowEpisodeNfo>, IEpisodeNfoReader
|
||||
break;
|
||||
}
|
||||
|
||||
break;
|
||||
case XmlNodeType.EndElement:
|
||||
switch (reader.Name.ToLowerInvariant())
|
||||
{
|
||||
case "episodedetails":
|
||||
if (nfo != null)
|
||||
{
|
||||
result.Add(nfo);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (XmlException)
|
||||
{
|
||||
_logger.LogWarning("Invalid XML detected; returning incomplete metadata");
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_client.Notify(ex);
|
||||
|
||||
@@ -2,22 +2,43 @@
|
||||
using Bugsnag;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Metadata.Nfo;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.IO;
|
||||
|
||||
namespace ErsatzTV.Core.Metadata.Nfo;
|
||||
|
||||
public class MovieNfoReader : NfoReader<MovieNfo>, IMovieNfoReader
|
||||
{
|
||||
private readonly IClient _client;
|
||||
private readonly ILogger<MovieNfoReader> _logger;
|
||||
|
||||
public MovieNfoReader(IClient client) => _client = client;
|
||||
|
||||
public async Task<Either<BaseError, MovieNfo>> Read(Stream input)
|
||||
public MovieNfoReader(
|
||||
RecyclableMemoryStreamManager recyclableMemoryStreamManager,
|
||||
IClient client,
|
||||
ILogger<MovieNfoReader> logger)
|
||||
: base(recyclableMemoryStreamManager, logger)
|
||||
{
|
||||
_client = client;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, MovieNfo>> ReadFromFile(string fileName)
|
||||
{
|
||||
// ReSharper disable once ConvertToUsingDeclaration
|
||||
await using (Stream s = await SanitizedStreamForFile(fileName))
|
||||
{
|
||||
return await Read(s);
|
||||
}
|
||||
}
|
||||
|
||||
internal async Task<Either<BaseError, MovieNfo>> Read(Stream input)
|
||||
{
|
||||
MovieNfo nfo = null;
|
||||
|
||||
try
|
||||
{
|
||||
var settings = new XmlReaderSettings { Async = true, ConformanceLevel = ConformanceLevel.Fragment };
|
||||
using var reader = XmlReader.Create(input, settings);
|
||||
MovieNfo nfo = null;
|
||||
var done = false;
|
||||
|
||||
while (!done && await reader.ReadAsync())
|
||||
@@ -105,6 +126,11 @@ public class MovieNfoReader : NfoReader<MovieNfo>, IMovieNfoReader
|
||||
|
||||
return Optional(nfo).ToEither((BaseError)new FailedToReadNfo());
|
||||
}
|
||||
catch (XmlException)
|
||||
{
|
||||
_logger.LogWarning("Invalid XML detected; returning incomplete metadata");
|
||||
return Optional(nfo).ToEither((BaseError)new FailedToReadNfo());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_client.Notify(ex);
|
||||
|
||||
@@ -17,6 +17,9 @@ public class MusicVideoNfo
|
||||
[XmlElement("plot")]
|
||||
public string Plot { get; set; }
|
||||
|
||||
[XmlElement("track")]
|
||||
public int Track { get; set; }
|
||||
|
||||
[XmlElement("aired")]
|
||||
public Option<DateTime> Aired { get; set; }
|
||||
|
||||
|
||||
@@ -2,22 +2,43 @@
|
||||
using Bugsnag;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Metadata.Nfo;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.IO;
|
||||
|
||||
namespace ErsatzTV.Core.Metadata.Nfo;
|
||||
|
||||
public class MusicVideoNfoReader : NfoReader<MusicVideoNfo>, IMusicVideoNfoReader
|
||||
{
|
||||
private readonly IClient _client;
|
||||
private readonly ILogger<MusicVideoNfoReader> _logger;
|
||||
|
||||
public MusicVideoNfoReader(IClient client) => _client = client;
|
||||
|
||||
public async Task<Either<BaseError, MusicVideoNfo>> Read(Stream input)
|
||||
public MusicVideoNfoReader(
|
||||
RecyclableMemoryStreamManager recyclableMemoryStreamManager,
|
||||
IClient client,
|
||||
ILogger<MusicVideoNfoReader> logger)
|
||||
: base(recyclableMemoryStreamManager, logger)
|
||||
{
|
||||
_client = client;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, MusicVideoNfo>> ReadFromFile(string fileName)
|
||||
{
|
||||
// ReSharper disable once ConvertToUsingDeclaration
|
||||
await using (Stream s = await SanitizedStreamForFile(fileName))
|
||||
{
|
||||
return await Read(s);
|
||||
}
|
||||
}
|
||||
|
||||
internal async Task<Either<BaseError, MusicVideoNfo>> Read(Stream input)
|
||||
{
|
||||
MusicVideoNfo nfo = null;
|
||||
|
||||
try
|
||||
{
|
||||
var settings = new XmlReaderSettings { Async = true, ConformanceLevel = ConformanceLevel.Fragment };
|
||||
using var reader = XmlReader.Create(input, settings);
|
||||
MusicVideoNfo nfo = null;
|
||||
var done = false;
|
||||
|
||||
while (!done && await reader.ReadAsync())
|
||||
@@ -51,6 +72,9 @@ public class MusicVideoNfoReader : NfoReader<MusicVideoNfo>, IMusicVideoNfoReade
|
||||
case "plot":
|
||||
await ReadStringContent(reader, nfo, (musicVideo, plot) => musicVideo.Plot = plot);
|
||||
break;
|
||||
case "track":
|
||||
await ReadIntContent(reader, nfo, (musicVideo, track) => musicVideo.Track = track);
|
||||
break;
|
||||
case "year":
|
||||
await ReadIntContent(reader, nfo, (musicVideo, year) => musicVideo.Year = year);
|
||||
break;
|
||||
@@ -87,6 +111,11 @@ public class MusicVideoNfoReader : NfoReader<MusicVideoNfo>, IMusicVideoNfoReade
|
||||
|
||||
return Optional(nfo).ToEither((BaseError)new FailedToReadNfo());
|
||||
}
|
||||
catch (XmlException)
|
||||
{
|
||||
_logger.LogWarning("Invalid XML detected; returning incomplete metadata");
|
||||
return Optional(nfo).ToEither((BaseError)new FailedToReadNfo());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_client.Notify(ex);
|
||||
|
||||
@@ -1,83 +1,166 @@
|
||||
using System.Xml;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Xml;
|
||||
using System.Xml.Linq;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.IO;
|
||||
|
||||
namespace ErsatzTV.Core.Metadata.Nfo;
|
||||
|
||||
public abstract class NfoReader<T>
|
||||
{
|
||||
protected static async Task ReadStringContent(XmlReader reader, T nfo, Action<T, string> action)
|
||||
{
|
||||
if (nfo != null)
|
||||
private static readonly byte[] Buffer = new byte[8 * 1024 * 1024];
|
||||
private static readonly Regex Pattern = new(@"[\p{C}-[\r\n\t]]+");
|
||||
|
||||
protected static readonly XmlReaderSettings Settings =
|
||||
new()
|
||||
{
|
||||
string result = await reader.ReadElementContentAsStringAsync();
|
||||
action(nfo, result);
|
||||
Async = true,
|
||||
ConformanceLevel = ConformanceLevel.Fragment,
|
||||
ValidationType = ValidationType.None,
|
||||
CheckCharacters = false,
|
||||
IgnoreProcessingInstructions = true,
|
||||
IgnoreComments = true
|
||||
};
|
||||
|
||||
private readonly ILogger _logger;
|
||||
|
||||
private readonly RecyclableMemoryStreamManager _recyclableMemoryStreamManager;
|
||||
|
||||
protected NfoReader(RecyclableMemoryStreamManager recyclableMemoryStreamManager, ILogger logger)
|
||||
{
|
||||
_recyclableMemoryStreamManager = recyclableMemoryStreamManager;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected async Task<Stream> SanitizedStreamForFile(string fileName)
|
||||
{
|
||||
using (var fs = new FileStream(fileName, FileMode.Open, FileAccess.Read, FileShare.Read, Buffer.Length, true))
|
||||
{
|
||||
while (await fs.ReadAsync(Buffer) > 0)
|
||||
{
|
||||
// read the file
|
||||
}
|
||||
|
||||
string text = Encoding.UTF8.GetString(Buffer);
|
||||
// trim BOM and zero width space, replace controls with replacement character
|
||||
string stripped = Pattern.Replace(text.Trim('\uFEFF', '\u200B'), "\ufffd");
|
||||
|
||||
MemoryStream ms = _recyclableMemoryStreamManager.GetStream();
|
||||
await ms.WriteAsync(Encoding.UTF8.GetBytes(stripped));
|
||||
ms.Position = 0;
|
||||
return ms;
|
||||
}
|
||||
}
|
||||
|
||||
protected static async Task ReadIntContent(XmlReader reader, T nfo, Action<T, int> action)
|
||||
protected async Task ReadStringContent(XmlReader reader, T nfo, Action<T, string> action)
|
||||
{
|
||||
if (nfo != null && int.TryParse(await reader.ReadElementContentAsStringAsync(), out int result))
|
||||
try
|
||||
{
|
||||
action(nfo, result);
|
||||
if (nfo != null)
|
||||
{
|
||||
string result = await reader.ReadElementContentAsStringAsync();
|
||||
action(nfo, result);
|
||||
}
|
||||
}
|
||||
catch (XmlException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Error reading string content from NFO {ElementName}", reader.Name);
|
||||
}
|
||||
}
|
||||
|
||||
protected static async Task ReadDateTimeContent(XmlReader reader, T nfo, Action<T, DateTime> action)
|
||||
protected async Task ReadIntContent(XmlReader reader, T nfo, Action<T, int> action)
|
||||
{
|
||||
if (nfo != null && DateTime.TryParse(await reader.ReadElementContentAsStringAsync(), out DateTime result))
|
||||
try
|
||||
{
|
||||
action(nfo, result);
|
||||
if (nfo != null && int.TryParse(await reader.ReadElementContentAsStringAsync(), out int result))
|
||||
{
|
||||
action(nfo, result);
|
||||
}
|
||||
}
|
||||
catch (XmlException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Error reading int content from NFO {ElementName}", reader.Name);
|
||||
}
|
||||
}
|
||||
|
||||
protected static void ReadActor(XmlReader reader, T nfo, Action<T, ActorNfo> action)
|
||||
protected async Task ReadDateTimeContent(XmlReader reader, T nfo, Action<T, DateTime> action)
|
||||
{
|
||||
if (nfo != null)
|
||||
try
|
||||
{
|
||||
var actor = new ActorNfo();
|
||||
var element = (XElement)XNode.ReadFrom(reader);
|
||||
|
||||
XElement name = element.Element("name");
|
||||
if (name != null)
|
||||
if (nfo != null && DateTime.TryParse(await reader.ReadElementContentAsStringAsync(), out DateTime result))
|
||||
{
|
||||
actor.Name = name.Value;
|
||||
action(nfo, result);
|
||||
}
|
||||
|
||||
XElement role = element.Element("role");
|
||||
if (role != null)
|
||||
{
|
||||
actor.Role = role.Value;
|
||||
}
|
||||
|
||||
XElement order = element.Element("order");
|
||||
if (order != null && int.TryParse(order.Value, out int orderValue))
|
||||
{
|
||||
actor.Order = orderValue;
|
||||
}
|
||||
|
||||
XElement thumb = element.Element("thumb");
|
||||
if (thumb != null)
|
||||
{
|
||||
actor.Thumb = thumb.Value;
|
||||
}
|
||||
|
||||
action(nfo, actor);
|
||||
}
|
||||
catch (XmlException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Error reading date content from NFO {ElementName}", reader.Name);
|
||||
}
|
||||
}
|
||||
|
||||
protected static async Task ReadUniqueId(XmlReader reader, T nfo, Action<T, UniqueIdNfo> action)
|
||||
protected void ReadActor(XmlReader reader, T nfo, Action<T, ActorNfo> action)
|
||||
{
|
||||
if (nfo != null)
|
||||
try
|
||||
{
|
||||
var uniqueId = new UniqueIdNfo();
|
||||
reader.MoveToAttribute("default");
|
||||
uniqueId.Default = bool.TryParse(reader.Value, out bool def) && def;
|
||||
reader.MoveToAttribute("type");
|
||||
uniqueId.Type = reader.Value;
|
||||
reader.MoveToElement();
|
||||
uniqueId.Guid = await reader.ReadElementContentAsStringAsync();
|
||||
if (nfo != null)
|
||||
{
|
||||
var actor = new ActorNfo();
|
||||
var element = (XElement)XNode.ReadFrom(reader);
|
||||
|
||||
action(nfo, uniqueId);
|
||||
XElement name = element.Element("name");
|
||||
if (name != null)
|
||||
{
|
||||
actor.Name = name.Value;
|
||||
}
|
||||
|
||||
XElement role = element.Element("role");
|
||||
if (role != null)
|
||||
{
|
||||
actor.Role = role.Value;
|
||||
}
|
||||
|
||||
XElement order = element.Element("order");
|
||||
if (order != null && int.TryParse(order.Value, out int orderValue))
|
||||
{
|
||||
actor.Order = orderValue;
|
||||
}
|
||||
|
||||
XElement thumb = element.Element("thumb");
|
||||
if (thumb != null)
|
||||
{
|
||||
actor.Thumb = thumb.Value;
|
||||
}
|
||||
|
||||
action(nfo, actor);
|
||||
}
|
||||
}
|
||||
catch (XmlException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Error reading actor content from NFO {ElementName}", reader.Name);
|
||||
}
|
||||
}
|
||||
|
||||
protected async Task ReadUniqueId(XmlReader reader, T nfo, Action<T, UniqueIdNfo> action)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (nfo != null)
|
||||
{
|
||||
var uniqueId = new UniqueIdNfo();
|
||||
reader.MoveToAttribute("default");
|
||||
uniqueId.Default = bool.TryParse(reader.Value, out bool def) && def;
|
||||
reader.MoveToAttribute("type");
|
||||
uniqueId.Type = reader.Value;
|
||||
reader.MoveToElement();
|
||||
uniqueId.Guid = await reader.ReadElementContentAsStringAsync();
|
||||
|
||||
action(nfo, uniqueId);
|
||||
}
|
||||
}
|
||||
catch (XmlException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Error reading uniqueid content from NFO {ElementName}", reader.Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,22 +2,43 @@
|
||||
using Bugsnag;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Metadata.Nfo;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.IO;
|
||||
|
||||
namespace ErsatzTV.Core.Metadata.Nfo;
|
||||
|
||||
public class OtherVideoNfoReader : NfoReader<OtherVideoNfo>, IOtherVideoNfoReader
|
||||
{
|
||||
private readonly IClient _client;
|
||||
private readonly ILogger<OtherVideoNfoReader> _logger;
|
||||
|
||||
public OtherVideoNfoReader(IClient client) => _client = client;
|
||||
|
||||
public async Task<Either<BaseError, OtherVideoNfo>> Read(Stream input)
|
||||
public OtherVideoNfoReader(
|
||||
RecyclableMemoryStreamManager recyclableMemoryStreamManager,
|
||||
IClient client,
|
||||
ILogger<OtherVideoNfoReader> logger)
|
||||
: base(recyclableMemoryStreamManager, logger)
|
||||
{
|
||||
_client = client;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, OtherVideoNfo>> ReadFromFile(string fileName)
|
||||
{
|
||||
// ReSharper disable once ConvertToUsingDeclaration
|
||||
await using (Stream s = await SanitizedStreamForFile(fileName))
|
||||
{
|
||||
return await Read(s);
|
||||
}
|
||||
}
|
||||
|
||||
internal async Task<Either<BaseError, OtherVideoNfo>> Read(Stream input)
|
||||
{
|
||||
OtherVideoNfo nfo = null;
|
||||
|
||||
try
|
||||
{
|
||||
var settings = new XmlReaderSettings { Async = true, ConformanceLevel = ConformanceLevel.Fragment };
|
||||
using var reader = XmlReader.Create(input, settings);
|
||||
OtherVideoNfo nfo = null;
|
||||
var done = false;
|
||||
|
||||
while (!done && await reader.ReadAsync())
|
||||
@@ -105,6 +126,11 @@ public class OtherVideoNfoReader : NfoReader<OtherVideoNfo>, IOtherVideoNfoReade
|
||||
|
||||
return Optional(nfo).ToEither((BaseError)new FailedToReadNfo());
|
||||
}
|
||||
catch (XmlException)
|
||||
{
|
||||
_logger.LogWarning("Invalid XML detected; returning incomplete metadata");
|
||||
return Optional(nfo).ToEither((BaseError)new FailedToReadNfo());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_client.Notify(ex);
|
||||
|
||||
@@ -2,22 +2,43 @@
|
||||
using Bugsnag;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Metadata.Nfo;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.IO;
|
||||
|
||||
namespace ErsatzTV.Core.Metadata.Nfo;
|
||||
|
||||
public class TvShowNfoReader : NfoReader<TvShowNfo>, ITvShowNfoReader
|
||||
{
|
||||
private readonly IClient _client;
|
||||
private readonly ILogger<TvShowNfoReader> _logger;
|
||||
|
||||
public TvShowNfoReader(IClient client) => _client = client;
|
||||
|
||||
public async Task<Either<BaseError, TvShowNfo>> Read(Stream input)
|
||||
public TvShowNfoReader(
|
||||
RecyclableMemoryStreamManager recyclableMemoryStreamManager,
|
||||
IClient client,
|
||||
ILogger<TvShowNfoReader> logger)
|
||||
: base(recyclableMemoryStreamManager, logger)
|
||||
{
|
||||
_client = client;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, TvShowNfo>> ReadFromFile(string fileName)
|
||||
{
|
||||
// ReSharper disable once ConvertToUsingDeclaration
|
||||
await using (Stream s = await SanitizedStreamForFile(fileName))
|
||||
{
|
||||
return await Read(s);
|
||||
}
|
||||
}
|
||||
|
||||
internal async Task<Either<BaseError, TvShowNfo>> Read(Stream input)
|
||||
{
|
||||
TvShowNfo nfo = null;
|
||||
|
||||
try
|
||||
{
|
||||
var settings = new XmlReaderSettings { Async = true, ConformanceLevel = ConformanceLevel.Fragment };
|
||||
using var reader = XmlReader.Create(input, settings);
|
||||
TvShowNfo nfo = null;
|
||||
var done = false;
|
||||
|
||||
while (!done && await reader.ReadAsync())
|
||||
@@ -91,6 +112,11 @@ public class TvShowNfoReader : NfoReader<TvShowNfo>, ITvShowNfoReader
|
||||
|
||||
return Optional(nfo).ToEither((BaseError)new FailedToReadNfo());
|
||||
}
|
||||
catch (XmlException)
|
||||
{
|
||||
_logger.LogWarning("Invalid XML detected; returning incomplete metadata");
|
||||
return Optional(nfo).ToEither((BaseError)new FailedToReadNfo());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_client.Notify(ex);
|
||||
|
||||
@@ -119,12 +119,18 @@ internal class ChronologicalMediaComparer : IComparer<MediaItem>
|
||||
string track1 = x switch
|
||||
{
|
||||
Song s => s.SongMetadata.HeadOrNone().Match(sm => sm.Track ?? string.Empty, () => string.Empty),
|
||||
MusicVideo mv => mv.MusicVideoMetadata.HeadOrNone()
|
||||
.Match(mvm => mvm.Track ?? int.MaxValue, () => int.MaxValue)
|
||||
.ToString("D10"),
|
||||
_ => string.Empty
|
||||
};
|
||||
|
||||
string track2 = y switch
|
||||
{
|
||||
Song s => s.SongMetadata.HeadOrNone().Match(sm => sm.Track ?? string.Empty, () => string.Empty),
|
||||
MusicVideo mv => mv.MusicVideoMetadata.HeadOrNone()
|
||||
.Match(mvm => mvm.Track ?? int.MaxValue, () => int.MaxValue)
|
||||
.ToString("D10"),
|
||||
_ => string.Empty
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Extensions;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Scheduling;
|
||||
using LanguageExt.UnsafeValueAccess;
|
||||
@@ -174,7 +175,8 @@ public class PlayoutBuilder : IPlayoutBuilder
|
||||
playout,
|
||||
parameters.Start,
|
||||
parameters.Finish,
|
||||
parameters.CollectionMediaItems);
|
||||
parameters.CollectionMediaItems,
|
||||
false);
|
||||
}
|
||||
|
||||
private async Task<Playout> ResetPlayout(Playout playout, PlayoutParameters parameters)
|
||||
@@ -193,7 +195,8 @@ public class PlayoutBuilder : IPlayoutBuilder
|
||||
playout,
|
||||
parameters.Start,
|
||||
parameters.Finish,
|
||||
parameters.CollectionMediaItems);
|
||||
parameters.CollectionMediaItems,
|
||||
playout.ProgramSchedule.RandomStartPoint);
|
||||
|
||||
return playout;
|
||||
}
|
||||
@@ -216,7 +219,8 @@ public class PlayoutBuilder : IPlayoutBuilder
|
||||
playout,
|
||||
parameters.Start,
|
||||
parameters.Finish,
|
||||
parameters.CollectionMediaItems);
|
||||
parameters.CollectionMediaItems,
|
||||
false);
|
||||
|
||||
return playout;
|
||||
}
|
||||
@@ -234,7 +238,13 @@ public class PlayoutBuilder : IPlayoutBuilder
|
||||
return None;
|
||||
}
|
||||
|
||||
Option<CollectionKey> maybeEmptyCollection = await CheckForEmptyCollections(collectionMediaItems);
|
||||
Option<bool> skipMissingItems =
|
||||
await _configElementRepository.GetValue<bool>(ConfigElementKey.PlayoutSkipMissingItems);
|
||||
|
||||
Option<CollectionKey> maybeEmptyCollection = await CheckForEmptyCollections(
|
||||
collectionMediaItems,
|
||||
await skipMissingItems.IfNoneAsync(false));
|
||||
|
||||
foreach (CollectionKey emptyCollection in maybeEmptyCollection)
|
||||
{
|
||||
Option<string> maybeName = await _mediaCollectionRepository.GetNameFromKey(emptyCollection);
|
||||
@@ -275,7 +285,8 @@ public class PlayoutBuilder : IPlayoutBuilder
|
||||
Playout playout,
|
||||
DateTimeOffset playoutStart,
|
||||
DateTimeOffset playoutFinish,
|
||||
Map<CollectionKey, List<MediaItem>> collectionMediaItems)
|
||||
Map<CollectionKey, List<MediaItem>> collectionMediaItems,
|
||||
bool randomStartPoint)
|
||||
{
|
||||
DateTimeOffset trimBefore = playoutStart.AddHours(-4);
|
||||
DateTimeOffset trimAfter = playoutFinish;
|
||||
@@ -294,7 +305,10 @@ public class PlayoutBuilder : IPlayoutBuilder
|
||||
while (finish < playoutFinish)
|
||||
{
|
||||
_logger.LogDebug("Building playout from {Start} to {Finish}", start, finish);
|
||||
playout = await BuildPlayoutItems(playout, start, finish, collectionMediaItems, true);
|
||||
playout = await BuildPlayoutItems(playout, start, finish, collectionMediaItems, true, randomStartPoint);
|
||||
|
||||
// only randomize once (at the start of the playout)
|
||||
randomStartPoint = false;
|
||||
|
||||
start = playout.Anchor.NextStartOffset;
|
||||
finish = finish.AddDays(1);
|
||||
@@ -309,7 +323,8 @@ public class PlayoutBuilder : IPlayoutBuilder
|
||||
start,
|
||||
playoutFinish,
|
||||
collectionMediaItems,
|
||||
false);
|
||||
false,
|
||||
randomStartPoint);
|
||||
}
|
||||
|
||||
// remove any items outside the desired range
|
||||
@@ -323,7 +338,8 @@ public class PlayoutBuilder : IPlayoutBuilder
|
||||
DateTimeOffset playoutStart,
|
||||
DateTimeOffset playoutFinish,
|
||||
Map<CollectionKey, List<MediaItem>> collectionMediaItems,
|
||||
bool saveAnchorDate)
|
||||
bool saveAnchorDate,
|
||||
bool randomStartPoint)
|
||||
{
|
||||
var sortedScheduleItems = playout.ProgramSchedule.Items.OrderBy(i => i.Index).ToList();
|
||||
CollectionEnumeratorState scheduleItemsEnumeratorState =
|
||||
@@ -341,7 +357,7 @@ public class PlayoutBuilder : IPlayoutBuilder
|
||||
PlaybackOrder playbackOrder = maybeScheduleItem
|
||||
.Match(item => item.PlaybackOrder, () => PlaybackOrder.Shuffle);
|
||||
IMediaCollectionEnumerator enumerator =
|
||||
await GetMediaCollectionEnumerator(playout, collectionKey, mediaItems, playbackOrder);
|
||||
await GetMediaCollectionEnumerator(playout, collectionKey, mediaItems, playbackOrder, randomStartPoint);
|
||||
collectionEnumerators.Add(collectionKey, enumerator);
|
||||
}
|
||||
|
||||
@@ -496,12 +512,13 @@ public class PlayoutBuilder : IPlayoutBuilder
|
||||
}
|
||||
|
||||
private async Task<Option<CollectionKey>> CheckForEmptyCollections(
|
||||
Map<CollectionKey, List<MediaItem>> collectionMediaItems)
|
||||
Map<CollectionKey, List<MediaItem>> collectionMediaItems,
|
||||
bool skipMissingItems)
|
||||
{
|
||||
foreach ((CollectionKey _, List<MediaItem> items) in collectionMediaItems)
|
||||
{
|
||||
var zeroItems = new List<MediaItem>();
|
||||
// var missingItems = new List<MediaItem>();
|
||||
var missingItems = new List<MediaItem>();
|
||||
|
||||
foreach (MediaItem item in items)
|
||||
{
|
||||
@@ -520,18 +537,17 @@ public class PlayoutBuilder : IPlayoutBuilder
|
||||
_ => true
|
||||
};
|
||||
|
||||
// if (item.State == MediaItemState.FileNotFound)
|
||||
// {
|
||||
// _logger.LogWarning(
|
||||
// "Skipping media item that does not exist on disk {MediaItem} - {MediaItemTitle} - {Path}",
|
||||
// item.Id,
|
||||
// DisplayTitle(item),
|
||||
// item.GetHeadVersion().MediaFiles.Head().Path);
|
||||
//
|
||||
// missingItems.Add(item);
|
||||
// }
|
||||
// else
|
||||
if (isZero)
|
||||
if (skipMissingItems && item.State is MediaItemState.FileNotFound or MediaItemState.Unavailable)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Skipping media item that does not exist on disk {MediaItem} - {MediaItemTitle} - {Path}",
|
||||
item.Id,
|
||||
DisplayTitle(item),
|
||||
item.GetHeadVersion().MediaFiles.Head().Path);
|
||||
|
||||
missingItems.Add(item);
|
||||
}
|
||||
else if (isZero)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Skipping media item with zero duration {MediaItem} - {MediaItemTitle}",
|
||||
@@ -542,7 +558,7 @@ public class PlayoutBuilder : IPlayoutBuilder
|
||||
}
|
||||
}
|
||||
|
||||
// items.RemoveAll(missingItems.Contains);
|
||||
items.RemoveAll(missingItems.Contains);
|
||||
items.RemoveAll(zeroItems.Contains);
|
||||
}
|
||||
|
||||
@@ -636,7 +652,8 @@ public class PlayoutBuilder : IPlayoutBuilder
|
||||
Playout playout,
|
||||
CollectionKey collectionKey,
|
||||
List<MediaItem> mediaItems,
|
||||
PlaybackOrder playbackOrder)
|
||||
PlaybackOrder playbackOrder,
|
||||
bool randomStartPoint)
|
||||
{
|
||||
Option<PlayoutProgramScheduleAnchor> maybeAnchor = playout.ProgramScheduleAnchors
|
||||
.OrderByDescending(a => a.AnchorDate is null)
|
||||
@@ -674,9 +691,21 @@ public class PlayoutBuilder : IPlayoutBuilder
|
||||
}
|
||||
}
|
||||
|
||||
// index shouldn't ever be greater than zero with randomStartPoint since anchors shouldn't exist, but
|
||||
randomStartPoint = randomStartPoint && state.Index == 0;
|
||||
|
||||
switch (playbackOrder)
|
||||
{
|
||||
case PlaybackOrder.Chronological:
|
||||
if (randomStartPoint)
|
||||
{
|
||||
state = new CollectionEnumeratorState
|
||||
{
|
||||
Seed = state.Seed,
|
||||
Index = Random.Next(0, mediaItems.Count - 1)
|
||||
};
|
||||
}
|
||||
|
||||
return new ChronologicalMediaCollectionEnumerator(mediaItems, state);
|
||||
case PlaybackOrder.Random:
|
||||
return new RandomizedMediaCollectionEnumerator(mediaItems, state);
|
||||
@@ -687,7 +716,8 @@ public class PlayoutBuilder : IPlayoutBuilder
|
||||
case PlaybackOrder.ShuffleInOrder:
|
||||
return new ShuffleInOrderCollectionEnumerator(
|
||||
await GetCollectionItemsForShuffleInOrder(collectionKey),
|
||||
state);
|
||||
state,
|
||||
playout.ProgramSchedule.RandomStartPoint);
|
||||
default:
|
||||
// TODO: handle this error case differently?
|
||||
return new RandomizedMediaCollectionEnumerator(mediaItems, state);
|
||||
|
||||
@@ -93,7 +93,8 @@ public abstract class PlayoutModeSchedulerBase<T> : IPlayoutModeScheduler<T> whe
|
||||
InPoint = TimeSpan.Zero,
|
||||
OutPoint = itemDuration,
|
||||
FillerKind = FillerKind.Tail,
|
||||
GuideGroup = nextState.NextGuideGroup
|
||||
GuideGroup = nextState.NextGuideGroup,
|
||||
DisableWatermarks = !scheduleItem.TailFiller.AllowWatermarks
|
||||
};
|
||||
|
||||
newItems.Add(playoutItem);
|
||||
@@ -135,7 +136,8 @@ public abstract class PlayoutModeSchedulerBase<T> : IPlayoutModeScheduler<T> whe
|
||||
InPoint = TimeSpan.Zero,
|
||||
OutPoint = TimeSpan.Zero,
|
||||
GuideGroup = nextState.NextGuideGroup,
|
||||
FillerKind = FillerKind.Fallback
|
||||
FillerKind = FillerKind.Fallback,
|
||||
DisableWatermarks = !scheduleItem.FallbackFiller.AllowWatermarks
|
||||
};
|
||||
|
||||
newItems.Add(playoutItem);
|
||||
@@ -341,12 +343,22 @@ public abstract class PlayoutModeSchedulerBase<T> : IPlayoutModeScheduler<T> whe
|
||||
case FillerMode.Duration when filler.Duration.HasValue:
|
||||
IMediaCollectionEnumerator e1 = enumerators[CollectionKey.ForFillerPreset(filler)];
|
||||
result.AddRange(
|
||||
AddDurationFiller(playoutBuilderState, e1, filler.Duration.Value, FillerKind.PreRoll));
|
||||
AddDurationFiller(
|
||||
playoutBuilderState,
|
||||
e1,
|
||||
filler.Duration.Value,
|
||||
FillerKind.PreRoll,
|
||||
filler.AllowWatermarks));
|
||||
break;
|
||||
case FillerMode.Count when filler.Count.HasValue:
|
||||
IMediaCollectionEnumerator e2 = enumerators[CollectionKey.ForFillerPreset(filler)];
|
||||
result.AddRange(
|
||||
AddCountFiller(playoutBuilderState, e2, filler.Count.Value, FillerKind.PreRoll));
|
||||
AddCountFiller(
|
||||
playoutBuilderState,
|
||||
e2,
|
||||
filler.Count.Value,
|
||||
FillerKind.PreRoll,
|
||||
filler.AllowWatermarks));
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -374,7 +386,8 @@ public abstract class PlayoutModeSchedulerBase<T> : IPlayoutModeScheduler<T> whe
|
||||
playoutBuilderState,
|
||||
e1,
|
||||
filler.Duration.Value,
|
||||
FillerKind.MidRoll));
|
||||
FillerKind.MidRoll,
|
||||
filler.AllowWatermarks));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -391,7 +404,8 @@ public abstract class PlayoutModeSchedulerBase<T> : IPlayoutModeScheduler<T> whe
|
||||
playoutBuilderState,
|
||||
e2,
|
||||
filler.Count.Value,
|
||||
FillerKind.MidRoll));
|
||||
FillerKind.MidRoll,
|
||||
filler.AllowWatermarks));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -408,12 +422,22 @@ public abstract class PlayoutModeSchedulerBase<T> : IPlayoutModeScheduler<T> whe
|
||||
case FillerMode.Duration when filler.Duration.HasValue:
|
||||
IMediaCollectionEnumerator e1 = enumerators[CollectionKey.ForFillerPreset(filler)];
|
||||
result.AddRange(
|
||||
AddDurationFiller(playoutBuilderState, e1, filler.Duration.Value, FillerKind.PostRoll));
|
||||
AddDurationFiller(
|
||||
playoutBuilderState,
|
||||
e1,
|
||||
filler.Duration.Value,
|
||||
FillerKind.PostRoll,
|
||||
filler.AllowWatermarks));
|
||||
break;
|
||||
case FillerMode.Count when filler.Count.HasValue:
|
||||
IMediaCollectionEnumerator e2 = enumerators[CollectionKey.ForFillerPreset(filler)];
|
||||
result.AddRange(
|
||||
AddCountFiller(playoutBuilderState, e2, filler.Count.Value, FillerKind.PostRoll));
|
||||
AddCountFiller(
|
||||
playoutBuilderState,
|
||||
e2,
|
||||
filler.Count.Value,
|
||||
FillerKind.PostRoll,
|
||||
filler.AllowWatermarks));
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -464,7 +488,8 @@ public abstract class PlayoutModeSchedulerBase<T> : IPlayoutModeScheduler<T> whe
|
||||
playoutBuilderState,
|
||||
pre1,
|
||||
remainingToFill,
|
||||
FillerKind.PreRoll));
|
||||
FillerKind.PreRoll,
|
||||
padFiller.AllowWatermarks));
|
||||
totalDuration =
|
||||
TimeSpan.FromMilliseconds(result.Sum(pi => (pi.Finish - pi.Start).TotalMilliseconds));
|
||||
remainingToFill = targetTime - totalDuration - playoutItem.StartOffset;
|
||||
@@ -487,7 +512,8 @@ public abstract class PlayoutModeSchedulerBase<T> : IPlayoutModeScheduler<T> whe
|
||||
playoutBuilderState,
|
||||
mid1,
|
||||
remainingToFill,
|
||||
FillerKind.MidRoll));
|
||||
FillerKind.MidRoll,
|
||||
padFiller.AllowWatermarks));
|
||||
TimeSpan average = effectiveChapters.Count == 0
|
||||
? remainingToFill
|
||||
: remainingToFill / (effectiveChapters.Count - 1);
|
||||
@@ -540,7 +566,8 @@ public abstract class PlayoutModeSchedulerBase<T> : IPlayoutModeScheduler<T> whe
|
||||
playoutBuilderState,
|
||||
post1,
|
||||
remainingToFill,
|
||||
FillerKind.PostRoll));
|
||||
FillerKind.PostRoll,
|
||||
padFiller.AllowWatermarks));
|
||||
totalDuration =
|
||||
TimeSpan.FromMilliseconds(result.Sum(pi => (pi.Finish - pi.Start).TotalMilliseconds));
|
||||
remainingToFill = targetTime - totalDuration - playoutItem.StartOffset;
|
||||
@@ -576,7 +603,8 @@ public abstract class PlayoutModeSchedulerBase<T> : IPlayoutModeScheduler<T> whe
|
||||
PlayoutBuilderState playoutBuilderState,
|
||||
IMediaCollectionEnumerator enumerator,
|
||||
int count,
|
||||
FillerKind fillerKind)
|
||||
FillerKind fillerKind,
|
||||
bool allowWatermarks)
|
||||
{
|
||||
var result = new List<PlayoutItem>();
|
||||
|
||||
@@ -594,7 +622,8 @@ public abstract class PlayoutModeSchedulerBase<T> : IPlayoutModeScheduler<T> whe
|
||||
InPoint = TimeSpan.Zero,
|
||||
OutPoint = itemDuration,
|
||||
GuideGroup = playoutBuilderState.NextGuideGroup,
|
||||
FillerKind = fillerKind
|
||||
FillerKind = fillerKind,
|
||||
DisableWatermarks = !allowWatermarks
|
||||
};
|
||||
|
||||
result.Add(playoutItem);
|
||||
@@ -605,24 +634,24 @@ public abstract class PlayoutModeSchedulerBase<T> : IPlayoutModeScheduler<T> whe
|
||||
return result;
|
||||
}
|
||||
|
||||
private static List<PlayoutItem> AddDurationFiller(
|
||||
private List<PlayoutItem> AddDurationFiller(
|
||||
PlayoutBuilderState playoutBuilderState,
|
||||
IMediaCollectionEnumerator enumerator,
|
||||
TimeSpan duration,
|
||||
FillerKind fillerKind)
|
||||
FillerKind fillerKind,
|
||||
bool allowWatermarks)
|
||||
{
|
||||
var result = new List<PlayoutItem>();
|
||||
|
||||
while (enumerator.Current.IsSome)
|
||||
TimeSpan remainingToFill = duration;
|
||||
var skipped = false;
|
||||
while (enumerator.Current.IsSome && remainingToFill > TimeSpan.Zero)
|
||||
{
|
||||
foreach (MediaItem mediaItem in enumerator.Current)
|
||||
{
|
||||
// TODO: retry up to x times when item doesn't fit?
|
||||
|
||||
TimeSpan itemDuration = DurationForMediaItem(mediaItem);
|
||||
duration -= itemDuration;
|
||||
|
||||
if (duration >= TimeSpan.Zero)
|
||||
if (remainingToFill - itemDuration >= TimeSpan.Zero)
|
||||
{
|
||||
var playoutItem = new PlayoutItem
|
||||
{
|
||||
@@ -632,17 +661,37 @@ public abstract class PlayoutModeSchedulerBase<T> : IPlayoutModeScheduler<T> whe
|
||||
InPoint = TimeSpan.Zero,
|
||||
OutPoint = itemDuration,
|
||||
GuideGroup = playoutBuilderState.NextGuideGroup,
|
||||
FillerKind = fillerKind
|
||||
FillerKind = fillerKind,
|
||||
DisableWatermarks = !allowWatermarks
|
||||
};
|
||||
|
||||
remainingToFill -= itemDuration;
|
||||
result.Add(playoutItem);
|
||||
enumerator.MoveNext();
|
||||
}
|
||||
}
|
||||
else if (skipped)
|
||||
{
|
||||
// set to zero so it breaks out of the while loop
|
||||
remainingToFill = TimeSpan.Zero;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (itemDuration >= duration * 2)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Filler item is too long {FillerDuration} to fill {GapDuration}; skipping to next filler item",
|
||||
itemDuration,
|
||||
duration);
|
||||
|
||||
if (duration < TimeSpan.Zero)
|
||||
{
|
||||
break;
|
||||
skipped = true;
|
||||
enumerator.MoveNext();
|
||||
}
|
||||
else
|
||||
{
|
||||
// set to zero so it breaks out of the while loop
|
||||
remainingToFill = TimeSpan.Zero;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -670,7 +719,8 @@ public abstract class PlayoutModeSchedulerBase<T> : IPlayoutModeScheduler<T> whe
|
||||
InPoint = TimeSpan.Zero,
|
||||
OutPoint = TimeSpan.Zero,
|
||||
GuideGroup = playoutBuilderState.NextGuideGroup,
|
||||
FillerKind = FillerKind.Fallback
|
||||
FillerKind = FillerKind.Fallback,
|
||||
DisableWatermarks = !scheduleItem.FallbackFiller.AllowWatermarks
|
||||
};
|
||||
|
||||
enumerator.MoveNext();
|
||||
|
||||
@@ -50,6 +50,7 @@ public class PlayoutModeSchedulerFlood : PlayoutModeSchedulerBase<ProgramSchedul
|
||||
FillerKind = scheduleItem.GuideMode == GuideMode.Filler
|
||||
? FillerKind.Tail
|
||||
: FillerKind.None,
|
||||
CustomTitle = scheduleItem.CustomTitle,
|
||||
WatermarkId = scheduleItem.WatermarkId,
|
||||
PreferredAudioLanguageCode = scheduleItem.PreferredAudioLanguageCode,
|
||||
PreferredSubtitleLanguageCode = scheduleItem.PreferredSubtitleLanguageCode,
|
||||
@@ -94,7 +95,11 @@ public class PlayoutModeSchedulerFlood : PlayoutModeSchedulerBase<ProgramSchedul
|
||||
{
|
||||
CurrentTime = itemEndTimeWithFiller,
|
||||
InFlood = true,
|
||||
NextGuideGroup = nextState.IncrementGuideGroup
|
||||
|
||||
// only bump guide group if we don't have a custom title
|
||||
NextGuideGroup = string.IsNullOrWhiteSpace(scheduleItem.CustomTitle)
|
||||
? nextState.IncrementGuideGroup
|
||||
: nextState.NextGuideGroup
|
||||
};
|
||||
|
||||
contentEnumerator.MoveNext();
|
||||
@@ -108,10 +113,18 @@ public class PlayoutModeSchedulerFlood : PlayoutModeSchedulerBase<ProgramSchedul
|
||||
nextState = nextState with
|
||||
{
|
||||
InFlood = nextState.CurrentTime >= hardStop,
|
||||
NextGuideGroup = nextState.DecrementGuideGroup
|
||||
|
||||
// only decrement guide group if it was bumped
|
||||
NextGuideGroup = playoutItems.Select(pi => pi.GuideGroup).Distinct().Count() != 1
|
||||
? nextState.DecrementGuideGroup
|
||||
: nextState.NextGuideGroup
|
||||
};
|
||||
|
||||
nextState.ScheduleItemsEnumerator.MoveNext();
|
||||
// only advance to the next schedule item if we aren't still in a flood
|
||||
if (!nextState.InFlood)
|
||||
{
|
||||
nextState.ScheduleItemsEnumerator.MoveNext();
|
||||
}
|
||||
|
||||
ProgramScheduleItem peekItem = nextScheduleItem;
|
||||
DateTimeOffset peekItemStart = GetStartTimeAfter(nextState, peekItem);
|
||||
|
||||
@@ -60,6 +60,7 @@ public class PlayoutModeSchedulerMultiple : PlayoutModeSchedulerBase<ProgramSche
|
||||
FillerKind = scheduleItem.GuideMode == GuideMode.Filler
|
||||
? FillerKind.Tail
|
||||
: FillerKind.None,
|
||||
CustomTitle = scheduleItem.CustomTitle,
|
||||
WatermarkId = scheduleItem.WatermarkId,
|
||||
PreferredAudioLanguageCode = scheduleItem.PreferredAudioLanguageCode,
|
||||
PreferredSubtitleLanguageCode = scheduleItem.PreferredSubtitleLanguageCode,
|
||||
@@ -81,7 +82,11 @@ public class PlayoutModeSchedulerMultiple : PlayoutModeSchedulerBase<ProgramSche
|
||||
{
|
||||
CurrentTime = itemEndTimeWithFiller,
|
||||
MultipleRemaining = nextState.MultipleRemaining.Map(i => i - 1),
|
||||
NextGuideGroup = nextState.IncrementGuideGroup
|
||||
|
||||
// only bump guide group if we don't have a custom title
|
||||
NextGuideGroup = string.IsNullOrWhiteSpace(scheduleItem.CustomTitle)
|
||||
? nextState.IncrementGuideGroup
|
||||
: nextState.NextGuideGroup
|
||||
};
|
||||
|
||||
contentEnumerator.MoveNext();
|
||||
@@ -96,7 +101,11 @@ public class PlayoutModeSchedulerMultiple : PlayoutModeSchedulerBase<ProgramSche
|
||||
nextState = nextState with
|
||||
{
|
||||
MultipleRemaining = None,
|
||||
NextGuideGroup = nextState.DecrementGuideGroup
|
||||
|
||||
// only decrement guide group if it was bumped
|
||||
NextGuideGroup = playoutItems.Select(pi => pi.GuideGroup).Distinct().Count() != 1
|
||||
? nextState.DecrementGuideGroup
|
||||
: nextState.NextGuideGroup
|
||||
};
|
||||
|
||||
nextState.ScheduleItemsEnumerator.MoveNext();
|
||||
|
||||
@@ -41,6 +41,7 @@ public class PlayoutModeSchedulerOne : PlayoutModeSchedulerBase<ProgramScheduleI
|
||||
FillerKind = scheduleItem.GuideMode == GuideMode.Filler
|
||||
? FillerKind.Tail
|
||||
: FillerKind.None,
|
||||
CustomTitle = scheduleItem.CustomTitle,
|
||||
WatermarkId = scheduleItem.WatermarkId,
|
||||
PreferredAudioLanguageCode = scheduleItem.PreferredAudioLanguageCode,
|
||||
PreferredSubtitleLanguageCode = scheduleItem.PreferredSubtitleLanguageCode,
|
||||
|
||||
@@ -7,14 +7,17 @@ public class ShuffleInOrderCollectionEnumerator : IMediaCollectionEnumerator
|
||||
{
|
||||
private readonly IList<CollectionWithItems> _collections;
|
||||
private readonly int _mediaItemCount;
|
||||
private readonly bool _randomStartPoint;
|
||||
private Random _random;
|
||||
private IList<MediaItem> _shuffled;
|
||||
|
||||
public ShuffleInOrderCollectionEnumerator(
|
||||
IList<CollectionWithItems> collections,
|
||||
CollectionEnumeratorState state)
|
||||
CollectionEnumeratorState state,
|
||||
bool randomStartPoint)
|
||||
{
|
||||
_collections = collections;
|
||||
_randomStartPoint = randomStartPoint;
|
||||
_mediaItemCount = collections.Sum(c => c.MediaItems.Count);
|
||||
|
||||
if (state.Index >= _mediaItemCount)
|
||||
@@ -87,7 +90,14 @@ public class ShuffleInOrderCollectionEnumerator : IMediaCollectionEnumerator
|
||||
var result = new List<MediaItem>();
|
||||
for (var i = 0; i < filled[0].Items.Count; i++)
|
||||
{
|
||||
var batch = filled.Select(collection => collection.Items[i]).ToList();
|
||||
var batch = new List<Option<MediaItem>>();
|
||||
|
||||
foreach (OrderedCollection collection in filled)
|
||||
{
|
||||
int index = (collection.Index + i) % collection.Items.Count;
|
||||
batch.Add(collection.Items[index]);
|
||||
}
|
||||
|
||||
foreach (Option<MediaItem> maybeItem in Shuffle(batch, random))
|
||||
{
|
||||
result.AddRange(maybeItem);
|
||||
@@ -144,7 +154,13 @@ public class ShuffleInOrderCollectionEnumerator : IMediaCollectionEnumerator
|
||||
ordered.AddRange(larger);
|
||||
}
|
||||
|
||||
result.Add(new OrderedCollection { Index = 0, Items = ordered });
|
||||
var index = 0;
|
||||
if (_randomStartPoint)
|
||||
{
|
||||
index = random.Next(0, ordered.Count - 1);
|
||||
}
|
||||
|
||||
result.Add(new OrderedCollection { Index = index, Items = ordered });
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
@@ -9,10 +9,14 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="6.6.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.1.0" />
|
||||
<PackageReference Include="Moq" Version="4.17.2" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.2.0" />
|
||||
<PackageReference Include="Moq" Version="4.18.0" />
|
||||
<PackageReference Include="NUnit" Version="3.13.3" />
|
||||
<PackageReference Include="NUnit3TestAdapter" Version="4.2.1" />
|
||||
<PackageReference Include="System.IO.FileSystem.Primitives" Version="4.3.0" />
|
||||
<PackageReference Include="System.Text.Encoding.Extensions" Version="4.3.0" />
|
||||
<PackageReference Include="System.Runtime.Handles" Version="4.3.0" />
|
||||
<PackageReference Include="System.Runtime.InteropServices" Version="4.3.0" />
|
||||
<PackageReference Include="coverlet.collector" Version="3.1.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CliWrap" Version="3.4.4" />
|
||||
<PackageReference Include="LanguageExt.Core" Version="4.0.4" />
|
||||
<PackageReference Include="LanguageExt.Core" Version="4.1.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="6.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -148,16 +148,20 @@ public class MediaSourceRepository : IMediaSourceRepository
|
||||
}
|
||||
}
|
||||
|
||||
var libraryIds = toDelete.Map(l => l.Id).ToList();
|
||||
List<int> deletedMediaIds = await dbContext.MediaItems
|
||||
.Filter(mi => libraryIds.Contains(mi.LibraryPath.LibraryId))
|
||||
.Map(mi => mi.Id)
|
||||
.ToListAsync();
|
||||
|
||||
foreach (PlexLibrary delete in toDelete)
|
||||
{
|
||||
dbContext.Entry(delete).State = EntityState.Deleted;
|
||||
dbContext.PlexLibraries.Remove(delete);
|
||||
}
|
||||
|
||||
List<int> ids = await DisablePlexLibrarySync(toDelete.Map(l => l.Id).ToList());
|
||||
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
return ids;
|
||||
return deletedMediaIds;
|
||||
}
|
||||
|
||||
public async Task<List<int>> UpdateLibraries(
|
||||
|
||||
@@ -14,13 +14,13 @@
|
||||
<PackageReference Include="Lucene.Net" Version="4.8.0-beta00016" />
|
||||
<PackageReference Include="Lucene.Net.Analysis.Common" Version="4.8.0-beta00016" />
|
||||
<PackageReference Include="Lucene.Net.QueryParser" Version="4.8.0-beta00016" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.4">
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="6.0.5" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.5">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.1.46">
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="6.0.5" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.2.32">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
|
||||
Generated
+4285
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Migrations
|
||||
{
|
||||
public partial class FillerPreset_AllowWatermarks : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "DisableWatermarks",
|
||||
table: "PlayoutItem",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "AllowWatermarks",
|
||||
table: "FillerPreset",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "DisableWatermarks",
|
||||
table: "PlayoutItem");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AllowWatermarks",
|
||||
table: "FillerPreset");
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+4288
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Migrations
|
||||
{
|
||||
public partial class Add_MusicVideoMetadata_Track : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "Track",
|
||||
table: "MusicVideoMetadata",
|
||||
type: "INTEGER",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Track",
|
||||
table: "MusicVideoMetadata");
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+4291
File diff suppressed because it is too large
Load Diff
+26
@@ -0,0 +1,26 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Migrations
|
||||
{
|
||||
public partial class Add_ProgramSchedule_RandomStartPoint : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "RandomStartPoint",
|
||||
table: "ProgramSchedule",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "RandomStartPoint",
|
||||
table: "ProgramSchedule");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -589,6 +589,9 @@ namespace ErsatzTV.Infrastructure.Migrations
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("AllowWatermarks")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("CollectionId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
@@ -1285,6 +1288,9 @@ namespace ErsatzTV.Infrastructure.Migrations
|
||||
b.Property<string>("Title")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("Track")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("Year")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
@@ -1386,6 +1392,9 @@ namespace ErsatzTV.Infrastructure.Migrations
|
||||
b.Property<string>("CustomTitle")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("DisableWatermarks")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("FillerKind")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
@@ -1539,6 +1548,9 @@ namespace ErsatzTV.Infrastructure.Migrations
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("RandomStartPoint")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("ShuffleScheduleItems")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ public class PlexLibraryResponse
|
||||
public string Key { get; set; }
|
||||
public string Title { get; set; }
|
||||
public string Type { get; set; }
|
||||
public string Agent { get; set; }
|
||||
public int Hidden { get; set; }
|
||||
public string Uuid { get; set; }
|
||||
}
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
using System.Security.Cryptography;
|
||||
using ErsatzTV.Infrastructure.Plex.Models;
|
||||
using Microsoft.IO;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Plex;
|
||||
|
||||
public static class PlexEtag
|
||||
public class PlexEtag
|
||||
{
|
||||
public static string ForMovie(PlexMetadataResponse response)
|
||||
private readonly RecyclableMemoryStreamManager _recyclableMemoryStreamManager;
|
||||
|
||||
public PlexEtag(RecyclableMemoryStreamManager recyclableMemoryStreamManager)
|
||||
{
|
||||
using var ms = new MemoryStream();
|
||||
_recyclableMemoryStreamManager = recyclableMemoryStreamManager;
|
||||
}
|
||||
|
||||
public string ForMovie(PlexMetadataResponse response)
|
||||
{
|
||||
using MemoryStream ms = _recyclableMemoryStreamManager.GetStream();
|
||||
using var bw = new BinaryWriter(ms);
|
||||
|
||||
// video key
|
||||
@@ -80,9 +88,9 @@ public static class PlexEtag
|
||||
return BitConverter.ToString(hash).Replace("-", string.Empty);
|
||||
}
|
||||
|
||||
public static string ForShow(PlexMetadataResponse response)
|
||||
public string ForShow(PlexMetadataResponse response)
|
||||
{
|
||||
using var ms = new MemoryStream();
|
||||
using MemoryStream ms = _recyclableMemoryStreamManager.GetStream();
|
||||
using var bw = new BinaryWriter(ms);
|
||||
|
||||
// video key
|
||||
@@ -127,9 +135,9 @@ public static class PlexEtag
|
||||
return BitConverter.ToString(hash).Replace("-", string.Empty);
|
||||
}
|
||||
|
||||
public static string ForSeason(PlexXmlMetadataResponse response)
|
||||
public string ForSeason(PlexXmlMetadataResponse response)
|
||||
{
|
||||
using var ms = new MemoryStream();
|
||||
using MemoryStream ms = _recyclableMemoryStreamManager.GetStream();
|
||||
using var bw = new BinaryWriter(ms);
|
||||
|
||||
// video key
|
||||
@@ -165,9 +173,9 @@ public static class PlexEtag
|
||||
return BitConverter.ToString(hash).Replace("-", string.Empty);
|
||||
}
|
||||
|
||||
public static string ForEpisode(PlexXmlMetadataResponse response)
|
||||
public string ForEpisode(PlexXmlMetadataResponse response)
|
||||
{
|
||||
using var ms = new MemoryStream();
|
||||
using MemoryStream ms = _recyclableMemoryStreamManager.GetStream();
|
||||
using var bw = new BinaryWriter(ms);
|
||||
|
||||
// video key
|
||||
|
||||
@@ -14,12 +14,15 @@ public class PlexServerApiClient : IPlexServerApiClient
|
||||
{
|
||||
private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
|
||||
private readonly ILogger<PlexServerApiClient> _logger;
|
||||
private readonly PlexEtag _plexEtag;
|
||||
|
||||
public PlexServerApiClient(
|
||||
IFallbackMetadataProvider fallbackMetadataProvider,
|
||||
PlexEtag plexEtag,
|
||||
ILogger<PlexServerApiClient> logger)
|
||||
{
|
||||
_fallbackMetadataProvider = fallbackMetadataProvider;
|
||||
_plexEtag = plexEtag;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -61,6 +64,7 @@ public class PlexServerApiClient : IPlexServerApiClient
|
||||
await service.GetLibraries(token.AuthToken).Map(r => r.MediaContainer.Directory);
|
||||
return directory
|
||||
// .Filter(l => l.Hidden == 0)
|
||||
.Filter(l => (l.Agent ?? string.Empty).ToLowerInvariant() is not "com.plexapp.agents.none")
|
||||
.Filter(l => l.Type.ToLowerInvariant() is "movie" or "show")
|
||||
.Map(Project)
|
||||
.Somes()
|
||||
@@ -344,7 +348,7 @@ public class PlexServerApiClient : IPlexServerApiClient
|
||||
|
||||
var movie = new PlexMovie
|
||||
{
|
||||
Etag = PlexEtag.ForMovie(response),
|
||||
Etag = _plexEtag.ForMovie(response),
|
||||
Key = response.Key,
|
||||
MovieMetadata = new List<MovieMetadata> { metadata },
|
||||
MediaVersions = new List<MediaVersion> { version },
|
||||
@@ -530,7 +534,7 @@ public class PlexServerApiClient : IPlexServerApiClient
|
||||
var show = new PlexShow
|
||||
{
|
||||
Key = response.Key,
|
||||
Etag = PlexEtag.ForShow(response),
|
||||
Etag = _plexEtag.ForShow(response),
|
||||
ShowMetadata = new List<ShowMetadata> { metadata },
|
||||
TraktListItems = new List<TraktListItem>()
|
||||
};
|
||||
@@ -701,7 +705,7 @@ public class PlexServerApiClient : IPlexServerApiClient
|
||||
var season = new PlexSeason
|
||||
{
|
||||
Key = response.Key,
|
||||
Etag = PlexEtag.ForSeason(response),
|
||||
Etag = _plexEtag.ForSeason(response),
|
||||
SeasonNumber = response.Index,
|
||||
SeasonMetadata = new List<SeasonMetadata> { metadata },
|
||||
TraktListItems = new List<TraktListItem>()
|
||||
@@ -742,7 +746,7 @@ public class PlexServerApiClient : IPlexServerApiClient
|
||||
var episode = new PlexEpisode
|
||||
{
|
||||
Key = response.Key,
|
||||
Etag = PlexEtag.ForEpisode(response),
|
||||
Etag = _plexEtag.ForEpisode(response),
|
||||
EpisodeMetadata = new List<EpisodeMetadata> { metadata },
|
||||
MediaVersions = new List<MediaVersion> { version },
|
||||
TraktListItems = new List<TraktListItem>()
|
||||
|
||||
@@ -54,6 +54,7 @@ public sealed class SearchIndex : ISearchIndex
|
||||
private const string ShowTitleField = "show_title";
|
||||
private const string ShowGenreField = "show_genre";
|
||||
private const string ShowTagField = "show_tag";
|
||||
private const string MetadataKindField = "metadata_kind";
|
||||
|
||||
internal const string MinutesField = "minutes";
|
||||
internal const string HeightField = "height";
|
||||
@@ -87,7 +88,7 @@ public sealed class SearchIndex : ISearchIndex
|
||||
_initialized = false;
|
||||
}
|
||||
|
||||
public int Version => 24;
|
||||
public int Version => 25;
|
||||
|
||||
public async Task<bool> Initialize(
|
||||
ILocalFileSystem localFileSystem,
|
||||
@@ -363,7 +364,8 @@ public sealed class SearchIndex : ISearchIndex
|
||||
new StringField(LibraryIdField, movie.LibraryPath.Library.Id.ToString(), Field.Store.NO),
|
||||
new StringField(TitleAndYearField, GetTitleAndYear(metadata), Field.Store.NO),
|
||||
new StringField(JumpLetterField, GetJumpLetter(metadata), Field.Store.YES),
|
||||
new StringField(StateField, movie.State.ToString(), Field.Store.NO)
|
||||
new StringField(StateField, movie.State.ToString(), Field.Store.NO),
|
||||
new TextField(MetadataKindField, metadata.MetadataKind.ToString(), Field.Store.NO)
|
||||
};
|
||||
|
||||
await AddLanguages(searchRepository, doc, movie.MediaVersions);
|
||||
@@ -500,7 +502,8 @@ public sealed class SearchIndex : ISearchIndex
|
||||
new StringField(LibraryIdField, show.LibraryPath.Library.Id.ToString(), Field.Store.NO),
|
||||
new StringField(TitleAndYearField, GetTitleAndYear(metadata), Field.Store.NO),
|
||||
new StringField(JumpLetterField, GetJumpLetter(metadata), Field.Store.YES),
|
||||
new StringField(StateField, show.State.ToString(), Field.Store.NO)
|
||||
new StringField(StateField, show.State.ToString(), Field.Store.NO),
|
||||
new TextField(MetadataKindField, metadata.MetadataKind.ToString(), Field.Store.NO)
|
||||
};
|
||||
|
||||
List<string> languages = await searchRepository.GetLanguagesForShow(show);
|
||||
@@ -670,7 +673,8 @@ public sealed class SearchIndex : ISearchIndex
|
||||
new TextField(LibraryNameField, artist.LibraryPath.Library.Name, Field.Store.NO),
|
||||
new StringField(LibraryIdField, artist.LibraryPath.Library.Id.ToString(), Field.Store.NO),
|
||||
new StringField(TitleAndYearField, GetTitleAndYear(metadata), Field.Store.NO),
|
||||
new StringField(JumpLetterField, GetJumpLetter(metadata), Field.Store.YES)
|
||||
new StringField(JumpLetterField, GetJumpLetter(metadata), Field.Store.YES),
|
||||
new TextField(MetadataKindField, metadata.MetadataKind.ToString(), Field.Store.NO)
|
||||
};
|
||||
|
||||
List<string> languages = await searchRepository.GetLanguagesForArtist(artist);
|
||||
@@ -722,7 +726,8 @@ public sealed class SearchIndex : ISearchIndex
|
||||
new StringField(LibraryIdField, musicVideo.LibraryPath.Library.Id.ToString(), Field.Store.NO),
|
||||
new StringField(TitleAndYearField, GetTitleAndYear(metadata), Field.Store.NO),
|
||||
new StringField(JumpLetterField, GetJumpLetter(metadata), Field.Store.YES),
|
||||
new StringField(StateField, musicVideo.State.ToString(), Field.Store.NO)
|
||||
new StringField(StateField, musicVideo.State.ToString(), Field.Store.NO),
|
||||
new TextField(MetadataKindField, metadata.MetadataKind.ToString(), Field.Store.NO)
|
||||
};
|
||||
|
||||
await AddLanguages(searchRepository, doc, musicVideo.MediaVersions);
|
||||
@@ -818,7 +823,8 @@ public sealed class SearchIndex : ISearchIndex
|
||||
new StringField(JumpLetterField, GetJumpLetter(metadata), Field.Store.YES),
|
||||
new StringField(StateField, episode.State.ToString(), Field.Store.NO),
|
||||
new Int32Field(SeasonNumberField, episode.Season?.SeasonNumber ?? 0, Field.Store.NO),
|
||||
new Int32Field(EpisodeNumberField, metadata.EpisodeNumber, Field.Store.NO)
|
||||
new Int32Field(EpisodeNumberField, metadata.EpisodeNumber, Field.Store.NO),
|
||||
new TextField(MetadataKindField, metadata.MetadataKind.ToString(), Field.Store.NO)
|
||||
};
|
||||
|
||||
// add some show fields to help filter episodes within a particular show
|
||||
@@ -938,7 +944,8 @@ public sealed class SearchIndex : ISearchIndex
|
||||
new StringField(LibraryIdField, otherVideo.LibraryPath.Library.Id.ToString(), Field.Store.NO),
|
||||
new StringField(TitleAndYearField, GetTitleAndYear(metadata), Field.Store.NO),
|
||||
new StringField(JumpLetterField, GetJumpLetter(metadata), Field.Store.YES),
|
||||
new StringField(StateField, otherVideo.State.ToString(), Field.Store.NO)
|
||||
new StringField(StateField, otherVideo.State.ToString(), Field.Store.NO),
|
||||
new TextField(MetadataKindField, metadata.MetadataKind.ToString(), Field.Store.NO)
|
||||
};
|
||||
|
||||
await AddLanguages(searchRepository, doc, otherVideo.MediaVersions);
|
||||
@@ -1036,7 +1043,8 @@ public sealed class SearchIndex : ISearchIndex
|
||||
new StringField(LibraryIdField, song.LibraryPath.Library.Id.ToString(), Field.Store.NO),
|
||||
new StringField(TitleAndYearField, GetTitleAndYear(metadata), Field.Store.NO),
|
||||
new StringField(JumpLetterField, GetJumpLetter(metadata), Field.Store.YES),
|
||||
new StringField(StateField, song.State.ToString(), Field.Store.NO)
|
||||
new StringField(StateField, song.State.ToString(), Field.Store.NO),
|
||||
new TextField(MetadataKindField, metadata.MetadataKind.ToString(), Field.Store.NO)
|
||||
};
|
||||
|
||||
await AddLanguages(searchRepository, doc, song.MediaVersions);
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=NV/@EntryIndexedValue">NV</s:String>
|
||||
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=SAR/@EntryIndexedValue">SAR</s:String>
|
||||
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=SDH/@EntryIndexedValue">SDH</s:String>
|
||||
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=UTF/@EntryIndexedValue">UTF</s:String>
|
||||
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=YUV/@EntryIndexedValue">YUV</s:String>
|
||||
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=YUVJ/@EntryIndexedValue">YUVJ</s:String>
|
||||
<s:Boolean x:Key="/Default/UserDictionary/Words/=anull/@EntryIndexedValue">True</s:Boolean>
|
||||
|
||||
@@ -55,20 +55,20 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Bugsnag.AspNet.Core" Version="3.0.1" />
|
||||
<PackageReference Include="FluentValidation" Version="11.0.0" />
|
||||
<PackageReference Include="FluentValidation.AspNetCore" Version="11.0.0" />
|
||||
<PackageReference Include="FluentValidation" Version="11.0.1" />
|
||||
<PackageReference Include="FluentValidation.AspNetCore" Version="11.0.1" />
|
||||
<PackageReference Include="HtmlSanitizer" Version="7.1.488" />
|
||||
<PackageReference Include="LanguageExt.Core" Version="4.0.4" />
|
||||
<PackageReference Include="LanguageExt.Core" Version="4.1.0" />
|
||||
<PackageReference Include="Markdig" Version="0.30.2" />
|
||||
<PackageReference Include="MediatR.Courier.DependencyInjection" Version="5.0.0" />
|
||||
<PackageReference Include="MediatR.Extensions.Microsoft.DependencyInjection" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.SpaServices.Extensions" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.4">
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="6.0.5" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.SpaServices.Extensions" Version="6.0.5" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.5">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.1.46">
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.2.32">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
<MudSelectItem T="int?" Value="15">15 (:00, :15, :30, :45)</MudSelectItem>
|
||||
<MudSelectItem T="int?" Value="30">30 (:00, :30)</MudSelectItem>
|
||||
</MudSelect>
|
||||
<MudCheckBox Class="mt-3" Label="Allow Watermarks" @bind-Checked="@_model.AllowWatermarks" For="@(() => _model.AllowWatermarks)"/>
|
||||
<MudSelect Class="mt-3" Label="Filler Collection Type" @bind-Value="_model.CollectionType" For="@(() => _model.CollectionType)">
|
||||
@foreach (ProgramScheduleItemCollectionType collectionType in Enum.GetValues<ProgramScheduleItemCollectionType>())
|
||||
{
|
||||
@@ -181,6 +182,7 @@
|
||||
_model.Duration = fillerPreset.Duration;
|
||||
_model.Count = fillerPreset.Count;
|
||||
_model.PadToNearestMinute = fillerPreset.PadToNearestMinute;
|
||||
_model.AllowWatermarks = fillerPreset.AllowWatermarks;
|
||||
_model.CollectionType = fillerPreset.CollectionType;
|
||||
_model.Collection = fillerPreset.CollectionId.HasValue
|
||||
? _mediaCollections.Find(c => c.Id == fillerPreset.CollectionId.Value)
|
||||
|
||||
@@ -38,6 +38,11 @@
|
||||
For="@(() => _model.ShuffleScheduleItems)"/>
|
||||
</MudTooltip>
|
||||
</MudElement>
|
||||
<MudElement HtmlTag="div" Class="mt-3">
|
||||
<MudCheckBox Label="Random Start Point"
|
||||
@bind-Checked="@_model.RandomStartPoint"
|
||||
For="@(() => _model.RandomStartPoint)"/>
|
||||
</MudElement>
|
||||
</MudCardContent>
|
||||
<MudCardActions>
|
||||
<MudButton ButtonType="ButtonType.Submit" Variant="Variant.Filled" Color="Color.Primary">
|
||||
@@ -78,6 +83,7 @@
|
||||
_model.ShuffleScheduleItems = viewModel.ShuffleScheduleItems;
|
||||
_model.KeepMultiPartEpisodesTogether = viewModel.KeepMultiPartEpisodesTogether;
|
||||
_model.TreatCollectionsAsShows = viewModel.TreatCollectionsAsShows;
|
||||
_model.RandomStartPoint = viewModel.RandomStartPoint;
|
||||
},
|
||||
() => _navigationManager.NavigateTo("404"));
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
@using ErsatzTV.Application.MediaItems
|
||||
@using ErsatzTV.Application.Watermarks
|
||||
@using System.Globalization
|
||||
@using ErsatzTV.Core.Domain.Filler
|
||||
@using ErsatzTV.Application.Configuration
|
||||
@using ErsatzTV.Core.Domain.Filler
|
||||
@implements IDisposable
|
||||
@inject IMediator _mediator
|
||||
@inject ISnackbar _snackbar
|
||||
@@ -147,12 +147,19 @@
|
||||
<MudForm @bind-IsValid="@_playoutSuccess">
|
||||
<MudTextField T="int"
|
||||
Label="Days To Build"
|
||||
@bind-Value="_playoutDaysToBuild"
|
||||
@bind-Value="_playoutSettings.DaysToBuild"
|
||||
Validation="@(new Func<int, string>(ValidatePlayoutDaysToBuild))"
|
||||
Required="true"
|
||||
RequiredError="Days to build is required!"
|
||||
Adornment="Adornment.End"
|
||||
AdornmentText="Days"/>
|
||||
<MudElement HtmlTag="div" Class="mt-3">
|
||||
<MudTooltip Text="Controls whether file-not-found or unavailable items should be included in playouts">
|
||||
<MudCheckBox Label="Skip Missing Items"
|
||||
@bind-Checked="_playoutSettings.SkipMissingItems"
|
||||
For="@(() => _playoutSettings.SkipMissingItems)"/>
|
||||
</MudTooltip>
|
||||
</MudElement>
|
||||
</MudForm>
|
||||
</MudCardContent>
|
||||
<MudCardActions>
|
||||
@@ -176,7 +183,7 @@
|
||||
private List<FillerPresetViewModel> _fillerPresets;
|
||||
private int _tunerCount;
|
||||
private int _libraryRefreshInterval;
|
||||
private int _playoutDaysToBuild;
|
||||
private PlayoutSettingsViewModel _playoutSettings;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
@@ -198,8 +205,8 @@
|
||||
_hdhrSuccess = string.IsNullOrWhiteSpace(ValidateTunerCount(_tunerCount));
|
||||
_libraryRefreshInterval = await _mediator.Send(new GetLibraryRefreshInterval(), _cts.Token);
|
||||
_scannerSuccess = _libraryRefreshInterval > 0;
|
||||
_playoutDaysToBuild = await _mediator.Send(new GetPlayoutDaysToBuild(), _cts.Token);
|
||||
_playoutSuccess = _playoutDaysToBuild > 0;
|
||||
_playoutSettings = await _mediator.Send(new GetPlayoutSettings(), _cts.Token);
|
||||
_playoutSuccess = _playoutSettings.DaysToBuild > 0;
|
||||
}
|
||||
|
||||
private static string ValidatePathExists(string path) => !File.Exists(path) ? "Path does not exist" : null;
|
||||
@@ -257,7 +264,7 @@
|
||||
|
||||
private async Task SavePlayoutSettings()
|
||||
{
|
||||
Either<BaseError, Unit> result = await _mediator.Send(new UpdatePlayoutDaysToBuild(_playoutDaysToBuild), _cts.Token);
|
||||
Either<BaseError, Unit> result = await _mediator.Send(new UpdatePlayoutSettings(_playoutSettings), _cts.Token);
|
||||
result.Match(
|
||||
Left: error =>
|
||||
{
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
@using System.Reflection
|
||||
@inherits LayoutComponentBase
|
||||
@using System.Reflection
|
||||
@using ErsatzTV.Extensions
|
||||
@inherits LayoutComponentBase
|
||||
@using ErsatzTV.Application.Search
|
||||
@implements IDisposable
|
||||
@inject NavigationManager _navigationManager
|
||||
@inject IMediator _mediator
|
||||
|
||||
<MudThemeProvider Theme="_ersatzTvTheme"/>
|
||||
<MudDialogProvider DisableBackdropClick="true"/>
|
||||
<MudSnackbarProvider/>
|
||||
|
||||
<MudLayout>
|
||||
<MudLayout @onclick="@(() => _isOpen = false)">
|
||||
<MudAppBar Elevation="1" Class="app-bar">
|
||||
<div style="min-width: 240px">
|
||||
<a href="/">
|
||||
@@ -16,12 +19,46 @@
|
||||
</div>
|
||||
<EditForm Model="@_dummyModel" OnSubmit="@(_ => PerformSearch())">
|
||||
<MudTextField T="string"
|
||||
@bind-Value="@_query"
|
||||
@bind-Value="@Query"
|
||||
AdornmentIcon="@Icons.Material.Filled.Search"
|
||||
Adornment="Adornment.Start"
|
||||
Variant="Variant.Outlined"
|
||||
Class="search-bar">
|
||||
Immediate="true"
|
||||
Class="search-bar"
|
||||
@onclick="@(() => _isOpen = true)"
|
||||
OnKeyUp="OnKeyUp">
|
||||
</MudTextField>
|
||||
<MudPopover Open="@_isOpen" MaxHeight="300" AnchorOrigin="Origin.BottomCenter" TransformOrigin="Origin.TopCenter" RelativeWidth="true">
|
||||
@if (!string.IsNullOrWhiteSpace(_query) && _query.Length >= 3)
|
||||
{
|
||||
var matches = _searchTargets.Where(s => s.Name.Contains(_query, StringComparison.CurrentCultureIgnoreCase)).ToList();
|
||||
if (matches.Any())
|
||||
{
|
||||
<MudList Clickable="true" Dense="true">
|
||||
@foreach (SearchTargetViewModel searchTarget in matches)
|
||||
{
|
||||
<MudListItem @key="@searchTarget" OnClick="@(() => NavigateTo(searchTarget))">
|
||||
<MudText Typo="Typo.body1">@searchTarget.Name</MudText>
|
||||
<MudText Typo="Typo.subtitle1" Class="mud-text-disabled">
|
||||
@(searchTarget.Kind switch
|
||||
{
|
||||
SearchTargetKind.Channel => "Channel",
|
||||
SearchTargetKind.FFmpegProfile => "FFmpeg Profile",
|
||||
SearchTargetKind.ChannelWatermark => "Channel Watermark",
|
||||
SearchTargetKind.Collection => "Collection",
|
||||
SearchTargetKind.MultiCollection => "Multi Collection",
|
||||
SearchTargetKind.SmartCollection => "Smart Collection",
|
||||
SearchTargetKind.Schedule => "Schedule",
|
||||
SearchTargetKind.ScheduleItems => "Schedule Items",
|
||||
_ => string.Empty
|
||||
})
|
||||
</MudText>
|
||||
</MudListItem>
|
||||
}
|
||||
</MudList>
|
||||
}
|
||||
}
|
||||
</MudPopover>
|
||||
</EditForm>
|
||||
<MudSpacer/>
|
||||
<MudLink Color="Color.Info" Href="/iptv/channels.m3u" Target="_blank" Underline="Underline.None">M3U</MudLink>
|
||||
@@ -81,11 +118,21 @@
|
||||
@code {
|
||||
private static readonly string InfoVersion = Assembly.GetEntryAssembly().GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion ?? "unknown";
|
||||
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
|
||||
private string _query;
|
||||
|
||||
private record SearchModel;
|
||||
|
||||
private readonly SearchModel _dummyModel = new();
|
||||
private bool _isOpen;
|
||||
private List<SearchTargetViewModel> _searchTargets;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cts.Cancel();
|
||||
_cts.Dispose();
|
||||
}
|
||||
|
||||
private MudTheme _ersatzTvTheme => new()
|
||||
{
|
||||
@@ -110,10 +157,31 @@
|
||||
}
|
||||
};
|
||||
|
||||
private string Query
|
||||
{
|
||||
get => _query;
|
||||
set
|
||||
{
|
||||
if (_query == value)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_query = value;
|
||||
_isOpen = true;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
await base.OnParametersSetAsync();
|
||||
_query = _navigationManager.Uri.GetSearchQuery();
|
||||
|
||||
if (_searchTargets is null)
|
||||
{
|
||||
_searchTargets = await _mediator.Send(new QuerySearchTargets(), _cts.Token);
|
||||
}
|
||||
}
|
||||
|
||||
private void PerformSearch()
|
||||
@@ -122,4 +190,37 @@
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private void OnKeyUp(KeyboardEventArgs args)
|
||||
{
|
||||
switch (args.Key)
|
||||
{
|
||||
case "Enter":
|
||||
case "NumpadEnter":
|
||||
_isOpen = false;
|
||||
break;
|
||||
case "Escape":
|
||||
_isOpen = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void NavigateTo(SearchTargetViewModel searchTarget) =>
|
||||
// need to force smart collections to navigate since the query string is all that differs
|
||||
_navigationManager.NavigateTo(UrlFor(searchTarget), searchTarget.Kind is SearchTargetKind.SmartCollection);
|
||||
|
||||
private string UrlFor(SearchTargetViewModel searchTarget) =>
|
||||
searchTarget.Kind switch
|
||||
{
|
||||
SearchTargetKind.Channel => $"channels/{searchTarget.Id}",
|
||||
SearchTargetKind.FFmpegProfile => $"ffmpeg/{searchTarget.Id}",
|
||||
SearchTargetKind.ChannelWatermark => $"watermarks/{searchTarget.Id}",
|
||||
SearchTargetKind.Collection => $"media/collections/{searchTarget.Id}",
|
||||
SearchTargetKind.MultiCollection => $"media/multi-collections/{searchTarget.Id}/edit",
|
||||
SearchTargetKind.SmartCollection when searchTarget is SmartCollectionSearchTargetViewModel sc =>
|
||||
sc.Query.GetRelativeSearchQuery(),
|
||||
SearchTargetKind.Schedule => $"schedules/{searchTarget.Id}",
|
||||
SearchTargetKind.ScheduleItems => $"schedules/{searchTarget.Id}/items",
|
||||
_ => null
|
||||
};
|
||||
|
||||
}
|
||||
@@ -56,6 +56,7 @@ using MediatR.Courier.DependencyInjection;
|
||||
using Microsoft.AspNetCore.StaticFiles;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.FileProviders;
|
||||
using Microsoft.IO;
|
||||
using MudBlazor.Services;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Converters;
|
||||
@@ -322,6 +323,7 @@ public class Startup
|
||||
services.AddSingleton<IFFmpegSegmenterService, FFmpegSegmenterService>();
|
||||
services.AddSingleton<ITempFilePool, TempFilePool>();
|
||||
services.AddSingleton<IHlsPlaylistFilter, HlsPlaylistFilter>();
|
||||
services.AddSingleton<RecyclableMemoryStreamManager>();
|
||||
AddChannel<IBackgroundServiceRequest>(services);
|
||||
AddChannel<IPlexBackgroundServiceRequest>(services);
|
||||
AddChannel<IJellyfinBackgroundServiceRequest>(services);
|
||||
@@ -417,6 +419,8 @@ public class Startup
|
||||
services.AddScoped<ITvShowNfoReader, TvShowNfoReader>();
|
||||
services.AddScoped<IOtherVideoNfoReader, OtherVideoNfoReader>();
|
||||
|
||||
services.AddScoped<PlexEtag>();
|
||||
|
||||
// services.AddTransient(typeof(IRequestHandler<,>), typeof(GetRecentLogEntriesHandler<>));
|
||||
|
||||
// run-once/blocking startup services
|
||||
|
||||
@@ -57,6 +57,8 @@ public class FillerPresetEditViewModel
|
||||
set => _padToNearestMinute = value;
|
||||
}
|
||||
|
||||
public bool AllowWatermarks { get; set; }
|
||||
|
||||
public ProgramScheduleItemCollectionType CollectionType
|
||||
{
|
||||
get => _collectionType;
|
||||
@@ -88,6 +90,7 @@ public class FillerPresetEditViewModel
|
||||
Duration.Map(FixDuration),
|
||||
Count,
|
||||
PadToNearestMinute,
|
||||
AllowWatermarks,
|
||||
CollectionType,
|
||||
Collection?.Id,
|
||||
MediaItem?.MediaItemId,
|
||||
@@ -102,6 +105,7 @@ public class FillerPresetEditViewModel
|
||||
Duration.Map(FixDuration),
|
||||
Count,
|
||||
PadToNearestMinute,
|
||||
AllowWatermarks,
|
||||
CollectionType,
|
||||
Collection?.Id,
|
||||
MediaItem?.MediaItemId,
|
||||
|
||||
@@ -9,10 +9,11 @@ public class ProgramScheduleEditViewModel
|
||||
public bool KeepMultiPartEpisodesTogether { get; set; }
|
||||
public bool TreatCollectionsAsShows { get; set; }
|
||||
public bool ShuffleScheduleItems { get; set; }
|
||||
public bool RandomStartPoint { get; set; }
|
||||
|
||||
public UpdateProgramSchedule ToUpdate() =>
|
||||
new(Id, Name, KeepMultiPartEpisodesTogether, TreatCollectionsAsShows, ShuffleScheduleItems);
|
||||
new(Id, Name, KeepMultiPartEpisodesTogether, TreatCollectionsAsShows, ShuffleScheduleItems, RandomStartPoint);
|
||||
|
||||
public CreateProgramSchedule ToCreate() =>
|
||||
new(Name, KeepMultiPartEpisodesTogether, TreatCollectionsAsShows, ShuffleScheduleItems);
|
||||
new(Name, KeepMultiPartEpisodesTogether, TreatCollectionsAsShows, ShuffleScheduleItems, RandomStartPoint);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user