Compare commits

..
Author SHA1 Message Date
Jason Dove f895ab5304 fix nuget versions 2022-05-14 06:37:22 -05:00
Jason Dove 07c54ff45f update changelog for release v0.5.7-beta [no ci] 2022-05-14 05:54:27 -05:00
Jason DoveandGitHub 6a29ce2049 update dependencies (#805) 2022-05-13 21:16:43 -05:00
Jason DoveandGitHub d19e95fb38 add random start point option (#804) 2022-05-13 20:36:03 -05:00
Jason DoveandGitHub d78daf8735 fix flood checkpoints (#803) 2022-05-13 15:31:04 -05:00
Jason DoveandGitHub 4f6522379d fix custom title scheduling (#802) 2022-05-13 13:06:05 -05:00
Jason DoveandGitHub 9e0972fec0 properly ignore plex other videos libraries (#801) 2022-05-13 12:31:34 -05:00
Jason DoveandGitHub 6d564233ed filler scheduling fix (#800) 2022-05-12 14:02:06 -05:00
Jason DoveandGitHub 47252b1243 read track from music video nfo metadata (#799) 2022-05-12 12:31:40 -05:00
Jason DoveandGitHub bd5b52922d add option to allow watermarks over filler (#796) 2022-05-09 17:51:11 -05:00
Jason DoveandGitHub 59c793b9be add option to skip missing items in playouts (#795) 2022-05-09 09:21:51 -05:00
Jason DoveandGitHub 3ad1ba01f8 add autocomplete to search bar (#791) 2022-05-08 19:58:15 -05:00
Jason DoveandGitHub ab10f0ed81 add metadata_kind to search index (#790)
* more nfo cleanup

* add metadata_kind to search index
2022-05-07 21:24:50 -05:00
Jason DoveandGitHub 44dd68fe59 nfo and memory fixes (#789)
* partial episode nfo metadata

* nfo metadata reliability fixes

* use recyclable memory streams
2022-05-07 20:32:57 -05:00
99 changed files with 14411 additions and 305 deletions
+28 -2
View File
@@ -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>>;
@@ -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,
+1
View File
@@ -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
}
@@ -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);
+13 -4
View File
@@ -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]
+1
View File
@@ -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; }
+1
View File
@@ -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; }
+1
View File
@@ -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; }
}
+3 -2
View File
@@ -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);
}
+9 -2
View File
@@ -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(
+30 -4
View File
@@ -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);
+33 -20
View File
@@ -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);
+30 -4
View File
@@ -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);
+133 -50
View File
@@ -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);
+30 -4
View File
@@ -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
};
+56 -26
View File
@@ -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>
+1 -1
View File
@@ -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>
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");
}
}
}
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");
}
}
}
@@ -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; }
}
+17 -9
View File
@@ -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>()
+16 -8
View File
@@ -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);
+1
View File
@@ -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>
+7 -7
View File
@@ -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>
+2
View File
@@ -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)
+6
View File
@@ -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"));
}
+13 -6
View File
@@ -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 =>
{
+106 -5
View File
@@ -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
};
}
+4
View File
@@ -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);
}