Compare commits

...

34 Commits

Author SHA1 Message Date
Jason Dove
0388425763 update changelog for release v0.6.0-beta [no ci] 2022-06-01 18:01:07 -05:00
Jason Dove
ca5d303ac7 fix qsv encoder regression and memory errors (#828)
* fix qsv encoders; only use 64 extra hw frames

* update changelog
2022-05-31 06:07:50 -05:00
Jason Dove
18e66a92ad add paging to media server show and collection calls (#827)
* add paging to media server show library calls

* add paging to media server season and episode library calls

* formatting

* add paging to media server collection calls

* add paging to media server collection item calls

* update changelog
2022-05-31 05:56:48 -05:00
Jason Dove
7d0a56ab98 disable lower-power mode for qsv encoders (#826) 2022-05-29 20:44:27 -05:00
Jason Dove
5069792d12 update dependencies 2022-05-28 20:41:54 -05:00
Jason Dove
c9789458b9 page media server movie libraries 2022-05-28 20:41:22 -05:00
Jason Dove
777a0d09ed hls segmenter fixes (#824)
* fix pts warning when channel first starts streaming

* rework playlist filtering
2022-05-25 21:05:55 -05:00
Jason Dove
4e2ebcc48a fix watermark opacity filter (#820) 2022-05-23 10:29:04 -05:00
Jason Dove
90fe1d7709 fix hw accel health check for qsv in vaapi docker (#818) 2022-05-22 19:42:14 -05:00
Jason Dove
1576dd026e enable qsv accel for vaapi docker images (#817) 2022-05-22 18:43:24 -05:00
Jason Dove
ee7a64eea9 fix other video libraries (#816)
* update depdendencies

* reset other video libraries
2022-05-22 12:29:35 -05:00
Jason Dove
9742e1eef7 update changelog for release v0.5.8-beta [no ci] 2022-05-20 09:11:16 -05:00
Jason Dove
a61c4b3472 fix a handful of scheduling edge cases (#814) 2022-05-18 06:00:58 -05:00
Jason Dove
ea0d43cf99 use hardware acceleration for error messages (#813)
* logging fixes

* use hardware acceleration for error messages
2022-05-18 05:44:01 -05:00
Jason Dove
fd36ea51a7 unlock ffmpeg thread count (#812)
* revert logging changes

* unlock ffmpeg thread count
2022-05-17 21:37:44 -05:00
Jason Dove
5213b71d62 add debug logging to track down playlist filtering issue (#811)
* add debug logging to track down playlist filtering issue

* revert work-ahead change
2022-05-17 15:18:12 -05:00
Jason Dove
0ba3ac7f50 fix more error stream settings (#810) 2022-05-17 11:44:12 -05:00
Jason Dove
d960fec734 error stream needs video track timescale (#809) 2022-05-17 10:26:46 -05:00
Jason Dove
f272036c6f reduce hls disk use (#808)
* reduce hls segmenter disk use

* logging improvements

* update dependencies
2022-05-17 08:43:28 -05:00
Jason Dove
9fe03b6ef3 reduce hls segmenter disk use (#806) 2022-05-16 21:45:13 -05:00
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 Dove
6a29ce2049 update dependencies (#805) 2022-05-13 21:16:43 -05:00
Jason Dove
d19e95fb38 add random start point option (#804) 2022-05-13 20:36:03 -05:00
Jason Dove
d78daf8735 fix flood checkpoints (#803) 2022-05-13 15:31:04 -05:00
Jason Dove
4f6522379d fix custom title scheduling (#802) 2022-05-13 13:06:05 -05:00
Jason Dove
9e0972fec0 properly ignore plex other videos libraries (#801) 2022-05-13 12:31:34 -05:00
Jason Dove
6d564233ed filler scheduling fix (#800) 2022-05-12 14:02:06 -05:00
Jason Dove
47252b1243 read track from music video nfo metadata (#799) 2022-05-12 12:31:40 -05:00
Jason Dove
bd5b52922d add option to allow watermarks over filler (#796) 2022-05-09 17:51:11 -05:00
Jason Dove
59c793b9be add option to skip missing items in playouts (#795) 2022-05-09 09:21:51 -05:00
Jason Dove
3ad1ba01f8 add autocomplete to search bar (#791) 2022-05-08 19:58:15 -05:00
Jason Dove
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 Dove
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
152 changed files with 20856 additions and 996 deletions

View File

@@ -5,6 +5,60 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased]
## [0.6.0-beta] - 2022-06-01
### Fixed
- Additional fix for duplicate `Other Videos` entries; trash may need to be emptied one last time after upgrading
- Fix watermark opacity in cultures where `,` is a decimal separator
- Rework playlist filtering to avoid empty playlist responses
- Fix some QSV/VAAPI memory errors by always requesting 64 extra hardware frames
### Added
- Enable QSV hardware acceleration for vaapi docker images
### Changed
- Use paging to synchronize all media from Plex, Jellyfin and Emby
- This will reduce memory use and improve reliability of synchronizing large libraries
- Disable low power mode for `h264_qsv` and `hevc_qsv` encoders
## [0.5.8-beta] - 2022-05-20
### Fixed
- Fix error display with `HLS Segmenter` and `MPEG-TS` streaming modes
- Remove erroneous log messages about normalizing framerate on channels where framerate normalization is disabled
- Fix unscheduled filler gaps that sometimes happen as playouts are automatically extended each hour
### Added
- Clean transcode cache folder on startup and after `HLS Segmenter` session terminates for any reason
### Changed
- Remove thread limitation for scenarios where it is not required
- This should give a performance boost to installations that don't use hardware acceleration
- Use hardware acceleration to display error messages where configured
## [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 `<60>` 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 +1225,11 @@ 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.6.0-beta...HEAD
[0.6.0-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.5.8-beta...v0.6.0-beta
[0.5.8-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.5.7-beta...v0.5.8-beta
[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

View File

@@ -21,11 +21,22 @@ public class GetChannelFramerateHandler : IRequestHandler<GetChannelFramerate, O
public async Task<Option<int>> Handle(GetChannelFramerate request, CancellationToken cancellationToken)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
FFmpegProfile ffmpegProfile = await dbContext.Channels
.Filter(c => c.Number == request.ChannelNumber)
.Include(c => c.FFmpegProfile)
.Map(c => c.FFmpegProfile)
.SingleAsync(cancellationToken);
if (!ffmpegProfile.NormalizeFramerate)
{
return Option<int>.None;
}
// TODO: expand to check everything in collection rather than what's scheduled?
_logger.LogDebug("Checking frame rates for channel {ChannelNumber}", request.ChannelNumber);
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
List<Playout> playouts = await dbContext.Playouts
.Include(p => p.Items)
.ThenInclude(pi => pi.MediaItem)

View File

@@ -1,15 +1,23 @@
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));
}

View File

@@ -1,5 +0,0 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.Configuration;
public record UpdatePlayoutDaysToBuild(int DaysToBuild) : IRequest<Either<BaseError, Unit>>;

View File

@@ -0,0 +1,5 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.Configuration;
public record UpdatePlayoutSettings(PlayoutSettingsViewModel PlayoutSettings) : IRequest<Either<BaseError, Unit>>;

View File

@@ -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")

View File

@@ -0,0 +1,7 @@
namespace ErsatzTV.Application.Configuration;
public class PlayoutSettingsViewModel
{
public int DaysToBuild { get; set; }
public bool SkipMissingItems { get; set; }
}

View File

@@ -1,3 +0,0 @@
namespace ErsatzTV.Application.Configuration;
public record GetPlayoutDaysToBuild : IRequest<int>;

View File

@@ -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));
}

View File

@@ -0,0 +1,3 @@
namespace ErsatzTV.Application.Configuration;
public record GetPlayoutSettings : IRequest<PlayoutSettingsViewModel>;

View File

@@ -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)
};
}
}

View File

@@ -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>

View File

@@ -11,6 +11,7 @@ public record CreateFillerPreset(
TimeSpan? Duration,
int? Count,
int? PadToNearestMinute,
bool AllowWatermarks,
ProgramScheduleItemCollectionType CollectionType,
int? CollectionId,
int? MediaItemId,

View File

@@ -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,

View File

@@ -12,6 +12,7 @@ public record UpdateFillerPreset(
TimeSpan? Duration,
int? Count,
int? PadToNearestMinute,
bool AllowWatermarks,
ProgramScheduleItemCollectionType CollectionType,
int? CollectionId,
int? MediaItemId,

View File

@@ -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;

View File

@@ -11,6 +11,7 @@ public record FillerPresetViewModel(
TimeSpan? Duration,
int? Count,
int? PadToNearestMinute,
bool AllowWatermarks,
ProgramScheduleItemCollectionType CollectionType,
int? CollectionId,
int? MediaItemId,

View File

@@ -13,6 +13,7 @@ internal static class Mapper
fillerPreset.Duration,
fillerPreset.Count,
fillerPreset.PadToNearestMinute,
fillerPreset.AllowWatermarks,
fillerPreset.CollectionType,
fillerPreset.CollectionId,
fillerPreset.MediaItemId,

View File

@@ -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>>;

View File

@@ -44,7 +44,8 @@ public class CreateProgramScheduleHandler :
Name = name,
KeepMultiPartEpisodesTogether = keepMultiPartEpisodesTogether,
TreatCollectionsAsShows = keepMultiPartEpisodesTogether && request.TreatCollectionsAsShows,
ShuffleScheduleItems = request.ShuffleScheduleItems
ShuffleScheduleItems = request.ShuffleScheduleItems,
RandomStartPoint = request.RandomStartPoint
};
});

View File

@@ -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>>;

View File

@@ -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();

View File

@@ -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

View File

@@ -5,4 +5,5 @@ public record ProgramScheduleViewModel(
string Name,
bool KeepMultiPartEpisodesTogether,
bool TreatCollectionsAsShows,
bool ShuffleScheduleItems);
bool ShuffleScheduleItems,
bool RandomStartPoint);

View File

@@ -22,7 +22,8 @@ public class GetAllProgramSchedulesHandler : IRequestHandler<GetAllProgramSchedu
ps.Name,
ps.KeepMultiPartEpisodesTogether,
ps.TreatCollectionsAsShows,
ps.ShuffleScheduleItems))
ps.ShuffleScheduleItems,
ps.RandomStartPoint))
.ToListAsync(cancellationToken);
}
}

View File

@@ -0,0 +1,3 @@
namespace ErsatzTV.Application.Search;
public record QuerySearchTargets : IRequest<List<SearchTargetViewModel>>;

View File

@@ -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;
}
}

View File

@@ -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
}

View File

@@ -21,6 +21,7 @@ public class HlsSessionWorker : IHlsSessionWorker
private static readonly SemaphoreSlim Slim = new(1, 1);
private static int _workAheadCount;
private readonly IHlsPlaylistFilter _hlsPlaylistFilter;
private readonly ILocalFileSystem _localFileSystem;
private readonly ILogger<HlsSessionWorker> _logger;
private readonly IServiceScopeFactory _serviceScopeFactory;
private readonly object _sync = new();
@@ -34,10 +35,12 @@ public class HlsSessionWorker : IHlsSessionWorker
public HlsSessionWorker(
IHlsPlaylistFilter hlsPlaylistFilter,
IServiceScopeFactory serviceScopeFactory,
ILocalFileSystem localFileSystem,
ILogger<HlsSessionWorker> logger)
{
_hlsPlaylistFilter = hlsPlaylistFilter;
_serviceScopeFactory = serviceScopeFactory;
_localFileSystem = localFileSystem;
_logger = logger;
}
@@ -153,6 +156,15 @@ public class HlsSessionWorker : IHlsSessionWorker
{
_timer.Elapsed -= Cancel;
}
try
{
_localFileSystem.EmptyFolder(Path.Combine(FileSystemLayout.TranscodeFolder, _channelNumber));
}
catch
{
// do nothing
}
}
}
@@ -178,7 +190,7 @@ public class HlsSessionWorker : IHlsSessionWorker
IMediator mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
long ptsOffset = await GetPtsOffset(mediator, _channelNumber, cancellationToken);
long ptsOffset = await GetPtsOffset(mediator, _channelNumber, _firstProcess, cancellationToken);
// _logger.LogInformation("PTS offset: {PtsOffset}", ptsOffset);
var request = new GetPlayoutItemProcessByChannelNumber(
@@ -338,11 +350,11 @@ public class HlsSessionWorker : IHlsSessionWorker
var toDelete = allSegments.Filter(s => s.SequenceNumber < trimResult.Sequence).ToList();
// if (toDelete.Count > 0)
// {
// _logger.LogInformation(
// "Deleting HLS segments {Min} to {Max} (less than {StartSequence})",
// toDelete.Map(s => s.SequenceNumber).Min(),
// toDelete.Map(s => s.SequenceNumber).Max(),
// trimResult.Sequence);
// _logger.LogDebug(
// "Deleting HLS segments {Min} to {Max} (less than {StartSequence})",
// toDelete.Map(s => s.SequenceNumber).Min(),
// toDelete.Map(s => s.SequenceNumber).Max(),
// trimResult.Sequence);
// }
foreach (Segment segment in toDelete)
@@ -359,9 +371,10 @@ public class HlsSessionWorker : IHlsSessionWorker
}
}
private static async Task<long> GetPtsOffset(
private async Task<long> GetPtsOffset(
IMediator mediator,
string channelNumber,
bool firstProcess,
CancellationToken cancellationToken)
{
await Slim.WaitAsync(cancellationToken);
@@ -369,10 +382,21 @@ public class HlsSessionWorker : IHlsSessionWorker
{
long result = 0;
// the first process always starts at zero
if (firstProcess)
{
return result;
}
Either<BaseError, PtsAndDuration> queryResult = await mediator.Send(
new GetLastPtsDuration(channelNumber),
cancellationToken);
foreach (BaseError error in queryResult.LeftToSeq())
{
_logger.LogWarning("Unable to determine last pts offset - {Error}", error.ToString());
}
foreach ((long pts, long duration) in queryResult.RightToSeq())
{
result = pts + duration;

View File

@@ -31,7 +31,9 @@ public class GetErrorProcessHandler : FFmpegProcessHandler<GetErrorProcess>
request.MaybeDuration,
request.ErrorMessage,
request.HlsRealtime,
request.PtsOffset);
request.PtsOffset,
channel.FFmpegProfile.VaapiDriver,
channel.FFmpegProfile.VaapiDevice);
return new PlayoutItemProcessModel(process, request.MaybeDuration, request.Until);
}

View File

@@ -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,
@@ -220,7 +221,9 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
maybeDuration,
"Channel is Offline",
request.HlsRealtime,
request.PtsOffset);
request.PtsOffset,
channel.FFmpegProfile.VaapiDriver,
channel.FFmpegProfile.VaapiDevice);
return new PlayoutItemProcessModel(offlineProcess, maybeDuration, finish);
case PlayoutItemDoesNotExistOnDisk:
@@ -230,7 +233,9 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
maybeDuration,
error.Value,
request.HlsRealtime,
request.PtsOffset);
request.PtsOffset,
channel.FFmpegProfile.VaapiDriver,
channel.FFmpegProfile.VaapiDevice);
return new PlayoutItemProcessModel(doesNotExistProcess, maybeDuration, finish);
default:
@@ -240,7 +245,9 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
maybeDuration,
"Channel is Offline",
request.HlsRealtime,
request.PtsOffset);
request.PtsOffset,
channel.FFmpegProfile.VaapiDriver,
channel.FFmpegProfile.VaapiDevice);
return new PlayoutItemProcessModel(errorProcess, maybeDuration, finish);
}
@@ -371,7 +378,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);

View File

@@ -9,19 +9,19 @@
<ItemGroup>
<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="FluentAssertions" Version="6.7.0" />
<PackageReference Include="LanguageExt.Core" Version="4.1.1" />
<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.1" />
<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>

View File

@@ -20,7 +20,7 @@ public class HlsPlaylistFilterTests
private HlsPlaylistFilter _hlsPlaylistFilter;
[Test]
public void _hlsPlaylistFilter_ShouldRewriteProgramDateTime()
public void HlsPlaylistFilter_ShouldRewriteProgramDateTime()
{
var start = new DateTimeOffset(2021, 10, 9, 8, 0, 0, TimeSpan.FromHours(-5));
string[] input = NormalizeLineEndings(
@@ -50,9 +50,8 @@ live001139.ts").Split(Environment.NewLine);
#EXT-X-VERSION:6
#EXT-X-TARGETDURATION:4
#EXT-X-MEDIA-SEQUENCE:1137
#EXT-X-DISCONTINUITY-SEQUENCE:0
#EXT-X-DISCONTINUITY-SEQUENCE:1
#EXT-X-INDEPENDENT-SEGMENTS
#EXT-X-DISCONTINUITY
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-09T08:00:00.000-0500
live001137.ts
@@ -66,7 +65,7 @@ live001139.ts
}
[Test]
public void _hlsPlaylistFilter_ShouldLimitSegments()
public void HlsPlaylistFilter_ShouldLimitSegments()
{
var start = new DateTimeOffset(2021, 10, 9, 8, 0, 0, TimeSpan.FromHours(-5));
string[] input = NormalizeLineEndings(
@@ -96,9 +95,8 @@ live001139.ts").Split(Environment.NewLine);
#EXT-X-VERSION:6
#EXT-X-TARGETDURATION:4
#EXT-X-MEDIA-SEQUENCE:1137
#EXT-X-DISCONTINUITY-SEQUENCE:0
#EXT-X-DISCONTINUITY-SEQUENCE:1
#EXT-X-INDEPENDENT-SEGMENTS
#EXT-X-DISCONTINUITY
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-09T08:00:00.000-0500
live001137.ts
@@ -109,7 +107,7 @@ live001138.ts
}
[Test]
public void _hlsPlaylistFilter_ShouldAddDiscontinuity()
public void HlsPlaylistFilter_ShouldAddDiscontinuity()
{
var start = new DateTimeOffset(2021, 10, 9, 8, 0, 0, TimeSpan.FromHours(-5));
string[] input = NormalizeLineEndings(
@@ -144,9 +142,8 @@ live001139.ts").Split(Environment.NewLine);
#EXT-X-VERSION:6
#EXT-X-TARGETDURATION:4
#EXT-X-MEDIA-SEQUENCE:1137
#EXT-X-DISCONTINUITY-SEQUENCE:0
#EXT-X-DISCONTINUITY-SEQUENCE:1
#EXT-X-INDEPENDENT-SEGMENTS
#EXT-X-DISCONTINUITY
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-09T08:00:00.000-0500
live001137.ts
@@ -161,7 +158,7 @@ live001139.ts
}
[Test]
public void _hlsPlaylistFilter_ShouldFilterOldSegments()
public void HlsPlaylistFilter_ShouldFilterOldSegmentsBeyondMax()
{
var start = new DateTimeOffset(2021, 10, 9, 8, 0, 0, TimeSpan.FromHours(-5));
string[] input = NormalizeLineEndings(
@@ -181,48 +178,7 @@ live001138.ts
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:57.320-0500
live001139.ts").Split(Environment.NewLine);
TrimPlaylistResult result = _hlsPlaylistFilter.TrimPlaylist(start, start.AddSeconds(6), input);
result.PlaylistStart.Should().Be(start.AddSeconds(8));
result.Sequence.Should().Be(1139);
result.Playlist.Should().Be(
NormalizeLineEndings(
@"#EXTM3U
#EXT-X-VERSION:6
#EXT-X-TARGETDURATION:4
#EXT-X-MEDIA-SEQUENCE:1139
#EXT-X-DISCONTINUITY-SEQUENCE:0
#EXT-X-INDEPENDENT-SEGMENTS
#EXT-X-DISCONTINUITY
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-09T08:00:08.000-0500
live001139.ts
"));
}
[Test]
public void _hlsPlaylistFilter_ShouldFilterOldDiscontinuity()
{
var start = new DateTimeOffset(2021, 10, 9, 8, 0, 0, TimeSpan.FromHours(-5));
string[] input = NormalizeLineEndings(
@"#EXTM3U
#EXT-X-VERSION:6
#EXT-X-TARGETDURATION:4
#EXT-X-MEDIA-SEQUENCE:1137
#EXT-X-INDEPENDENT-SEGMENTS
#EXT-X-DISCONTINUITY
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:49.320-0500
live001137.ts
#EXT-X-DISCONTINUITY
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:53.320-0500
live001138.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:57.320-0500
live001139.ts").Split(Environment.NewLine);
TrimPlaylistResult result = _hlsPlaylistFilter.TrimPlaylist(start, start.AddSeconds(6), input);
TrimPlaylistResult result = _hlsPlaylistFilter.TrimPlaylist(start, start.AddSeconds(6), input, 1);
result.PlaylistStart.Should().Be(start.AddSeconds(8));
result.Sequence.Should().Be(1139);
@@ -234,13 +190,367 @@ live001139.ts").Split(Environment.NewLine);
#EXT-X-MEDIA-SEQUENCE:1139
#EXT-X-DISCONTINUITY-SEQUENCE:1
#EXT-X-INDEPENDENT-SEGMENTS
#EXT-X-DISCONTINUITY
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-09T08:00:08.000-0500
live001139.ts
"));
}
[Test]
public void HlsPlaylistFilter_ShouldFilterOldDiscontinuity()
{
var start = new DateTimeOffset(2021, 10, 9, 8, 0, 0, TimeSpan.FromHours(-5));
string[] input = NormalizeLineEndings(
@"#EXTM3U
#EXT-X-VERSION:6
#EXT-X-TARGETDURATION:4
#EXT-X-MEDIA-SEQUENCE:1137
#EXT-X-INDEPENDENT-SEGMENTS
#EXT-X-DISCONTINUITY
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:49.320-0500
live001137.ts
#EXT-X-DISCONTINUITY
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:53.320-0500
live001138.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:57.320-0500
live001139.ts").Split(Environment.NewLine);
TrimPlaylistResult result = _hlsPlaylistFilter.TrimPlaylist(start, start.AddSeconds(6), input);
result.PlaylistStart.Should().Be(start);
result.Sequence.Should().Be(1137);
result.Playlist.Should().Be(
NormalizeLineEndings(
@"#EXTM3U
#EXT-X-VERSION:6
#EXT-X-TARGETDURATION:4
#EXT-X-MEDIA-SEQUENCE:1137
#EXT-X-DISCONTINUITY-SEQUENCE:1
#EXT-X-INDEPENDENT-SEGMENTS
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-09T08:00:00.000-0500
live001137.ts
#EXT-X-DISCONTINUITY
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-09T08:00:04.000-0500
live001138.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-09T08:00:08.000-0500
live001139.ts
"));
}
[Test]
public void HlsPlaylistFilter_Should_Increment_DiscontinuityCount()
{
var start = new DateTimeOffset(2022, 5, 25, 20, 8, 0, TimeSpan.FromHours(-5));
string[] input = NormalizeLineEndings(
@"#EXTM3U
#EXT-X-VERSION:6
#EXT-X-TARGETDURATION:4
#EXT-X-MEDIA-SEQUENCE:0
#EXT-X-DISCONTINUITY
#EXT-X-INDEPENDENT-SEGMENTS
#EXT-X-DISCONTINUITY
#EXTINF:4.004000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:08:55.981-0500
live000000.ts
#EXTINF:4.004000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:08:59.985-0500
live000001.ts
#EXTINF:4.004000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:09:03.989-0500
live000002.ts
#EXTINF:4.004000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:09:07.993-0500
live000003.ts
#EXTINF:4.004000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:09:11.997-0500
live000004.ts
#EXTINF:4.004000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:09:16.001-0500
live000005.ts
#EXTINF:4.004000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:09:20.005-0500
live000006.ts
#EXTINF:4.004000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:09:24.009-0500
live000007.ts
#EXTINF:3.970633,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:09:28.013-0500
live000008.ts
#EXTINF:4.004000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:09:31.983-0500
live000009.ts
#EXTINF:4.004000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:09:35.987-0500
live000010.ts
#EXTINF:4.004000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:09:39.991-0500
live000011.ts
#EXTINF:4.004000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:09:43.995-0500
live000012.ts
#EXTINF:4.004000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:09:47.999-0500
live000013.ts
#EXTINF:4.004000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:09:52.003-0500
live000014.ts
#EXTINF:4.004000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:09:56.007-0500
live000015.ts
#EXTINF:3.970633,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:10:00.011-0500
live000016.ts
#EXTINF:4.004000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:10:03.982-0500
live000017.ts
#EXTINF:4.004000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:10:07.986-0500
live000018.ts
#EXTINF:4.004000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:10:11.990-0500
live000019.ts
#EXTINF:4.004000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:10:15.994-0500
live000020.ts
#EXTINF:4.004000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:10:19.998-0500
live000021.ts
#EXTINF:4.004000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:10:24.002-0500
live000022.ts
#EXTINF:4.004000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:10:28.006-0500
live000023.ts
#EXTINF:4.004000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:10:32.010-0500
live000024.ts
#EXTINF:3.970633,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:10:36.014-0500
live000025.ts
#EXTINF:4.004000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:10:39.985-0500
live000026.ts
#EXTINF:4.004000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:10:43.989-0500
live000027.ts
#EXTINF:4.004000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:10:47.993-0500
live000028.ts
#EXTINF:4.004000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:10:51.997-0500
live000029.ts
#EXTINF:4.004000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:10:56.001-0500
live000030.ts
#EXTINF:4.004000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:11:00.005-0500
live000031.ts
#EXTINF:4.004000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:11:04.009-0500
live000032.ts
#EXTINF:3.970633,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:11:08.013-0500
live000033.ts
#EXTINF:4.004000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:11:11.983-0500
live000034.ts
#EXTINF:4.004000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:11:15.987-0500
live000035.ts
#EXTINF:4.004000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:11:19.991-0500
live000036.ts
#EXTINF:4.004000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:11:23.995-0500
live000037.ts
#EXTINF:4.004000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:11:27.999-0500
live000038.ts
#EXTINF:4.004000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:11:32.003-0500
live000039.ts
#EXTINF:4.004000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:11:36.007-0500
live000040.ts
#EXTINF:3.970633,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:11:40.011-0500
live000041.ts
#EXTINF:4.004000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:11:43.982-0500
live000042.ts
#EXTINF:4.004000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:11:47.986-0500
live000043.ts
#EXTINF:4.004000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:11:51.990-0500
live000044.ts
#EXTINF:4.004000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:11:55.994-0500
live000045.ts
#EXTINF:4.004000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:11:59.998-0500
live000046.ts
#EXTINF:4.004000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:12:04.002-0500
live000047.ts
#EXTINF:4.004000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:12:08.006-0500
live000048.ts
#EXTINF:4.004000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:12:12.010-0500
live000049.ts
#EXTINF:3.970633,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:12:16.014-0500
live000050.ts
#EXTINF:4.004000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:12:19.985-0500
live000051.ts
#EXTINF:0.433767,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:12:23.989-0500
live000052.ts
#EXT-X-DISCONTINUITY
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:11:30.007-0500
live000053.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:11:34.007-0500
live000054.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:11:38.007-0500
live000055.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:11:42.007-0500
live000056.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:11:46.007-0500
live000057.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:11:50.007-0500
live000058.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:11:54.007-0500
live000059.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:11:58.007-0500
live000060.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:12:02.007-0500
live000061.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:12:06.007-0500
live000062.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:12:10.007-0500
live000063.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:12:14.007-0500
live000064.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:12:18.007-0500
live000065.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:12:22.007-0500
live000066.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:12:26.007-0500
live000067.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:12:30.007-0500
live000068.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:12:34.007-0500
live000069.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:12:38.007-0500
live000070.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:12:42.007-0500
live000071.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:12:46.007-0500
live000072.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:12:50.007-0500
live000073.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:12:54.007-0500
live000074.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:12:58.007-0500
live000075.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:13:02.007-0500
live000076.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:13:06.007-0500
live000077.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:13:10.007-0500
live000078.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:13:14.007-0500
live000079.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:13:18.007-0500
live000080.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:13:22.007-0500
live000081.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:13:26.007-0500
live000082.ts").Split(Environment.NewLine);
TrimPlaylistResult result = _hlsPlaylistFilter.TrimPlaylist(start, start.AddSeconds(220), input);
// result.PlaylistStart.Should().Be(start);
result.Sequence.Should().Be(56);
result.Playlist.Should().Be(
NormalizeLineEndings(
@"#EXTM3U
#EXT-X-VERSION:6
#EXT-X-TARGETDURATION:4
#EXT-X-MEDIA-SEQUENCE:56
#EXT-X-DISCONTINUITY-SEQUENCE:2
#EXT-X-INDEPENDENT-SEGMENTS
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:11:40.441-0500
live000056.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:11:44.441-0500
live000057.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:11:48.441-0500
live000058.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:11:52.441-0500
live000059.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:11:56.441-0500
live000060.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:12:00.441-0500
live000061.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:12:04.441-0500
live000062.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:12:08.441-0500
live000063.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:12:12.441-0500
live000064.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:12:16.441-0500
live000065.ts
"));
}
private static string NormalizeLineEndings(string str) =>
str
.Replace("\r\n", "\n")

View File

@@ -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)}");

View File

@@ -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")

View File

@@ -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");
}
}
}

View File

@@ -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;

View File

@@ -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);
}
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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—¯¬}É

View File

@@ -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>

View File

@@ -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—¯¬}É

View File

@@ -68,6 +68,190 @@ public class PlayoutBuilderTests
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));
result.Anchor.NextStartOffset.Should().Be(HoursAfterMidnight(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));
result.Anchor.NextStartOffset.Should().Be(HoursAfterMidnight(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));
result.Anchor.NextStartOffset.Should().Be(HoursAfterMidnight(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));
result.Anchor.NextStartOffset.Should().Be(HoursAfterMidnight(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));
result.Anchor.NextStartOffset.Should().Be(HoursAfterMidnight(6));
}
[Test]
@@ -87,6 +271,8 @@ public class PlayoutBuilderTests
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));
result.Anchor.NextStartOffset.Should().Be(HoursAfterMidnight(6));
}
[Test]
@@ -107,6 +293,8 @@ public class PlayoutBuilderTests
result.Items[0].StartOffset.TimeOfDay.Should().Be(TimeSpan.Zero);
result.Items[1].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(6));
result.Items[1].FinishOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(12));
result.Anchor.NextStartOffset.Should().Be(HoursAfterMidnight(12));
}
[Test]
@@ -134,6 +322,8 @@ public class PlayoutBuilderTests
result.Items[2].MediaItemId.Should().Be(1);
result.Items[3].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(3));
result.Items[3].MediaItemId.Should().Be(2);
result.Anchor.NextStartOffset.Should().Be(HoursAfterMidnight(4));
}
[Test]
@@ -155,7 +345,7 @@ public class PlayoutBuilderTests
result.Items.Count.Should().Be(1);
result.Items.Head().MediaItemId.Should().Be(1);
result.Anchor.NextStartOffset.Should().Be(DateTime.Today.AddHours(6));
result.Anchor.NextStartOffset.Should().Be(HoursAfterMidnight(6));
result.ProgramScheduleAnchors.Count.Should().Be(1);
result.ProgramScheduleAnchors.Head().EnumeratorState.Index.Should().Be(1);
@@ -169,7 +359,7 @@ public class PlayoutBuilderTests
result2.Items.Last().StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(6));
result2.Items.Last().MediaItemId.Should().Be(2);
result2.Anchor.NextStartOffset.Should().Be(DateTime.Today.AddHours(12));
result2.Anchor.NextStartOffset.Should().Be(HoursAfterMidnight(12));
result2.ProgramScheduleAnchors.Count.Should().Be(1);
result2.ProgramScheduleAnchors.Head().EnumeratorState.Index.Should().Be(0);
}
@@ -278,6 +468,8 @@ public class PlayoutBuilderTests
int firstSeedValue = result.ProgramScheduleAnchors.Head().EnumeratorState.Seed;
result.Anchor.NextStartOffset.Should().Be(HoursAfterMidnight(6));
DateTimeOffset start2 = HoursAfterMidnight(0);
DateTimeOffset finish2 = start2 + TimeSpan.FromHours(6);
@@ -286,6 +478,8 @@ public class PlayoutBuilderTests
int secondSeedValue = result2.ProgramScheduleAnchors.Head().EnumeratorState.Seed;
firstSeedValue.Should().Be(secondSeedValue);
result2.Anchor.NextStartOffset.Should().Be(HoursAfterMidnight(6));
}
[Test]
@@ -374,6 +568,144 @@ public class PlayoutBuilderTests
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.Anchor.NextStartOffset.Should().Be(HoursAfterMidnight(6));
}
[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);
result.Anchor.NextStartOffset.Should().Be(HoursAfterMidnight(30));
}
[Test]
@@ -469,6 +801,8 @@ public class PlayoutBuilderTests
result.Items[5].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(6));
result.Items[5].MediaItemId.Should().Be(2);
result.Anchor.NextStartOffset.Should().Be(HoursAfterMidnight(7));
}
[Test]
@@ -562,6 +896,8 @@ public class PlayoutBuilderTests
result.Items[5].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(12));
result.Items[5].MediaItemId.Should().Be(3);
result.Anchor.NextStartOffset.Should().Be(HoursAfterMidnight(31));
}
[Test]
@@ -666,6 +1002,8 @@ public class PlayoutBuilderTests
result.Items[4].MediaItemId.Should().Be(2);
result.Anchor.InFlood.Should().BeTrue();
result.Anchor.NextStartOffset.Should().Be(HoursAfterMidnight(32));
}
[Test]
@@ -765,6 +1103,8 @@ public class PlayoutBuilderTests
result.Items[5].MediaItemId.Should().Be(1);
result.Items[6].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(5.75));
result.Items[6].MediaItemId.Should().Be(2);
result.Anchor.NextStartOffset.Should().Be(DateTime.Today.AddHours(6.75));
}
[Test]
@@ -863,6 +1203,8 @@ public class PlayoutBuilderTests
result.Items[5].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(4.75));
result.Items[5].MediaItemId.Should().Be(4);
result.Anchor.NextStartOffset.Should().Be(DateTime.Today.AddHours(6.25));
}
[Test]
@@ -967,6 +1309,7 @@ public class PlayoutBuilderTests
result.Anchor.ScheduleItemsEnumeratorState.Index.Should().Be(1);
result.Anchor.MultipleRemaining.Should().Be(1);
result.Anchor.NextStartOffset.Should().Be(HoursAfterMidnight(5));
}
[Test]
@@ -1066,6 +1409,7 @@ public class PlayoutBuilderTests
result.Anchor.ScheduleItemsEnumeratorState.Index.Should().Be(0);
result.Anchor.MultipleRemaining.Should().BeNull();
result.Anchor.NextStartOffset.Should().Be(HoursAfterMidnight(5));
}
[Test]
@@ -1172,6 +1516,7 @@ public class PlayoutBuilderTests
result.Anchor.ScheduleItemsEnumeratorState.Index.Should().Be(1);
result.Anchor.DurationFinish.Should().Be(HoursAfterMidnight(6).UtcDateTime);
result.Anchor.NextStartOffset.Should().Be(HoursAfterMidnight(5));
}
[Test]
@@ -1309,6 +1654,96 @@ public class PlayoutBuilderTests
result.Anchor.ScheduleItemsEnumeratorState.Index.Should().Be(0);
result.Anchor.DurationFinish.Should().BeNull();
result.Anchor.NextStartOffset.Should().Be(HoursAfterMidnight(6));
}
[Test]
public async Task Multiple_With_Filler_Should_Keep_Filler_After_End_Of_Playout()
{
var collectionOne = new Collection
{
Id = 1,
Name = "Duration Items 1",
MediaItems = new List<MediaItem>
{
TestMovie(1, TimeSpan.FromMinutes(61), new DateTime(2020, 1, 1))
}
};
var collectionTwo = new Collection
{
Id = 2,
Name = "Filler Items",
MediaItems = new List<MediaItem>
{
TestMovie(2, TimeSpan.FromMinutes(4), new DateTime(2020, 1, 1))
}
};
var fakeRepository = new FakeMediaCollectionRepository(
Map(
(collectionOne.Id, collectionOne.MediaItems.ToList()),
(collectionTwo.Id, collectionTwo.MediaItems.ToList())));
var items = new List<ProgramScheduleItem>
{
new ProgramScheduleItemMultiple
{
Id = 1,
Index = 1,
Collection = collectionOne,
CollectionId = collectionOne.Id,
StartTime = null,
Count = 1,
PlaybackOrder = PlaybackOrder.Chronological,
PostRollFiller = new FillerPreset
{
FillerKind = FillerKind.PostRoll,
Collection = collectionTwo,
CollectionId = collectionTwo.Id,
FillerMode = FillerMode.Count,
Count = 1
}
}
};
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(1);
Playout result = await builder.Build(playout, PlayoutBuildMode.Reset, start, finish);
result.Items.Count.Should().Be(2);
result.Items[0].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromMinutes(0));
result.Items[0].MediaItemId.Should().Be(1);
result.Items[1].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromMinutes(61));
result.Items[1].MediaItemId.Should().Be(2);
result.Anchor.ScheduleItemsEnumeratorState.Index.Should().Be(0);
result.Anchor.DurationFinish.Should().BeNull();
result.Anchor.NextStartOffset.Should().Be(DateTime.Today.AddMinutes(65));
}
[Test]
@@ -1388,6 +1823,7 @@ public class PlayoutBuilderTests
result.Anchor.ScheduleItemsEnumeratorState.Index.Should().Be(0);
result.Anchor.DurationFinish.Should().BeNull();
result.Anchor.NextStartOffset.Should().Be(HoursAfterMidnight(6));
}
[Test]
@@ -1438,6 +1874,8 @@ public class PlayoutBuilderTests
int seed = result.ProgramScheduleAnchors[0].EnumeratorState.Seed;
result.ProgramScheduleAnchors.All(a => a.EnumeratorState.Seed == seed).Should().BeTrue();
result.Anchor.NextStartOffset.Should().Be(DateTime.Today.AddDays(2));
}
}
@@ -1590,6 +2028,8 @@ public class PlayoutBuilderTests
result.Items[3].MediaItemId.Should().Be(2);
result.Items[3].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(18));
result.Items[3].FinishOffset.TimeOfDay.Should().Be(TimeSpan.Zero);
result.Anchor.NextStartOffset.Should().Be(HoursAfterMidnight(48));
}
}
@@ -1906,6 +2346,7 @@ public class PlayoutBuilderTests
result.Items[4].MediaItemId.Should().Be(2);
result.Anchor.InFlood.Should().BeTrue();
result.Anchor.NextStartOffset.Should().Be(HoursAfterMidnight(32));
}
[Test]
@@ -2010,6 +2451,7 @@ public class PlayoutBuilderTests
result.Anchor.ScheduleItemsEnumeratorState.Index.Should().Be(1);
result.Anchor.MultipleRemaining.Should().Be(1);
result.Anchor.NextStartOffset.Should().Be(HoursAfterMidnight(5));
}
[Test]
@@ -2116,6 +2558,7 @@ public class PlayoutBuilderTests
result.Anchor.ScheduleItemsEnumeratorState.Index.Should().Be(1);
result.Anchor.DurationFinish.Should().Be(HoursAfterMidnight(6).UtcDateTime);
result.Anchor.NextStartOffset.Should().Be(HoursAfterMidnight(5));
}
}
@@ -2142,11 +2585,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 +2606,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>();

View File

@@ -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]
@@ -691,4 +695,60 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
playoutItems[6].FillerKind.Should().Be(FillerKind.Fallback);
playoutItems[6].GuideFinish.HasValue.Should().BeFalse();
}
[Test]
public void Should_Not_Schedule_At_HardStop()
{
Collection collectionOne = TwoItemCollection(1, 2, TimeSpan.FromMinutes(55));
var scheduleItem = new ProgramScheduleItemDuration
{
Id = 1,
Index = 1,
Collection = collectionOne,
CollectionId = collectionOne.Id,
StartTime = TimeSpan.FromHours(6),
PlaybackOrder = PlaybackOrder.Chronological,
TailFiller = null,
FallbackFiller = null,
PlayoutDuration = TimeSpan.FromHours(1)
};
var enumerator = new ChronologicalMediaCollectionEnumerator(
collectionOne.MediaItems,
new CollectionEnumeratorState());
var sortedScheduleItems = new List<ProgramScheduleItem>
{
scheduleItem,
NextScheduleItem
};
var scheduleItemsEnumerator = new OrderedScheduleItemsEnumerator(
sortedScheduleItems,
new CollectionEnumeratorState());
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
var scheduler = new PlayoutModeSchedulerDuration(new Mock<ILogger>().Object);
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule(
startState,
CollectionEnumerators(scheduleItem, enumerator),
scheduleItem,
NextScheduleItem,
HardStop(scheduleItemsEnumerator));
playoutItems.Should().BeEmpty();
playoutBuilderState.CurrentTime.Should().Be(HardStop(scheduleItemsEnumerator));
playoutBuilderState.NextGuideGroup.Should().Be(1);
playoutBuilderState.DurationFinish.IsNone.Should().BeTrue();
playoutBuilderState.InFlood.Should().BeFalse();
playoutBuilderState.MultipleRemaining.IsNone.Should().BeTrue();
playoutBuilderState.InDurationFiller.Should().BeFalse();
playoutBuilderState.ScheduleItemsEnumerator.State.Index.Should().Be(0);
enumerator.State.Index.Should().Be(0);
}
}

View File

@@ -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]
@@ -827,6 +831,61 @@ public class PlayoutModeSchedulerFloodTests : SchedulerTestBase
playoutItems[2].FillerKind.Should().Be(FillerKind.None);
}
[Test]
public void Should_Not_Schedule_At_HardStop()
{
Collection collectionOne = TwoItemCollection(1, 2, TimeSpan.FromMinutes(55));
var scheduleItem = new ProgramScheduleItemFlood
{
Id = 1,
Index = 1,
Collection = collectionOne,
CollectionId = collectionOne.Id,
StartTime = TimeSpan.FromHours(6),
PlaybackOrder = PlaybackOrder.Chronological,
TailFiller = null,
FallbackFiller = null
};
var enumerator = new ChronologicalMediaCollectionEnumerator(
collectionOne.MediaItems,
new CollectionEnumeratorState());
var sortedScheduleItems = new List<ProgramScheduleItem>
{
scheduleItem,
NextScheduleItem
};
var scheduleItemsEnumerator = new OrderedScheduleItemsEnumerator(
sortedScheduleItems,
new CollectionEnumeratorState());
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
var scheduler = new PlayoutModeSchedulerFlood(new Mock<ILogger>().Object);
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule(
startState,
CollectionEnumerators(scheduleItem, enumerator),
scheduleItem,
NextScheduleItem,
HardStop(scheduleItemsEnumerator));
playoutItems.Should().BeEmpty();
playoutBuilderState.CurrentTime.Should().Be(HardStop(scheduleItemsEnumerator));
playoutBuilderState.NextGuideGroup.Should().Be(1);
playoutBuilderState.DurationFinish.IsNone.Should().BeTrue();
playoutBuilderState.InFlood.Should().BeFalse();
playoutBuilderState.MultipleRemaining.IsNone.Should().BeTrue();
playoutBuilderState.InDurationFiller.Should().BeFalse();
playoutBuilderState.ScheduleItemsEnumerator.State.Index.Should().Be(0);
enumerator.State.Index.Should().Be(0);
}
protected override ProgramScheduleItem NextScheduleItem => new ProgramScheduleItemOne
{
StartTime = TimeSpan.FromHours(3)

View File

@@ -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]
@@ -660,6 +664,67 @@ public class PlayoutModeSchedulerMultipleTests : SchedulerTestBase
playoutItems[2].FillerKind.Should().Be(FillerKind.None);
}
[Test]
public void Should_Not_Schedule_At_HardStop()
{
Collection collectionOne = TwoItemCollection(1, 2, TimeSpan.FromMinutes(55));
var scheduleItem = new ProgramScheduleItemMultiple
{
Id = 1,
Index = 1,
Collection = collectionOne,
CollectionId = collectionOne.Id,
StartTime = TimeSpan.FromHours(6),
PlaybackOrder = PlaybackOrder.Chronological,
TailFiller = null,
FallbackFiller = null,
Count = 2
};
var enumerator = new ChronologicalMediaCollectionEnumerator(
collectionOne.MediaItems,
new CollectionEnumeratorState());
var sortedScheduleItems = new List<ProgramScheduleItem>
{
scheduleItem,
NextScheduleItem
};
var scheduleItemsEnumerator = new OrderedScheduleItemsEnumerator(
sortedScheduleItems,
new CollectionEnumeratorState());
var collectionMediaItems = new Dictionary<CollectionKey, List<MediaItem>>
{
{ CollectionKey.ForScheduleItem(scheduleItem), collectionOne.MediaItems }
}.ToMap();
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
var scheduler = new PlayoutModeSchedulerMultiple(collectionMediaItems, new Mock<ILogger>().Object);
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule(
startState,
CollectionEnumerators(scheduleItem, enumerator),
scheduleItem,
NextScheduleItem,
HardStop(scheduleItemsEnumerator));
playoutItems.Should().BeEmpty();
playoutBuilderState.CurrentTime.Should().Be(HardStop(scheduleItemsEnumerator));
playoutBuilderState.NextGuideGroup.Should().Be(1);
playoutBuilderState.DurationFinish.IsNone.Should().BeTrue();
playoutBuilderState.InFlood.Should().BeFalse();
playoutBuilderState.MultipleRemaining.IsNone.Should().BeTrue();
playoutBuilderState.InDurationFiller.Should().BeFalse();
playoutBuilderState.ScheduleItemsEnumerator.State.Index.Should().Be(0);
enumerator.State.Index.Should().Be(0);
}
protected override ProgramScheduleItem NextScheduleItem => new ProgramScheduleItemOne
{
StartTime = TimeSpan.FromHours(3)

View File

@@ -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]
@@ -767,6 +769,61 @@ public class PlayoutModeSchedulerOneTests : SchedulerTestBase
playoutItems[3].FillerKind.Should().Be(FillerKind.PostRoll);
}
[Test]
public void Should_Not_Schedule_At_HardStop()
{
Collection collectionOne = TwoItemCollection(1, 2, TimeSpan.FromMinutes(55));
var scheduleItem = new ProgramScheduleItemOne
{
Id = 1,
Index = 1,
Collection = collectionOne,
CollectionId = collectionOne.Id,
StartTime = TimeSpan.FromHours(6),
PlaybackOrder = PlaybackOrder.Chronological,
TailFiller = null,
FallbackFiller = null
};
var enumerator = new ChronologicalMediaCollectionEnumerator(
collectionOne.MediaItems,
new CollectionEnumeratorState());
var sortedScheduleItems = new List<ProgramScheduleItem>
{
scheduleItem,
NextScheduleItem
};
var scheduleItemsEnumerator = new OrderedScheduleItemsEnumerator(
sortedScheduleItems,
new CollectionEnumeratorState());
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
var scheduler = new PlayoutModeSchedulerOne(new Mock<ILogger>().Object);
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule(
startState,
CollectionEnumerators(scheduleItem, enumerator),
scheduleItem,
NextScheduleItem,
HardStop(scheduleItemsEnumerator));
playoutItems.Should().BeEmpty();
playoutBuilderState.CurrentTime.Should().Be(HardStop(scheduleItemsEnumerator));
playoutBuilderState.NextGuideGroup.Should().Be(1);
playoutBuilderState.DurationFinish.IsNone.Should().BeTrue();
playoutBuilderState.InFlood.Should().BeFalse();
playoutBuilderState.MultipleRemaining.IsNone.Should().BeTrue();
playoutBuilderState.InDurationFiller.Should().BeFalse();
playoutBuilderState.ScheduleItemsEnumerator.State.Index.Should().Be(0);
enumerator.State.Index.Should().Be(0);
}
protected override ProgramScheduleItem NextScheduleItem => new ProgramScheduleItemOne
{
StartTime = TimeSpan.FromHours(3)

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");
}

View File

@@ -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; }

View File

@@ -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; }

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; }

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; }
}

View File

@@ -30,28 +30,22 @@ public class EmbyCollectionScanner : IEmbyCollectionScanner
public async Task<Either<BaseError, Unit>> ScanCollections(string address, string apiKey)
{
// get all collections from db (item id, etag)
List<EmbyCollection> existingCollections = await _embyCollectionRepository.GetCollections();
// get all collections from emby
Either<BaseError, List<EmbyCollection>> maybeIncomingCollections =
await _embyApiClient.GetCollectionLibraryItems(address, apiKey);
foreach (BaseError error in maybeIncomingCollections.LeftToSeq())
try
{
_logger.LogWarning("Failed to get collections from Emby: {Error}", error.ToString());
return error;
}
var incomingItemIds = new List<string>();
foreach (List<EmbyCollection> incomingCollections in maybeIncomingCollections.RightToSeq())
{
// loop over collections
foreach (EmbyCollection collection in incomingCollections)
// get all collections from db (item id, etag)
List<EmbyCollection> existingCollections = await _embyCollectionRepository.GetCollections();
await foreach (EmbyCollection collection in _embyApiClient.GetCollectionLibraryItems(address, apiKey))
{
incomingItemIds.Add(collection.ItemId);
Option<EmbyCollection> maybeExisting = existingCollections.Find(c => c.ItemId == collection.ItemId);
// skip if unchanged (etag)
if (await maybeExisting.Map(e => e.Etag ?? string.Empty).IfNoneAsync(string.Empty) == collection.Etag)
if (await maybeExisting.Map(e => e.Etag ?? string.Empty).IfNoneAsync(string.Empty) ==
collection.Etag)
{
_logger.LogDebug("Emby collection {Name} is unchanged", collection.Name);
continue;
@@ -75,12 +69,16 @@ public class EmbyCollectionScanner : IEmbyCollectionScanner
}
// remove missing collections (and remove any lingering tags from those collections)
foreach (EmbyCollection collection in existingCollections
.Filter(e => incomingCollections.All(i => i.ItemId != e.ItemId)))
foreach (EmbyCollection collection in existingCollections.Filter(e => !incomingItemIds.Contains(e.ItemId)))
{
await _embyCollectionRepository.RemoveCollection(collection);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to get collections from Emby");
return BaseError.New(ex.Message);
}
return Unit.Default;
}
@@ -90,32 +88,31 @@ public class EmbyCollectionScanner : IEmbyCollectionScanner
string apiKey,
EmbyCollection collection)
{
// get collection items from JF
Either<BaseError, List<MediaItem>> maybeItems =
await _embyApiClient.GetCollectionItems(address, apiKey, collection.ItemId);
foreach (BaseError error in maybeItems.LeftToSeq())
try
{
_logger.LogWarning("Failed to get collection items from Emby: {Error}", error.ToString());
return;
// get collection items from Emby
IAsyncEnumerable<MediaItem> items = _embyApiClient.GetCollectionItems(address, apiKey, collection.ItemId);
List<int> removedIds = await _embyCollectionRepository.RemoveAllTags(collection);
// sync tags on items
var addedIds = new List<int>();
await foreach (MediaItem item in items)
{
addedIds.Add(await _embyCollectionRepository.AddTag(item, collection));
}
_logger.LogDebug("Emby collection {Name} contains {Count} items", collection.Name, addedIds.Count);
var changedIds = removedIds.Except(addedIds).ToList();
changedIds.AddRange(addedIds.Except(removedIds));
await _searchIndex.RebuildItems(_searchRepository, changedIds);
_searchIndex.Commit();
}
List<int> removedIds = await _embyCollectionRepository.RemoveAllTags(collection);
var embyItems = maybeItems.RightToSeq().Flatten().ToList();
_logger.LogDebug("Emby collection {Name} contains {Count} items", collection.Name, embyItems.Count);
// sync tags on items
var addedIds = new List<int>();
foreach (MediaItem item in embyItems)
catch (Exception ex)
{
addedIds.Add(await _embyCollectionRepository.AddTag(item, collection));
_logger.LogWarning(ex, "Failed to synchronize Emby collection {Name}", collection.Name);
}
var changedIds = removedIds.Except(addedIds).ToList();
changedIds.AddRange(addedIds.Except(removedIds));
await _searchIndex.RebuildItems(_searchRepository, changedIds);
_searchIndex.Commit();
}
}

View File

@@ -0,0 +1,11 @@
namespace ErsatzTV.Core.Emby;
public static class EmbyItemType
{
public static readonly string Movie = "Movie";
public static readonly string Show = "Series";
public static readonly string Season = "Season";
public static readonly string Episode = "Episode";
public static readonly string Collection = "BoxSet";
public static readonly string CollectionItems = "Movie,Series,Season,Episode";
}

View File

@@ -79,7 +79,16 @@ public class EmbyMovieLibraryScanner :
protected override string MediaServerItemId(EmbyMovie movie) => movie.ItemId;
protected override string MediaServerEtag(EmbyMovie movie) => movie.Etag;
protected override Task<Either<BaseError, List<EmbyMovie>>> GetMovieLibraryItems(
protected override Task<Either<BaseError, int>> CountMovieLibraryItems(
EmbyConnectionParameters connectionParameters,
EmbyLibrary library) =>
_embyApiClient.GetLibraryItemCount(
connectionParameters.Address,
connectionParameters.ApiKey,
library.ItemId,
EmbyItemType.Movie);
protected override IAsyncEnumerable<EmbyMovie> GetMovieLibraryItems(
EmbyConnectionParameters connectionParameters,
EmbyLibrary library) =>
_embyApiClient.GetMovieLibraryItems(

View File

@@ -76,13 +76,19 @@ public class EmbyTelevisionLibraryScanner : MediaServerTelevisionLibraryScanner<
cancellationToken);
}
protected override Task<Either<BaseError, List<EmbyShow>>> GetShowLibraryItems(
protected override Task<Either<BaseError, int>> CountShowLibraryItems(
EmbyConnectionParameters connectionParameters,
EmbyLibrary library) =>
_embyApiClient.GetShowLibraryItems(
EmbyLibrary library)
=> _embyApiClient.GetLibraryItemCount(
connectionParameters.Address,
connectionParameters.ApiKey,
library.ItemId);
library.ItemId,
EmbyItemType.Show);
protected override IAsyncEnumerable<EmbyShow> GetShowLibraryItems(
EmbyConnectionParameters connectionParameters,
EmbyLibrary library) =>
_embyApiClient.GetShowLibraryItems(connectionParameters.Address, connectionParameters.ApiKey, library);
protected override string MediaServerItemId(EmbyShow show) => show.ItemId;
protected override string MediaServerItemId(EmbySeason season) => season.ItemId;
@@ -92,23 +98,46 @@ public class EmbyTelevisionLibraryScanner : MediaServerTelevisionLibraryScanner<
protected override string MediaServerEtag(EmbySeason season) => season.Etag;
protected override string MediaServerEtag(EmbyEpisode episode) => episode.Etag;
protected override Task<Either<BaseError, List<EmbySeason>>> GetSeasonLibraryItems(
protected override Task<Either<BaseError, int>> CountSeasonLibraryItems(
EmbyConnectionParameters connectionParameters,
EmbyLibrary library,
EmbyShow show) =>
_embyApiClient.GetLibraryItemCount(
connectionParameters.Address,
connectionParameters.ApiKey,
show.ItemId,
EmbyItemType.Season);
protected override IAsyncEnumerable<EmbySeason> GetSeasonLibraryItems(
EmbyLibrary library,
EmbyConnectionParameters connectionParameters,
EmbyShow show) =>
_embyApiClient.GetSeasonLibraryItems(
connectionParameters.Address,
connectionParameters.ApiKey,
library,
show.ItemId);
protected override Task<Either<BaseError, List<EmbyEpisode>>> GetEpisodeLibraryItems(
protected override Task<Either<BaseError, int>> CountEpisodeLibraryItems(
EmbyConnectionParameters connectionParameters,
EmbyLibrary library,
EmbySeason season) =>
_embyApiClient.GetLibraryItemCount(
connectionParameters.Address,
connectionParameters.ApiKey,
season.ItemId,
EmbyItemType.Episode);
protected override IAsyncEnumerable<EmbyEpisode> GetEpisodeLibraryItems(
EmbyLibrary library,
EmbyConnectionParameters connectionParameters,
EmbyShow show,
EmbySeason season) =>
_embyApiClient.GetEpisodeLibraryItems(
connectionParameters.Address,
connectionParameters.ApiKey,
library,
show.ItemId,
season.ItemId);
protected override Task<Option<ShowMetadata>> GetFullMetadata(

View File

@@ -9,14 +9,15 @@
<ItemGroup>
<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="Flurl" Version="3.0.6" />
<PackageReference Include="LanguageExt.Core" Version="4.1.1" />
<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>

View File

@@ -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,
@@ -179,14 +181,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
string videoFormat = GetVideoFormat(playbackSettings);
HardwareAccelerationMode hwAccel = playbackSettings.HardwareAcceleration switch
{
HardwareAccelerationKind.Nvenc => HardwareAccelerationMode.Nvenc,
HardwareAccelerationKind.Qsv => HardwareAccelerationMode.Qsv,
HardwareAccelerationKind.Vaapi => HardwareAccelerationMode.Vaapi,
HardwareAccelerationKind.VideoToolbox => HardwareAccelerationMode.VideoToolbox,
_ => HardwareAccelerationMode.None
};
HardwareAccelerationMode hwAccel = GetHardwareAccelerationMode(playbackSettings);
OutputFormatKind outputFormat = channel.StreamingMode == StreamingMode.HttpLiveStreamingSegmenter
? OutputFormatKind.Hls
@@ -232,7 +227,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
outputFormat,
hlsPlaylistPath,
hlsSegmentTemplate,
ptsOffset);
ptsOffset,
playbackSettings.ThreadCount);
_logger.LogDebug("FFmpeg desired state {FrameState}", desiredState);
@@ -256,7 +252,9 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
Option<TimeSpan> duration,
string errorMessage,
bool hlsRealtime,
long ptsOffset)
long ptsOffset,
VaapiDriver vaapiDriver,
string vaapiDevice)
{
FFmpegPlaybackSettings playbackSettings = _playbackSettingsCalculator.CalculateErrorSettings(
channel.StreamingMode,
@@ -291,7 +289,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
playbackSettings.AudioBufferSize,
playbackSettings.AudioSampleRate,
Option<TimeSpan>.None,
playbackSettings.NormalizeLoudness);
false);
var desiredState = new FrameState(
playbackSettings.RealtimeOutput,
@@ -325,18 +323,20 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
var ffmpegVideoStream = new VideoStream(
0,
VideoFormat.GeneratedImage,
new PixelFormatYuv420P(),
new PixelFormatUnknown(), // leave this unknown so we convert to desired yuv420p
new FrameSize(videoVersion.Width, videoVersion.Height),
None,
true);
var videoInputFile = new VideoInputFile(videoPath, new List<VideoStream> { ffmpegVideoStream });
HardwareAccelerationMode hwAccel = GetHardwareAccelerationMode(playbackSettings);
var ffmpegState = new FFmpegState(
false,
HardwareAccelerationMode.None,
None,
None,
hwAccel,
VaapiDriverName(hwAccel, vaapiDriver),
VaapiDeviceName(hwAccel, vaapiDevice),
playbackSettings.StreamSeek,
duration,
channel.StreamingMode != StreamingMode.HttpLiveStreamingDirect,
@@ -346,7 +346,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
outputFormat,
hlsPlaylistPath,
hlsSegmentTemplate,
ptsOffset);
ptsOffset,
Option<int>.None);
var ffmpegSubtitleStream = new ErsatzTV.FFmpeg.MediaStream(0, "ass", StreamKind.Video);
@@ -598,4 +599,14 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
FFmpegProfileVideoFormat.Copy => VideoFormat.Copy,
_ => throw new ArgumentOutOfRangeException($"unexpected video format {playbackSettings.VideoFormat}")
};
private static HardwareAccelerationMode GetHardwareAccelerationMode(FFmpegPlaybackSettings playbackSettings) =>
playbackSettings.HardwareAcceleration switch
{
HardwareAccelerationKind.Nvenc => HardwareAccelerationMode.Nvenc,
HardwareAccelerationKind.Qsv => HardwareAccelerationMode.Qsv,
HardwareAccelerationKind.Vaapi => HardwareAccelerationMode.Vaapi,
HardwareAccelerationKind.VideoToolbox => HardwareAccelerationMode.VideoToolbox,
_ => HardwareAccelerationMode.None
};
}

View File

@@ -1,6 +1,6 @@
// zlib License
//
// Copyright (c) 2021 Dan Ferguson, Victor Hugo Soliz Kuncar, Jason Dove
// Copyright (c) 2022 Dan Ferguson, Victor Hugo Soliz Kuncar, Jason Dove
//
// This software is provided 'as-is', without any express or implied
// warranty. In no event will the authors be held liable for any damages
@@ -176,16 +176,23 @@ public class FFmpegPlaybackSettingsCalculator
bool hlsRealtime) =>
new()
{
HardwareAcceleration = HardwareAccelerationKind.None,
ThreadCount = 1,
HardwareAcceleration = ffmpegProfile.HardwareAcceleration,
FormatFlags = CommonFormatFlags,
VideoFormat = ffmpegProfile.VideoFormat,
VideoBitrate = ffmpegProfile.VideoBitrate,
VideoBufferSize = ffmpegProfile.VideoBufferSize,
AudioFormat = ffmpegProfile.AudioFormat,
AudioBitrate = ffmpegProfile.AudioBitrate,
AudioBufferSize = ffmpegProfile.AudioBufferSize,
AudioChannels = ffmpegProfile.AudioChannels,
AudioSampleRate = ffmpegProfile.AudioSampleRate,
RealtimeOutput = streamingMode switch
{
StreamingMode.HttpLiveStreamingSegmenter => hlsRealtime,
_ => true
}
},
VideoTrackTimeScale = 90000,
FrameRate = 24
};
private static bool NeedToScale(FFmpegProfile ffmpegProfile, MediaVersion version) =>

View File

@@ -1,6 +1,6 @@
// zlib License
//
// Copyright (c) 2021 Dan Ferguson, Victor Hugo Soliz Kuncar, Jason Dove
// Copyright (c) 2022 Dan Ferguson, Victor Hugo Soliz Kuncar, Jason Dove
//
// This software is provided 'as-is', without any express or implied
// warranty. In no event will the authors be held liable for any damages

View File

@@ -82,7 +82,7 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector
"Unable to find audio stream with preferred audio language code(s) {Code}; selecting stream with most channels",
allCodes);
return audioStreams.OrderByDescending(s => s.Channels).Head();
return audioStreams.OrderByDescending(s => s.Channels).HeadOrNone();
}
public async Task<Option<Subtitle>> SelectSubtitleStream(

View File

@@ -25,45 +25,38 @@ public class HlsPlaylistFilter : IHlsPlaylistFilter
{
try
{
List<PlaylistItem> items = new();
DateTimeOffset currentTime = playlistStart;
DateTimeOffset nextPlaylistStart = DateTimeOffset.MaxValue;
var discontinuitySequence = 0;
var startSequence = 0;
var output = new StringBuilder();
var started = false;
var i = 0;
var segments = 0;
while (!lines[i].StartsWith("#EXTINF:"))
{
if (lines[i].StartsWith("#EXT-X-DISCONTINUITY-SEQUENCE"))
{
discontinuitySequence = int.Parse(lines[i].Split(':')[1]);
}
else if (lines[i].StartsWith("#EXT-X-DISCONTINUITY"))
{
items.Add(new PlaylistDiscontinuity());
}
i++;
}
while (i < lines.Length)
{
if (segments >= maxSegments)
string line = lines[i];
if (string.IsNullOrWhiteSpace(line))
{
break;
i++;
continue;
}
string line = lines[i];
// _logger.LogInformation("Line: {Line}", line);
if (line.StartsWith("#EXT-X-DISCONTINUITY"))
{
if (started)
{
output.AppendLine("#EXT-X-DISCONTINUITY");
}
else
{
discontinuitySequence++;
}
items.Add(new PlaylistDiscontinuity());
i++;
continue;
}
@@ -73,50 +66,23 @@ public class HlsPlaylistFilter : IHlsPlaylistFilter
lines[i].TrimEnd(',').Split(':')[1],
NumberStyles.Number,
CultureInfo.InvariantCulture));
if (currentTime < filterBefore)
{
currentTime += duration;
i += 3;
continue;
}
nextPlaylistStart = currentTime < nextPlaylistStart ? currentTime : nextPlaylistStart;
if (!started)
{
startSequence = int.Parse(lines[i + 2].Replace("live", string.Empty).Split('.')[0]);
output.AppendLine("#EXTM3U");
output.AppendLine("#EXT-X-VERSION:6");
output.AppendLine("#EXT-X-TARGETDURATION:4");
output.AppendLine($"#EXT-X-MEDIA-SEQUENCE:{startSequence}");
output.AppendLine($"#EXT-X-DISCONTINUITY-SEQUENCE:{discontinuitySequence}");
output.AppendLine("#EXT-X-INDEPENDENT-SEGMENTS");
output.AppendLine("#EXT-X-DISCONTINUITY");
started = true;
}
output.AppendLine(lines[i]);
string offset = currentTime.ToString("zzz").Replace(":", string.Empty);
output.AppendLine($"#EXT-X-PROGRAM-DATE-TIME:{currentTime:yyyy-MM-ddTHH:mm:ss.fff}{offset}");
output.AppendLine(lines[i + 2]);
items.Add(new PlaylistSegment(currentTime, lines[i], lines[i + 2]));
currentTime += duration;
segments++;
i += 3;
}
var playlist = output.ToString();
if (endWithDiscontinuity && !playlist.EndsWith($"#EXT-X-DISCONTINUITY{Environment.NewLine}"))
if (endWithDiscontinuity && items[^1] is not PlaylistDiscontinuity)
{
playlist += "#EXT-X-DISCONTINUITY" + Environment.NewLine;
items.Add(new PlaylistDiscontinuity());
}
if (playlist.Trim().Split(Environment.NewLine).All(l => l.StartsWith('#')))
{
throw new Exception("Trimming playlist to nothing");
}
(string playlist, DateTimeOffset nextPlaylistStart, int startSequence, int segments) = GeneratePlaylist(
items,
filterBefore,
discontinuitySequence,
maxSegments);
return new TrimPlaylistResult(nextPlaylistStart, startSequence, playlist, segments);
}
@@ -146,6 +112,98 @@ public class HlsPlaylistFilter : IHlsPlaylistFilter
DateTimeOffset filterBefore,
string[] lines) =>
TrimPlaylist(playlistStart, filterBefore, lines, int.MaxValue, true);
private static Tuple<string, DateTimeOffset, int, int> GeneratePlaylist(
List<PlaylistItem> items,
DateTimeOffset filterBefore,
int discontinuitySequence,
int maxSegments)
{
if (items.Any() && items[0] is PlaylistDiscontinuity)
{
discontinuitySequence++;
}
while (items.Any() && items[0] is PlaylistDiscontinuity)
{
items.RemoveAt(0);
}
var allSegments = items.OfType<PlaylistSegment>().ToList();
// only filter if we have more than requested
if (allSegments.Count > maxSegments)
{
var afterFilter = allSegments.Filter(s => s.StartTime >= filterBefore).ToList();
// if there are enough new segments after filtering, use those
// otherwise return the last maxSegments
allSegments = afterFilter.Count >= maxSegments
? afterFilter.Take(maxSegments).ToList()
: allSegments.TakeLast(maxSegments).ToList();
}
int startSequence = allSegments
.HeadOrNone()
.Map(s => s.StartSequence)
.IfNone(0);
// count all discontinuities that were filtered out
if (allSegments.Any())
{
int index = items.IndexOf(allSegments.Head());
int count = items.Take(index + 1).OfType<PlaylistDiscontinuity>().Count();
discontinuitySequence += count;
}
var output = new StringBuilder();
output.AppendLine("#EXTM3U");
output.AppendLine("#EXT-X-VERSION:6");
output.AppendLine("#EXT-X-TARGETDURATION:4");
output.AppendLine($"#EXT-X-MEDIA-SEQUENCE:{startSequence}");
output.AppendLine($"#EXT-X-DISCONTINUITY-SEQUENCE:{discontinuitySequence}");
output.AppendLine("#EXT-X-INDEPENDENT-SEGMENTS");
for (var i = 0; i < items.Count; i++)
{
switch (items[i])
{
case PlaylistDiscontinuity:
if (i == items.Count - 1 || allSegments.Contains(items[i + 1]))
{
output.AppendLine("#EXT-X-DISCONTINUITY");
}
break;
case PlaylistSegment segment:
if (allSegments.Contains(segment))
{
output.AppendLine(segment.ExtInf);
string offset = segment.StartTime.ToString("zzz").Replace(":", string.Empty);
output.AppendLine(
$"#EXT-X-PROGRAM-DATE-TIME:{segment.StartTime:yyyy-MM-ddTHH:mm:ss.fff}{offset}");
output.AppendLine(segment.Line);
}
break;
}
}
var playlist = output.ToString();
DateTimeOffset nextPlaylistStart = allSegments.HeadOrNone()
.Map(s => s.StartTime)
.IfNone(DateTimeOffset.MaxValue);
return Tuple(playlist, nextPlaylistStart, startSequence, allSegments.Count);
}
private abstract record PlaylistItem;
private record PlaylistSegment(DateTimeOffset StartTime, string ExtInf, string Line) : PlaylistItem
{
public int StartSequence => int.Parse(Line.Replace("live", string.Empty).Split('.')[0]);
}
private record PlaylistDiscontinuity : PlaylistItem;
}
public record TrimPlaylistResult(DateTimeOffset PlaylistStart, int Sequence, string Playlist, int SegmentCount);

View File

@@ -8,33 +8,30 @@ public interface IEmbyApiClient
Task<Either<BaseError, EmbyServerInformation>> GetServerInformation(string address, string apiKey);
Task<Either<BaseError, List<EmbyLibrary>>> GetLibraries(string address, string apiKey);
Task<Either<BaseError, List<EmbyMovie>>> GetMovieLibraryItems(
string address,
string apiKey,
EmbyLibrary library);
IAsyncEnumerable<EmbyMovie> GetMovieLibraryItems(string address, string apiKey, EmbyLibrary library);
Task<Either<BaseError, List<EmbyShow>>> GetShowLibraryItems(
string address,
string apiKey,
string libraryId);
IAsyncEnumerable<EmbyShow> GetShowLibraryItems(string address, string apiKey, EmbyLibrary library);
Task<Either<BaseError, List<EmbySeason>>> GetSeasonLibraryItems(
string address,
string apiKey,
string showId);
Task<Either<BaseError, List<EmbyEpisode>>> GetEpisodeLibraryItems(
IAsyncEnumerable<EmbySeason> GetSeasonLibraryItems(
string address,
string apiKey,
EmbyLibrary library,
string seasonId);
string showId);
Task<Either<BaseError, List<EmbyCollection>>> GetCollectionLibraryItems(
string address,
string apiKey);
Task<Either<BaseError, List<MediaItem>>> GetCollectionItems(
IAsyncEnumerable<EmbyEpisode> GetEpisodeLibraryItems(
string address,
string apiKey,
string collectionId);
EmbyLibrary library,
string showId,
string seasonId);
IAsyncEnumerable<EmbyCollection> GetCollectionLibraryItems(string address, string apiKey);
IAsyncEnumerable<MediaItem> GetCollectionItems(string address, string apiKey, string collectionId);
Task<Either<BaseError, int>> GetLibraryItemCount(
string address,
string apiKey,
string parentId,
string includeItemTypes);
}

View File

@@ -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,
@@ -41,7 +42,9 @@ public interface IFFmpegProcessService
Option<TimeSpan> duration,
string errorMessage,
bool hlsRealtime,
long ptsOffset);
long ptsOffset,
VaapiDriver vaapiDriver,
string vaapiDevice);
Command ConcatChannel(string ffmpegPath, bool saveReports, Channel channel, string scheme, string host);

View File

@@ -9,37 +9,35 @@ public interface IJellyfinApiClient
Task<Either<BaseError, List<JellyfinLibrary>>> GetLibraries(string address, string apiKey);
Task<Either<BaseError, string>> GetAdminUserId(string address, string apiKey);
Task<Either<BaseError, List<JellyfinMovie>>> GetMovieLibraryItems(
string address,
string apiKey,
JellyfinLibrary library);
IAsyncEnumerable<JellyfinMovie> GetMovieLibraryItems(string address, string apiKey, JellyfinLibrary library);
Task<Either<BaseError, List<JellyfinShow>>> GetShowLibraryItems(
string address,
string apiKey,
int mediaSourceId,
string libraryId);
IAsyncEnumerable<JellyfinShow> GetShowLibraryItems(string address, string apiKey, JellyfinLibrary library);
Task<Either<BaseError, List<JellyfinSeason>>> GetSeasonLibraryItems(
IAsyncEnumerable<JellyfinSeason> GetSeasonLibraryItems(
string address,
string apiKey,
int mediaSourceId,
JellyfinLibrary library,
string showId);
Task<Either<BaseError, List<JellyfinEpisode>>> GetEpisodeLibraryItems(
IAsyncEnumerable<JellyfinEpisode> GetEpisodeLibraryItems(
string address,
string apiKey,
JellyfinLibrary library,
string seasonId);
Task<Either<BaseError, List<JellyfinCollection>>> GetCollectionLibraryItems(
string address,
string apiKey,
int mediaSourceId);
IAsyncEnumerable<JellyfinCollection> GetCollectionLibraryItems(string address, string apiKey, int mediaSourceId);
Task<Either<BaseError, List<MediaItem>>> GetCollectionItems(
IAsyncEnumerable<MediaItem> GetCollectionItems(
string address,
string apiKey,
int mediaSourceId,
string collectionId);
Task<Either<BaseError, int>> GetLibraryItemCount(
string address,
string apiKey,
JellyfinLibrary library,
string parentId,
string includeItemTypes,
bool excludeFolders);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -13,23 +13,33 @@ public interface IPlexServerApiClient
PlexConnection connection,
PlexServerAuthToken token);
Task<Either<BaseError, List<PlexMovie>>> GetMovieLibraryContents(
IAsyncEnumerable<PlexMovie> GetMovieLibraryContents(
PlexLibrary library,
PlexConnection connection,
PlexServerAuthToken token);
Task<Either<BaseError, List<PlexShow>>> GetShowLibraryContents(
IAsyncEnumerable<PlexShow> GetShowLibraryContents(
PlexLibrary library,
PlexConnection connection,
PlexServerAuthToken token);
Task<Either<BaseError, List<PlexSeason>>> GetShowSeasons(
Task<Either<BaseError, int>> CountShowSeasons(
PlexShow show,
PlexConnection connection,
PlexServerAuthToken token);
IAsyncEnumerable<PlexSeason> GetShowSeasons(
PlexLibrary library,
PlexShow show,
PlexConnection connection,
PlexServerAuthToken token);
Task<Either<BaseError, List<PlexEpisode>>> GetSeasonEpisodes(
Task<Either<BaseError, int>> CountSeasonEpisodes(
PlexSeason season,
PlexConnection connection,
PlexServerAuthToken token);
IAsyncEnumerable<PlexEpisode> GetSeasonEpisodes(
PlexLibrary library,
PlexSeason season,
PlexConnection connection,
@@ -58,4 +68,9 @@ public interface IPlexServerApiClient
string key,
PlexConnection connection,
PlexServerAuthToken token);
Task<Either<BaseError, int>> GetLibraryItemCount(
PlexLibrary library,
PlexConnection connection,
PlexServerAuthToken token);
}

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();

View File

@@ -30,24 +30,21 @@ public class JellyfinCollectionScanner : IJellyfinCollectionScanner
public async Task<Either<BaseError, Unit>> ScanCollections(string address, string apiKey, int mediaSourceId)
{
// get all collections from db (item id, etag)
List<JellyfinCollection> existingCollections = await _jellyfinCollectionRepository.GetCollections();
// get all collections from jellyfin
Either<BaseError, List<JellyfinCollection>> maybeIncomingCollections =
await _jellyfinApiClient.GetCollectionLibraryItems(address, apiKey, mediaSourceId);
foreach (BaseError error in maybeIncomingCollections.LeftToSeq())
try
{
_logger.LogWarning("Failed to get collections from Jellyfin: {Error}", error.ToString());
return error;
}
var incomingItemIds = new List<string>();
// get all collections from db (item id, etag)
List<JellyfinCollection> existingCollections = await _jellyfinCollectionRepository.GetCollections();
foreach (List<JellyfinCollection> incomingCollections in maybeIncomingCollections.RightToSeq())
{
// loop over collections
foreach (JellyfinCollection collection in incomingCollections)
await foreach (JellyfinCollection collection in _jellyfinApiClient.GetCollectionLibraryItems(
address,
apiKey,
mediaSourceId))
{
incomingItemIds.Add(collection.ItemId);
Option<JellyfinCollection> maybeExisting = existingCollections.Find(c => c.ItemId == collection.ItemId);
// skip if unchanged (etag)
@@ -75,12 +72,17 @@ public class JellyfinCollectionScanner : IJellyfinCollectionScanner
}
// remove missing collections (and remove any lingering tags from those collections)
foreach (JellyfinCollection collection in existingCollections
.Filter(e => incomingCollections.All(i => i.ItemId != e.ItemId)))
foreach (JellyfinCollection collection in existingCollections.Filter(
e => !incomingItemIds.Contains(e.ItemId)))
{
await _jellyfinCollectionRepository.RemoveCollection(collection);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to get collections from Jellyfin");
return BaseError.New(ex.Message);
}
return Unit.Default;
}
@@ -91,32 +93,35 @@ public class JellyfinCollectionScanner : IJellyfinCollectionScanner
int mediaSourceId,
JellyfinCollection collection)
{
// get collection items from JF
Either<BaseError, List<MediaItem>> maybeItems =
await _jellyfinApiClient.GetCollectionItems(address, apiKey, mediaSourceId, collection.ItemId);
foreach (BaseError error in maybeItems.LeftToSeq())
try
{
_logger.LogWarning("Failed to get collection items from Jellyfin: {Error}", error.ToString());
return;
// get collection items from JF
IAsyncEnumerable<MediaItem> items = _jellyfinApiClient.GetCollectionItems(
address,
apiKey,
mediaSourceId,
collection.ItemId);
List<int> removedIds = await _jellyfinCollectionRepository.RemoveAllTags(collection);
// sync tags on items
var addedIds = new List<int>();
await foreach (MediaItem item in items)
{
addedIds.Add(await _jellyfinCollectionRepository.AddTag(item, collection));
}
_logger.LogDebug("Jellyfin collection {Name} contains {Count} items", collection.Name, addedIds.Count);
var changedIds = removedIds.Except(addedIds).ToList();
changedIds.AddRange(addedIds.Except(removedIds));
await _searchIndex.RebuildItems(_searchRepository, changedIds);
_searchIndex.Commit();
}
List<int> removedIds = await _jellyfinCollectionRepository.RemoveAllTags(collection);
var jellyfinItems = maybeItems.RightToSeq().Flatten().ToList();
_logger.LogDebug("Jellyfin collection {Name} contains {Count} items", collection.Name, jellyfinItems.Count);
// sync tags on items
var addedIds = new List<int>();
foreach (MediaItem item in jellyfinItems)
catch (Exception ex)
{
addedIds.Add(await _jellyfinCollectionRepository.AddTag(item, collection));
_logger.LogWarning(ex, "Failed to synchronize Jellyfin collection {Name}", collection.Name);
}
var changedIds = removedIds.Except(addedIds).ToList();
changedIds.AddRange(addedIds.Except(removedIds));
await _searchIndex.RebuildItems(_searchRepository, changedIds);
_searchIndex.Commit();
}
}

View File

@@ -0,0 +1,11 @@
namespace ErsatzTV.Core.Jellyfin;
public static class JellyfinItemType
{
public static readonly string Movie = "Movie";
public static readonly string Show = "Series";
public static readonly string Season = "Season";
public static readonly string Episode = "Episode";
public static readonly string Collection = "BoxSet";
public static readonly string CollectionItems = "Movie,Series,Season,Episode";
}

View File

@@ -80,7 +80,18 @@ public class JellyfinMovieLibraryScanner :
protected override string MediaServerEtag(JellyfinMovie movie) => movie.Etag;
protected override Task<Either<BaseError, List<JellyfinMovie>>> GetMovieLibraryItems(
protected override Task<Either<BaseError, int>> CountMovieLibraryItems(
JellyfinConnectionParameters connectionParameters,
JellyfinLibrary library) =>
_jellyfinApiClient.GetLibraryItemCount(
connectionParameters.Address,
connectionParameters.ApiKey,
library,
library.ItemId,
JellyfinItemType.Movie,
true);
protected override IAsyncEnumerable<JellyfinMovie> GetMovieLibraryItems(
JellyfinConnectionParameters connectionParameters,
JellyfinLibrary library) =>
_jellyfinApiClient.GetMovieLibraryItems(

View File

@@ -77,14 +77,21 @@ public class JellyfinTelevisionLibraryScanner : MediaServerTelevisionLibraryScan
cancellationToken);
}
protected override Task<Either<BaseError, List<JellyfinShow>>> GetShowLibraryItems(
protected override Task<Either<BaseError, int>> CountShowLibraryItems(
JellyfinConnectionParameters connectionParameters,
JellyfinLibrary library) =>
_jellyfinApiClient.GetShowLibraryItems(
JellyfinLibrary library)
=> _jellyfinApiClient.GetLibraryItemCount(
connectionParameters.Address,
connectionParameters.ApiKey,
library.MediaSourceId,
library.ItemId);
library,
library.ItemId,
JellyfinItemType.Show,
false);
protected override IAsyncEnumerable<JellyfinShow> GetShowLibraryItems(
JellyfinConnectionParameters connectionParameters,
JellyfinLibrary library) =>
_jellyfinApiClient.GetShowLibraryItems(connectionParameters.Address, connectionParameters.ApiKey, library);
protected override string MediaServerItemId(JellyfinShow show) => show.ItemId;
protected override string MediaServerItemId(JellyfinSeason season) => season.ItemId;
@@ -94,19 +101,44 @@ public class JellyfinTelevisionLibraryScanner : MediaServerTelevisionLibraryScan
protected override string MediaServerEtag(JellyfinSeason season) => season.Etag;
protected override string MediaServerEtag(JellyfinEpisode episode) => episode.Etag;
protected override Task<Either<BaseError, List<JellyfinSeason>>> GetSeasonLibraryItems(
protected override Task<Either<BaseError, int>> CountSeasonLibraryItems(
JellyfinConnectionParameters connectionParameters,
JellyfinLibrary library,
JellyfinShow show) =>
_jellyfinApiClient.GetLibraryItemCount(
connectionParameters.Address,
connectionParameters.ApiKey,
library,
show.ItemId,
JellyfinItemType.Season,
false);
protected override IAsyncEnumerable<JellyfinSeason> GetSeasonLibraryItems(
JellyfinLibrary library,
JellyfinConnectionParameters connectionParameters,
JellyfinShow show) =>
_jellyfinApiClient.GetSeasonLibraryItems(
connectionParameters.Address,
connectionParameters.ApiKey,
library.MediaSourceId,
library,
show.ItemId);
protected override Task<Either<BaseError, List<JellyfinEpisode>>> GetEpisodeLibraryItems(
protected override Task<Either<BaseError, int>> CountEpisodeLibraryItems(
JellyfinConnectionParameters connectionParameters,
JellyfinLibrary library,
JellyfinSeason season) =>
_jellyfinApiClient.GetLibraryItemCount(
connectionParameters.Address,
connectionParameters.ApiKey,
library,
season.ItemId,
JellyfinItemType.Episode,
true);
protected override IAsyncEnumerable<JellyfinEpisode> GetEpisodeLibraryItems(
JellyfinLibrary library,
JellyfinConnectionParameters connectionParameters,
JellyfinShow _,
JellyfinSeason season) =>
_jellyfinApiClient.GetEpisodeLibraryItems(
connectionParameters.Address,

View File

@@ -299,6 +299,7 @@ public abstract class LocalFolderScanner
}
protected bool ShouldIncludeFolder(string folder) =>
!string.IsNullOrWhiteSpace(folder) &&
!Path.GetFileName(folder).StartsWith('.') &&
!_localFileSystem.FileExists(Path.Combine(folder, ".etvignore"));
}

View File

@@ -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(

View File

@@ -53,23 +53,29 @@ public abstract class MediaServerMovieLibraryScanner<TConnectionParameters, TLib
{
try
{
Either<BaseError, List<TMovie>> entries = await GetMovieLibraryItems(connectionParameters, library);
foreach (BaseError error in entries.LeftToSeq())
Either<BaseError, int> maybeCount = await CountMovieLibraryItems(connectionParameters, library);
foreach (BaseError error in maybeCount.LeftToSeq())
{
return error;
}
return await ScanLibrary(
movieRepository,
connectionParameters,
library,
getLocalPath,
ffmpegPath,
ffprobePath,
entries.RightToSeq().Flatten().ToList(),
deepScan,
cancellationToken);
foreach (int count in maybeCount.RightToSeq())
{
return await ScanLibrary(
movieRepository,
connectionParameters,
library,
getLocalPath,
ffmpegPath,
ffprobePath,
GetMovieLibraryItems(connectionParameters, library),
count,
deepScan,
cancellationToken);
}
// this won't happen
return Unit.Default;
}
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
{
@@ -88,21 +94,24 @@ public abstract class MediaServerMovieLibraryScanner<TConnectionParameters, TLib
Func<TMovie, string> getLocalPath,
string ffmpegPath,
string ffprobePath,
List<TMovie> movieEntries,
IAsyncEnumerable<TMovie> movieEntries,
int totalMovieCount,
bool deepScan,
CancellationToken cancellationToken)
{
var incomingItemIds = new List<string>();
List<TEtag> existingMovies = await movieRepository.GetExistingMovies(library);
var sortedMovies = movieEntries.OrderBy(m => m.MovieMetadata.Head().SortTitle).ToList();
foreach (TMovie incoming in sortedMovies)
await foreach (TMovie incoming in movieEntries.WithCancellation(cancellationToken))
{
if (cancellationToken.IsCancellationRequested)
{
return new ScanCanceled();
}
decimal percentCompletion = (decimal)sortedMovies.IndexOf(incoming) / sortedMovies.Count;
incomingItemIds.Add(MediaServerItemId(incoming));
decimal percentCompletion = Math.Clamp((decimal)incomingItemIds.Count / totalMovieCount, 0, 1);
await _mediator.Publish(new LibraryScanProgress(library.Id, percentCompletion), cancellationToken);
string localPath = getLocalPath(incoming);
@@ -165,8 +174,7 @@ public abstract class MediaServerMovieLibraryScanner<TConnectionParameters, TLib
}
// trash movies that are no longer present on the media server
var fileNotFoundItemIds = existingMovies.Map(m => m.MediaServerItemId)
.Except(movieEntries.Map(MediaServerItemId)).ToList();
var fileNotFoundItemIds = existingMovies.Map(m => m.MediaServerItemId).Except(incomingItemIds).ToList();
List<int> ids = await movieRepository.FlagFileNotFound(library, fileNotFoundItemIds);
await _searchIndex.RebuildItems(_searchRepository, ids);
@@ -178,7 +186,11 @@ public abstract class MediaServerMovieLibraryScanner<TConnectionParameters, TLib
protected abstract string MediaServerItemId(TMovie movie);
protected abstract string MediaServerEtag(TMovie movie);
protected abstract Task<Either<BaseError, List<TMovie>>> GetMovieLibraryItems(
protected abstract Task<Either<BaseError, int>> CountMovieLibraryItems(
TConnectionParameters connectionParameters,
TLibrary library);
protected abstract IAsyncEnumerable<TMovie> GetMovieLibraryItems(
TConnectionParameters connectionParameters,
TLibrary library);

View File

@@ -56,23 +56,31 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
{
try
{
Either<BaseError, List<TShow>> entries = await GetShowLibraryItems(connectionParameters, library);
foreach (BaseError error in entries.LeftToSeq())
Either<BaseError, int> maybeCount = await CountShowLibraryItems(connectionParameters, library);
foreach (BaseError error in maybeCount.LeftToSeq())
{
return error;
}
return await ScanLibrary(
televisionRepository,
connectionParameters,
library,
getLocalPath,
ffmpegPath,
ffprobePath,
entries.RightToSeq().Flatten().ToList(),
deepScan,
cancellationToken);
foreach (int count in maybeCount.RightToSeq())
{
_logger.LogDebug("Library {Library} contains {Count} shows", library.Name, count);
return await ScanLibrary(
televisionRepository,
connectionParameters,
library,
getLocalPath,
ffmpegPath,
ffprobePath,
GetShowLibraryItems(connectionParameters, library),
count,
deepScan,
cancellationToken);
}
// this won't happen
return Unit.Default;
}
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
{
@@ -84,7 +92,11 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
}
}
protected abstract Task<Either<BaseError, List<TShow>>> GetShowLibraryItems(
protected abstract Task<Either<BaseError, int>> CountShowLibraryItems(
TConnectionParameters connectionParameters,
TLibrary library);
protected abstract IAsyncEnumerable<TShow> GetShowLibraryItems(
TConnectionParameters connectionParameters,
TLibrary library);
@@ -102,21 +114,24 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
Func<TEpisode, string> getLocalPath,
string ffmpegPath,
string ffprobePath,
List<TShow> showEntries,
IAsyncEnumerable<TShow> showEntries,
int totalShowCount,
bool deepScan,
CancellationToken cancellationToken)
{
var incomingItemIds = new List<string>();
List<TEtag> existingShows = await televisionRepository.GetExistingShows(library);
var sortedShows = showEntries.OrderBy(s => s.ShowMetadata.Head().SortTitle).ToList();
foreach (TShow incoming in showEntries)
await foreach (TShow incoming in showEntries.WithCancellation(cancellationToken))
{
if (cancellationToken.IsCancellationRequested)
{
return new ScanCanceled();
}
decimal percentCompletion = (decimal)sortedShows.IndexOf(incoming) / sortedShows.Count;
incomingItemIds.Add(MediaServerItemId(incoming));
decimal percentCompletion = Math.Clamp((decimal)incomingItemIds.Count / totalShowCount, 0, 1);
await _mediator.Publish(new LibraryScanProgress(library.Id, percentCompletion), cancellationToken);
Either<BaseError, MediaItemScanResult<TShow>> maybeShow = await televisionRepository
@@ -138,16 +153,23 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
foreach (MediaItemScanResult<TShow> result in maybeShow.RightToSeq())
{
Either<BaseError, List<TSeason>> entries = await GetSeasonLibraryItems(
library,
Either<BaseError, int> maybeCount = await CountSeasonLibraryItems(
connectionParameters,
library,
result.Item);
foreach (BaseError error in entries.LeftToSeq())
foreach (BaseError error in maybeCount.LeftToSeq())
{
return error;
}
foreach (int count in maybeCount.RightToSeq())
{
_logger.LogDebug(
"Show {Title} contains {Count} seasons",
result.Item.ShowMetadata.Head().Title,
count);
}
Either<BaseError, Unit> scanResult = await ScanSeasons(
televisionRepository,
library,
@@ -156,7 +178,7 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
connectionParameters,
ffmpegPath,
ffprobePath,
entries.RightToSeq().Flatten().ToList(),
GetSeasonLibraryItems(library, connectionParameters, result.Item),
deepScan,
cancellationToken);
@@ -175,8 +197,7 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
}
// trash shows that are no longer present on the media server
var fileNotFoundItemIds = existingShows.Map(s => s.MediaServerItemId)
.Except(showEntries.Map(MediaServerItemId)).ToList();
var fileNotFoundItemIds = existingShows.Map(s => s.MediaServerItemId).Except(incomingItemIds).ToList();
List<int> ids = await televisionRepository.FlagFileNotFoundShows(library, fileNotFoundItemIds);
await _searchIndex.RebuildItems(_searchRepository, ids);
@@ -185,14 +206,25 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
return Unit.Default;
}
protected abstract Task<Either<BaseError, List<TSeason>>> GetSeasonLibraryItems(
protected abstract Task<Either<BaseError, int>> CountSeasonLibraryItems(
TConnectionParameters connectionParameters,
TLibrary library,
TShow show);
protected abstract IAsyncEnumerable<TSeason> GetSeasonLibraryItems(
TLibrary library,
TConnectionParameters connectionParameters,
TShow show);
protected abstract Task<Either<BaseError, List<TEpisode>>> GetEpisodeLibraryItems(
protected abstract Task<Either<BaseError, int>> CountEpisodeLibraryItems(
TConnectionParameters connectionParameters,
TLibrary library,
TSeason season);
protected abstract IAsyncEnumerable<TEpisode> GetEpisodeLibraryItems(
TLibrary library,
TConnectionParameters connectionParameters,
TShow show,
TSeason season);
protected abstract Task<Option<ShowMetadata>> GetFullMetadata(
@@ -236,14 +268,14 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
TConnectionParameters connectionParameters,
string ffmpegPath,
string ffprobePath,
List<TSeason> seasonEntries,
IAsyncEnumerable<TSeason> seasonEntries,
bool deepScan,
CancellationToken cancellationToken)
{
var incomingItemIds = new List<string>();
List<TEtag> existingSeasons = await televisionRepository.GetExistingSeasons(library, show);
var sortedSeasons = seasonEntries.OrderBy(s => s.SeasonNumber).ToList();
foreach (TSeason incoming in sortedSeasons)
await foreach (TSeason incoming in seasonEntries.WithCancellation(cancellationToken))
{
incoming.ShowId = show.Id;
@@ -252,6 +284,8 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
return new ScanCanceled();
}
incomingItemIds.Add(MediaServerItemId(incoming));
Either<BaseError, MediaItemScanResult<TSeason>> maybeSeason = await televisionRepository
.GetOrAdd(library, incoming)
.BindT(existing => UpdateMetadata(connectionParameters, library, existing, incoming, deepScan));
@@ -272,16 +306,24 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
foreach (MediaItemScanResult<TSeason> result in maybeSeason.RightToSeq())
{
Either<BaseError, List<TEpisode>> entries = await GetEpisodeLibraryItems(
library,
Either<BaseError, int> maybeCount = await CountEpisodeLibraryItems(
connectionParameters,
library,
result.Item);
foreach (BaseError error in entries.LeftToSeq())
foreach (BaseError error in maybeCount.LeftToSeq())
{
return error;
}
foreach (int count in maybeCount.RightToSeq())
{
_logger.LogDebug(
"Show {Title} season {Season} contains {Count} episodes",
show.ShowMetadata.Head().Title,
result.Item.SeasonNumber,
count);
}
Either<BaseError, Unit> scanResult = await ScanEpisodes(
televisionRepository,
library,
@@ -291,7 +333,7 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
connectionParameters,
ffmpegPath,
ffprobePath,
entries.RightToSeq().Flatten().ToList(),
GetEpisodeLibraryItems(library, connectionParameters, show, result.Item),
deepScan,
cancellationToken);
@@ -312,8 +354,7 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
}
// trash seasons that are no longer present on the media server
var fileNotFoundItemIds = existingSeasons.Map(s => s.MediaServerItemId)
.Except(seasonEntries.Map(MediaServerItemId)).ToList();
var fileNotFoundItemIds = existingSeasons.Map(s => s.MediaServerItemId).Except(incomingItemIds).ToList();
List<int> ids = await televisionRepository.FlagFileNotFoundSeasons(library, fileNotFoundItemIds);
await _searchIndex.RebuildItems(_searchRepository, ids);
@@ -329,20 +370,22 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
TConnectionParameters connectionParameters,
string ffmpegPath,
string ffprobePath,
List<TEpisode> episodeEntries,
IAsyncEnumerable<TEpisode> episodeEntries,
bool deepScan,
CancellationToken cancellationToken)
{
var incomingItemIds = new List<string>();
List<TEtag> existingEpisodes = await televisionRepository.GetExistingEpisodes(library, season);
var sortedEpisodes = episodeEntries.OrderBy(s => s.EpisodeMetadata.Head().EpisodeNumber).ToList();
foreach (TEpisode incoming in sortedEpisodes)
await foreach (TEpisode incoming in episodeEntries.WithCancellation(cancellationToken))
{
if (cancellationToken.IsCancellationRequested)
{
return new ScanCanceled();
}
incomingItemIds.Add(MediaServerItemId(incoming));
string localPath = getLocalPath(incoming);
if (await ShouldScanItem(
televisionRepository,
@@ -414,8 +457,7 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
}
// trash episodes that are no longer present on the media server
var fileNotFoundItemIds = existingEpisodes.Map(m => m.MediaServerItemId)
.Except(episodeEntries.Map(MediaServerItemId)).ToList();
var fileNotFoundItemIds = existingEpisodes.Map(m => m.MediaServerItemId).Except(incomingItemIds).ToList();
List<int> ids = await televisionRepository.FlagFileNotFoundEpisodes(library, fileNotFoundItemIds);
await _searchIndex.RebuildItems(_searchRepository, ids);

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);

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);

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);

View File

@@ -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; }

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 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);

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);
}
}
}

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 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);

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);

View File

@@ -89,7 +89,15 @@ public class PlexMovieLibraryScanner :
protected override string MediaServerEtag(PlexMovie movie) => movie.Etag;
protected override Task<Either<BaseError, List<PlexMovie>>> GetMovieLibraryItems(
protected override Task<Either<BaseError, int>> CountMovieLibraryItems(
PlexConnectionParameters connectionParameters,
PlexLibrary library)
=> _plexServerApiClient.GetLibraryItemCount(
library,
connectionParameters.Connection,
connectionParameters.Token);
protected override IAsyncEnumerable<PlexMovie> GetMovieLibraryItems(
PlexConnectionParameters connectionParameters,
PlexLibrary library) =>
_plexServerApiClient.GetMovieLibraryContents(

View File

@@ -137,7 +137,15 @@ public class PlexTelevisionLibraryScanner :
// }
// }
protected override Task<Either<BaseError, List<PlexShow>>> GetShowLibraryItems(
protected override Task<Either<BaseError, int>> CountShowLibraryItems(
PlexConnectionParameters connectionParameters,
PlexLibrary library) =>
_plexServerApiClient.GetLibraryItemCount(
library,
connectionParameters.Connection,
connectionParameters.Token);
protected override IAsyncEnumerable<PlexShow> GetShowLibraryItems(
PlexConnectionParameters connectionParameters,
PlexLibrary library) =>
_plexServerApiClient.GetShowLibraryContents(
@@ -145,7 +153,16 @@ public class PlexTelevisionLibraryScanner :
connectionParameters.Connection,
connectionParameters.Token);
protected override Task<Either<BaseError, List<PlexSeason>>> GetSeasonLibraryItems(
protected override Task<Either<BaseError, int>> CountSeasonLibraryItems(
PlexConnectionParameters connectionParameters,
PlexLibrary library,
PlexShow show) =>
_plexServerApiClient.CountShowSeasons(
show,
connectionParameters.Connection,
connectionParameters.Token);
protected override IAsyncEnumerable<PlexSeason> GetSeasonLibraryItems(
PlexLibrary library,
PlexConnectionParameters connectionParameters,
PlexShow show) =>
@@ -155,9 +172,19 @@ public class PlexTelevisionLibraryScanner :
connectionParameters.Connection,
connectionParameters.Token);
protected override Task<Either<BaseError, List<PlexEpisode>>> GetEpisodeLibraryItems(
protected override Task<Either<BaseError, int>> CountEpisodeLibraryItems(
PlexConnectionParameters connectionParameters,
PlexLibrary library,
PlexSeason season) =>
_plexServerApiClient.CountSeasonEpisodes(
season,
connectionParameters.Connection,
connectionParameters.Token);
protected override IAsyncEnumerable<PlexEpisode> GetEpisodeLibraryItems(
PlexLibrary library,
PlexConnectionParameters connectionParameters,
PlexShow _,
PlexSeason season) =>
_plexServerApiClient.GetSeasonEpisodes(
library,

View File

@@ -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
};

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,11 +323,29 @@ public class PlayoutBuilder : IPlayoutBuilder
start,
playoutFinish,
collectionMediaItems,
false);
false,
randomStartPoint);
}
// remove any items outside the desired range
playout.Items.RemoveAll(old => old.FinishOffset < trimBefore || old.StartOffset > trimAfter);
// remove old items
playout.Items.RemoveAll(old => old.FinishOffset < trimBefore);
// check for future items that aren't grouped inside range
var futureItems = playout.Items.Filter(i => i.StartOffset > trimAfter).ToList();
foreach (PlayoutItem futureItem in futureItems)
{
if (playout.Items.All(i => i == futureItem || i.GuideGroup != futureItem.GuideGroup))
{
_logger.LogError(
"Playout item scheduled for {Time} after hard stop of {HardStop}",
futureItem.StartOffset,
trimAfter);
// it feels hacky to have to clean up a playlist like this,
// so only log the error, and leave the bad data to fail tests
// playout.Items.Remove(futureItem);
}
}
return playout;
}
@@ -323,7 +355,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 +374,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);
}
@@ -393,6 +426,8 @@ public class PlayoutBuilder : IPlayoutBuilder
// loop until we're done filling the desired amount of time
while (playoutBuilderState.CurrentTime < playoutFinish)
{
// _logger.LogDebug("Playout time is {CurrentTime}", playoutBuilderState.CurrentTime);
// get the schedule item out of the sorted list
ProgramScheduleItem scheduleItem = playoutBuilderState.ScheduleItemsEnumerator.Current;
@@ -496,12 +531,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 +556,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 +577,7 @@ public class PlayoutBuilder : IPlayoutBuilder
}
}
// items.RemoveAll(missingItems.Contains);
items.RemoveAll(missingItems.Contains);
items.RemoveAll(zeroItems.Contains);
}
@@ -636,7 +671,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 +710,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 +735,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);

View File

@@ -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();

View File

@@ -35,6 +35,12 @@ public class PlayoutModeSchedulerDuration : PlayoutModeSchedulerBase<ProgramSche
// find when we should start this item, based on the current time
DateTimeOffset itemStartTime = GetStartTimeAfter(nextState, scheduleItem);
if (itemStartTime >= hardStop)
{
nextState = nextState with { CurrentTime = hardStop };
break;
}
// remember when we need to finish this duration item
if (nextState.DurationFinish.IsNone)
{

View File

@@ -30,12 +30,21 @@ public class PlayoutModeSchedulerFlood : PlayoutModeSchedulerBase<ProgramSchedul
ProgramScheduleItem peekScheduleItem = nextScheduleItem;
var scheduledNone = false;
while (contentEnumerator.Current.IsSome && nextState.CurrentTime < hardStop && willFinishInTime)
{
MediaItem mediaItem = contentEnumerator.Current.ValueUnsafe();
// find when we should start this item, based on the current time
DateTimeOffset itemStartTime = GetStartTimeAfter(nextState, scheduleItem);
if (itemStartTime >= hardStop)
{
scheduledNone = playoutItems.Count == 0;
nextState = nextState with { CurrentTime = hardStop };
break;
}
TimeSpan itemDuration = DurationForMediaItem(mediaItem);
List<MediaChapter> itemChapters = ChaptersForMediaItem(mediaItem);
@@ -50,6 +59,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 +104,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();
@@ -107,11 +121,19 @@ public class PlayoutModeSchedulerFlood : PlayoutModeSchedulerBase<ProgramSchedul
nextState = nextState with
{
InFlood = nextState.CurrentTime >= hardStop,
NextGuideGroup = nextState.DecrementGuideGroup
InFlood = playoutItems.Any() && nextState.CurrentTime >= hardStop,
// 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 && !scheduledNone)
{
nextState.ScheduleItemsEnumerator.MoveNext();
}
ProgramScheduleItem peekItem = nextScheduleItem;
DateTimeOffset peekItemStart = GetStartTimeAfter(nextState, peekItem);

View File

@@ -23,6 +23,13 @@ public class PlayoutModeSchedulerMultiple : PlayoutModeSchedulerBase<ProgramSche
{
var playoutItems = new List<PlayoutItem>();
DateTimeOffset firstStart = GetStartTimeAfter(playoutBuilderState, scheduleItem);
if (firstStart >= hardStop)
{
playoutBuilderState = playoutBuilderState with { CurrentTime = hardStop };
return Tuple(playoutBuilderState, playoutItems);
}
PlayoutBuilderState nextState = playoutBuilderState with
{
MultipleRemaining = playoutBuilderState.MultipleRemaining.IfNone(scheduleItem.Count)
@@ -60,6 +67,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 +89,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 +108,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();

View File

@@ -27,6 +27,12 @@ public class PlayoutModeSchedulerOne : PlayoutModeSchedulerBase<ProgramScheduleI
playoutBuilderState,
scheduleItem);
if (itemStartTime >= hardStop)
{
playoutBuilderState = playoutBuilderState with { CurrentTime = hardStop };
break;
}
TimeSpan itemDuration = DurationForMediaItem(mediaItem);
List<MediaChapter> itemChapters = ChaptersForMediaItem(mediaItem);
@@ -41,6 +47,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,

View File

@@ -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;

Some files were not shown because too many files have changed in this diff Show More