Compare commits

..
Author SHA1 Message Date
Jason Dove 082bc6145c update changelog for release v0.6.1-beta [no ci] 2022-06-03 15:05:06 -05:00
Jason DoveandGitHub bf3f16451b music video credits tweaks (#834)
* fix song subtitles

* always use generated subtitles

* file not found/unavailable fixes
2022-06-03 14:44:52 -05:00
3cb37003cb UI rewrite - ffmpeg profiles (#823)
* ffmpeg profile functionality, sweetalert2

* add new files

* cleanup controller; remove unused classes

* apply formatting

* cleanup core project

* don't use bom

* whitespace

* remove generated css

* remove generated js/map

* Remove attempted linter fix, channels button, watermarks page. Fixed handlerror.

* Changed deleted confirmation to toast.

* Localized strings for language change. Modified Action icons to buttons and left default sizes. Changed Cancel to No where Yes is an option

* lint

Co-authored-by: Jason Dove <jason@jasondove.me>
2022-06-03 06:28:32 -05:00
Jason DoveandGitHub 9acfd2cd06 fix plex server identification (#833) 2022-06-03 05:53:41 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
3242e7ebb8 Bump HtmlSanitizer from 7.1.488 to 7.1.509 (#830)
Bumps [HtmlSanitizer](https://github.com/mganss/HtmlSanitizer) from 7.1.488 to 7.1.509.
- [Release notes](https://github.com/mganss/HtmlSanitizer/releases)
- [Commits](https://github.com/mganss/HtmlSanitizer/compare/v7.1.488...v7.1.509)

---
updated-dependencies:
- dependency-name: HtmlSanitizer
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-06-02 21:21:17 -05:00
Jason DoveandGitHub 7644d628e7 generate music video credits (#832) 2022-06-02 20:50:55 -05:00
Jason DoveandGitHub b4f19e6de4 fix jellyfin tv paging (#831) 2022-06-02 12:16:59 -05:00
Jason Dove 0388425763 update changelog for release v0.6.0-beta [no ci] 2022-06-01 18:01:07 -05:00
Jason DoveandGitHub 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 DoveandGitHub 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 DoveandGitHub 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 DoveandGitHub 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 DoveandGitHub 4e2ebcc48a fix watermark opacity filter (#820) 2022-05-23 10:29:04 -05:00
Jason DoveandGitHub 90fe1d7709 fix hw accel health check for qsv in vaapi docker (#818) 2022-05-22 19:42:14 -05:00
Jason DoveandGitHub 1576dd026e enable qsv accel for vaapi docker images (#817) 2022-05-22 18:43:24 -05:00
Jason DoveandGitHub 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 DoveandGitHub a61c4b3472 fix a handful of scheduling edge cases (#814) 2022-05-18 06:00:58 -05:00
Jason DoveandGitHub 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 DoveandGitHub fd36ea51a7 unlock ffmpeg thread count (#812)
* revert logging changes

* unlock ffmpeg thread count
2022-05-17 21:37:44 -05:00
Jason DoveandGitHub 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 DoveandGitHub 0ba3ac7f50 fix more error stream settings (#810) 2022-05-17 11:44:12 -05:00
Jason DoveandGitHub d960fec734 error stream needs video track timescale (#809) 2022-05-17 10:26:46 -05:00
Jason DoveandGitHub f272036c6f reduce hls disk use (#808)
* reduce hls segmenter disk use

* logging improvements

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

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

* nfo metadata reliability fixes

* use recyclable memory streams
2022-05-07 20:32:57 -05:00
197 changed files with 26988 additions and 1100 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
[*]
charset=utf-8-bom
charset=utf-8
end_of_line=lf
trim_trailing_whitespace=false
insert_final_newline=false
+70 -2
View File
@@ -5,6 +5,70 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased]
## [0.6.1-beta] - 2022-06-03
### Fixed
- Fix Jellyfin show library paging
- Properly locate and identify multiple Plex servers
- Properly restore `Unavailable`/`File Not Found` items when they are located on disk
### Added
- Add basic music video credits subtitle generation
- This can be enabled in channel settings
## [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 `` 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 +1235,12 @@ 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.1-beta...HEAD
[0.6.1-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.6.0-beta...v0.6.1-beta
[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
@@ -16,4 +16,5 @@ public record ChannelViewModel(
int? FallbackFillerId,
int PlayoutCount,
string PreferredSubtitleLanguageCode,
ChannelSubtitleMode SubtitleMode);
ChannelSubtitleMode SubtitleMode,
ChannelMusicVideoCreditsMode MusicVideoCreditsMode);
@@ -16,4 +16,5 @@ public record CreateChannel
int? WatermarkId,
int? FallbackFillerId,
string PreferredSubtitleLanguageCode,
ChannelSubtitleMode SubtitleMode) : IRequest<Either<BaseError, CreateChannelResult>>;
ChannelSubtitleMode SubtitleMode,
ChannelMusicVideoCreditsMode MusicVideoCreditsMode) : IRequest<Either<BaseError, CreateChannelResult>>;
@@ -21,7 +21,7 @@ public class CreateChannelHandler : IRequestHandler<CreateChannel, Either<BaseEr
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Channel> validation = await Validate(dbContext, request);
return await LanguageExtensions.Apply(validation, c => PersistChannel(dbContext, c));
return await validation.Apply(c => PersistChannel(dbContext, c));
}
private static async Task<CreateChannelResult> PersistChannel(TvContext dbContext, Channel channel)
@@ -72,7 +72,8 @@ public class CreateChannelHandler : IRequestHandler<CreateChannel, Either<BaseEr
Artwork = artwork,
PreferredAudioLanguageCode = preferredAudioLanguageCode,
PreferredSubtitleLanguageCode = preferredSubtitleLanguageCode,
SubtitleMode = request.SubtitleMode
SubtitleMode = request.SubtitleMode,
MusicVideoCreditsMode = request.MusicVideoCreditsMode
};
foreach (int id in watermarkId)
@@ -17,4 +17,5 @@ public record UpdateChannel
int? WatermarkId,
int? FallbackFillerId,
string PreferredSubtitleLanguageCode,
ChannelSubtitleMode SubtitleMode) : IRequest<Either<BaseError, ChannelViewModel>>;
ChannelSubtitleMode SubtitleMode,
ChannelMusicVideoCreditsMode MusicVideoCreditsMode) : IRequest<Either<BaseError, ChannelViewModel>>;
@@ -44,6 +44,7 @@ public class UpdateChannelHandler : IRequestHandler<UpdateChannel, Either<BaseEr
c.PreferredAudioLanguageCode = update.PreferredAudioLanguageCode;
c.PreferredSubtitleLanguageCode = update.PreferredSubtitleLanguageCode;
c.SubtitleMode = update.SubtitleMode;
c.MusicVideoCreditsMode = update.MusicVideoCreditsMode;
c.Artwork ??= new List<Artwork>();
if (!string.IsNullOrWhiteSpace(update.Logo))
+2 -1
View File
@@ -20,7 +20,8 @@ internal static class Mapper
channel.FallbackFillerId,
channel.Playouts?.Count ?? 0,
channel.PreferredSubtitleLanguageCode,
channel.SubtitleMode);
channel.SubtitleMode,
channel.MusicVideoCreditsMode);
internal static ChannelResponseModel ProjectToResponseModel(Channel channel) =>
new(
@@ -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)
@@ -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));
}
@@ -1,5 +0,0 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.Configuration;
public record UpdatePlayoutDaysToBuild(int DaysToBuild) : IRequest<Either<BaseError, Unit>>;
@@ -0,0 +1,5 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.Configuration;
public record UpdatePlayoutSettings(PlayoutSettingsViewModel PlayoutSettings) : IRequest<Either<BaseError, Unit>>;
@@ -9,13 +9,13 @@ using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Configuration;
public class UpdatePlayoutDaysToBuildHandler : IRequestHandler<UpdatePlayoutDaysToBuild, Either<BaseError, Unit>>
public class UpdatePlayoutSettingsHandler : IRequestHandler<UpdatePlayoutSettings, Either<BaseError, Unit>>
{
private readonly IConfigElementRepository _configElementRepository;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
public UpdatePlayoutDaysToBuildHandler(
public UpdatePlayoutSettingsHandler(
IConfigElementRepository configElementRepository,
IDbContextFactory<TvContext> dbContextFactory,
ChannelWriter<IBackgroundServiceRequest> workerChannel)
@@ -26,17 +26,20 @@ public class UpdatePlayoutDaysToBuildHandler : IRequestHandler<UpdatePlayoutDays
}
public async Task<Either<BaseError, Unit>> Handle(
UpdatePlayoutDaysToBuild request,
UpdatePlayoutSettings request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Unit> validation = await Validate(request);
return await validation.Apply<Unit, Unit>(_ => ApplyUpdate(dbContext, request.DaysToBuild));
return await validation.Apply<Unit, Unit>(_ => ApplyUpdate(dbContext, request.PlayoutSettings));
}
private async Task<Unit> ApplyUpdate(TvContext dbContext, int daysToBuild)
private async Task<Unit> ApplyUpdate(TvContext dbContext, PlayoutSettingsViewModel playoutSettings)
{
await _configElementRepository.Upsert(ConfigElementKey.PlayoutDaysToBuild, daysToBuild);
await _configElementRepository.Upsert(ConfigElementKey.PlayoutDaysToBuild, playoutSettings.DaysToBuild);
await _configElementRepository.Upsert(
ConfigElementKey.PlayoutSkipMissingItems,
playoutSettings.SkipMissingItems);
// continue all playouts to proper number of days
List<Playout> playouts = await dbContext.Playouts
@@ -50,8 +53,8 @@ public class UpdatePlayoutDaysToBuildHandler : IRequestHandler<UpdatePlayoutDays
return Unit.Default;
}
private static Task<Validation<BaseError, Unit>> Validate(UpdatePlayoutDaysToBuild request) =>
Optional(request.DaysToBuild)
private static Task<Validation<BaseError, Unit>> Validate(UpdatePlayoutSettings request) =>
Optional(request.PlayoutSettings.DaysToBuild)
.Where(days => days > 0)
.Map(_ => Unit.Default)
.ToValidation<BaseError>("Days to build must be greater than zero")
@@ -0,0 +1,7 @@
namespace ErsatzTV.Application.Configuration;
public class PlayoutSettingsViewModel
{
public int DaysToBuild { get; set; }
public bool SkipMissingItems { get; set; }
}
@@ -1,3 +0,0 @@
namespace ErsatzTV.Application.Configuration;
public record GetPlayoutDaysToBuild : IRequest<int>;
@@ -1,16 +0,0 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
namespace ErsatzTV.Application.Configuration;
public class GetPlayoutDaysToBuildHandler : IRequestHandler<GetPlayoutDaysToBuild, int>
{
private readonly IConfigElementRepository _configElementRepository;
public GetPlayoutDaysToBuildHandler(IConfigElementRepository configElementRepository) =>
_configElementRepository = configElementRepository;
public Task<int> Handle(GetPlayoutDaysToBuild request, CancellationToken cancellationToken) =>
_configElementRepository.GetValue<int>(ConfigElementKey.PlayoutDaysToBuild)
.Map(result => result.IfNone(2));
}
@@ -0,0 +1,3 @@
namespace ErsatzTV.Application.Configuration;
public record GetPlayoutSettings : IRequest<PlayoutSettingsViewModel>;
@@ -0,0 +1,26 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
namespace ErsatzTV.Application.Configuration;
public class GetPlayoutSettingsHandler : IRequestHandler<GetPlayoutSettings, PlayoutSettingsViewModel>
{
private readonly IConfigElementRepository _configElementRepository;
public GetPlayoutSettingsHandler(IConfigElementRepository configElementRepository) =>
_configElementRepository = configElementRepository;
public async Task<PlayoutSettingsViewModel> Handle(GetPlayoutSettings request, CancellationToken cancellationToken)
{
Option<int> daysToBuild = await _configElementRepository.GetValue<int>(ConfigElementKey.PlayoutDaysToBuild);
Option<bool> skipMissingItems =
await _configElementRepository.GetValue<bool>(ConfigElementKey.PlayoutSkipMissingItems);
return new PlayoutSettingsViewModel
{
DaysToBuild = await daysToBuild.IfNoneAsync(2),
SkipMissingItems = await skipMissingItems.IfNoneAsync(false)
};
}
}
@@ -12,7 +12,7 @@
<PackageReference Include="Humanizer.Core" Version="2.14.1" />
<PackageReference Include="MediatR" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="6.0.0" />
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.1.46">
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.2.32">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
@@ -20,7 +20,7 @@ public class CreateFFmpegProfileHandler :
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, FFmpegProfile> validation = await Validate(dbContext, request);
return await LanguageExtensions.Apply(validation, profile => PersistFFmpegProfile(dbContext, profile));
return await validation.Apply(profile => PersistFFmpegProfile(dbContext, profile));
}
private static async Task<CreateFFmpegProfileResult> PersistFFmpegProfile(
@@ -35,6 +35,27 @@ internal static class Mapper
ffmpegProfile.VideoFormat.ToString().ToLowerInvariant(),
ffmpegProfile.AudioFormat.ToString().ToLowerInvariant());
internal static FFmpegFullProfileResponseModel ProjectToFullResponseModel(FFmpegProfile ffmpegProfile) =>
new(
ffmpegProfile.Id,
ffmpegProfile.Name,
ffmpegProfile.ThreadCount,
(int)ffmpegProfile.HardwareAcceleration,
(int)ffmpegProfile.VaapiDriver,
ffmpegProfile.VaapiDevice,
ffmpegProfile.ResolutionId,
(int)ffmpegProfile.VideoFormat,
ffmpegProfile.VideoBitrate,
ffmpegProfile.VideoBufferSize,
(int)ffmpegProfile.AudioFormat,
ffmpegProfile.AudioBitrate,
ffmpegProfile.AudioBufferSize,
ffmpegProfile.NormalizeLoudness,
ffmpegProfile.AudioChannels,
ffmpegProfile.AudioSampleRate,
ffmpegProfile.NormalizeFramerate,
ffmpegProfile.DeinterlaceVideo);
private static ResolutionViewModel Project(Resolution resolution) =>
new(resolution.Id, resolution.Name, resolution.Width, resolution.Height);
}
@@ -0,0 +1,5 @@
using ErsatzTV.Core.Api.FFmpegProfiles;
namespace ErsatzTV.Application.FFmpegProfiles;
public record GetFFmpegFullProfileByIdForApi(int Id) : IRequest<Option<FFmpegFullProfileResponseModel>>;
@@ -0,0 +1,28 @@
using ErsatzTV.Core.Api.FFmpegProfiles;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.FFmpegProfiles.Mapper;
namespace ErsatzTV.Application.FFmpegProfiles;
public class
GetFFmpegProfileByIdForApiHandler : IRequestHandler<GetFFmpegFullProfileByIdForApi,
Option<FFmpegFullProfileResponseModel>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public GetFFmpegProfileByIdForApiHandler(IDbContextFactory<TvContext> dbContextFactory) =>
_dbContextFactory = dbContextFactory;
public async Task<Option<FFmpegFullProfileResponseModel>> Handle(
GetFFmpegFullProfileByIdForApi request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
return await dbContext.FFmpegProfiles
.Include(p => p.Resolution)
.SelectOneAsync(p => p.Id, p => p.Id == request.Id)
.MapT(ProjectToFullResponseModel);
}
}
@@ -11,6 +11,7 @@ public record CreateFillerPreset(
TimeSpan? Duration,
int? Count,
int? PadToNearestMinute,
bool AllowWatermarks,
ProgramScheduleItemCollectionType CollectionType,
int? CollectionId,
int? MediaItemId,
@@ -31,6 +31,7 @@ public class CreateFillerPresetHandler : IRequestHandler<CreateFillerPreset, Eit
Duration = request.Duration,
Count = request.Count,
PadToNearestMinute = request.PadToNearestMinute,
AllowWatermarks = request.AllowWatermarks,
CollectionType = request.CollectionType,
CollectionId = request.CollectionId,
MediaItemId = request.MediaItemId,
@@ -12,6 +12,7 @@ public record UpdateFillerPreset(
TimeSpan? Duration,
int? Count,
int? PadToNearestMinute,
bool AllowWatermarks,
ProgramScheduleItemCollectionType CollectionType,
int? CollectionId,
int? MediaItemId,
@@ -15,8 +15,7 @@ public class UpdateFillerPresetHandler : IRequestHandler<UpdateFillerPreset, Eit
public async Task<Either<BaseError, Unit>> Handle(UpdateFillerPreset request, CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, FillerPreset> validation = await FillerPresetMustExist(dbContext, request);
return await LanguageExtensions.Apply(validation, ps => ApplyUpdateRequest(dbContext, ps, request));
}
@@ -32,6 +31,7 @@ public class UpdateFillerPresetHandler : IRequestHandler<UpdateFillerPreset, Eit
existing.Duration = request.Duration;
existing.Count = request.Count;
existing.PadToNearestMinute = request.PadToNearestMinute;
existing.AllowWatermarks = request.AllowWatermarks;
existing.CollectionType = request.CollectionType;
existing.CollectionId = request.CollectionId;
existing.MediaItemId = request.MediaItemId;
@@ -11,6 +11,7 @@ public record FillerPresetViewModel(
TimeSpan? Duration,
int? Count,
int? PadToNearestMinute,
bool AllowWatermarks,
ProgramScheduleItemCollectionType CollectionType,
int? CollectionId,
int? MediaItemId,
+1
View File
@@ -13,6 +13,7 @@ internal static class Mapper
fillerPreset.Duration,
fillerPreset.Count,
fillerPreset.PadToNearestMinute,
fillerPreset.AllowWatermarks,
fillerPreset.CollectionType,
fillerPreset.CollectionId,
fillerPreset.MediaItemId,
@@ -6,4 +6,5 @@ public record CreateProgramSchedule(
string Name,
bool KeepMultiPartEpisodesTogether,
bool TreatCollectionsAsShows,
bool ShuffleScheduleItems) : IRequest<Either<BaseError, CreateProgramScheduleResult>>;
bool ShuffleScheduleItems,
bool RandomStartPoint) : IRequest<Either<BaseError, CreateProgramScheduleResult>>;
@@ -44,7 +44,8 @@ public class CreateProgramScheduleHandler :
Name = name,
KeepMultiPartEpisodesTogether = keepMultiPartEpisodesTogether,
TreatCollectionsAsShows = keepMultiPartEpisodesTogether && request.TreatCollectionsAsShows,
ShuffleScheduleItems = request.ShuffleScheduleItems
ShuffleScheduleItems = request.ShuffleScheduleItems,
RandomStartPoint = request.RandomStartPoint
};
});
@@ -8,4 +8,5 @@ public record UpdateProgramSchedule
string Name,
bool KeepMultiPartEpisodesTogether,
bool TreatCollectionsAsShows,
bool ShuffleScheduleItems) : IRequest<Either<BaseError, UpdateProgramScheduleResult>>;
bool ShuffleScheduleItems,
bool RandomStartPoint) : IRequest<Either<BaseError, UpdateProgramScheduleResult>>;
@@ -41,13 +41,15 @@ public class UpdateProgramScheduleHandler :
bool needToRefreshPlayout =
programSchedule.KeepMultiPartEpisodesTogether != request.KeepMultiPartEpisodesTogether ||
programSchedule.TreatCollectionsAsShows != request.TreatCollectionsAsShows ||
programSchedule.ShuffleScheduleItems != request.ShuffleScheduleItems;
programSchedule.ShuffleScheduleItems != request.ShuffleScheduleItems ||
programSchedule.RandomStartPoint != request.RandomStartPoint;
programSchedule.Name = request.Name;
programSchedule.KeepMultiPartEpisodesTogether = request.KeepMultiPartEpisodesTogether;
programSchedule.TreatCollectionsAsShows = programSchedule.KeepMultiPartEpisodesTogether &&
request.TreatCollectionsAsShows;
programSchedule.ShuffleScheduleItems = request.ShuffleScheduleItems;
programSchedule.RandomStartPoint = request.RandomStartPoint;
await dbContext.SaveChangesAsync();
@@ -10,7 +10,8 @@ internal static class Mapper
programSchedule.Name,
programSchedule.KeepMultiPartEpisodesTogether,
programSchedule.TreatCollectionsAsShows,
programSchedule.ShuffleScheduleItems);
programSchedule.ShuffleScheduleItems,
programSchedule.RandomStartPoint);
internal static ProgramScheduleItemViewModel ProjectToViewModel(ProgramScheduleItem programScheduleItem) =>
programScheduleItem switch
@@ -5,4 +5,5 @@ public record ProgramScheduleViewModel(
string Name,
bool KeepMultiPartEpisodesTogether,
bool TreatCollectionsAsShows,
bool ShuffleScheduleItems);
bool ShuffleScheduleItems,
bool RandomStartPoint);
@@ -22,7 +22,8 @@ public class GetAllProgramSchedulesHandler : IRequestHandler<GetAllProgramSchedu
ps.Name,
ps.KeepMultiPartEpisodesTogether,
ps.TreatCollectionsAsShows,
ps.ShuffleScheduleItems))
ps.ShuffleScheduleItems,
ps.RandomStartPoint))
.ToListAsync(cancellationToken);
}
}
@@ -0,0 +1,3 @@
namespace ErsatzTV.Application.Search;
public record QuerySearchTargets : IRequest<List<SearchTargetViewModel>>;
@@ -0,0 +1,54 @@
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Search;
public class QuerySearchTargetsHandler : IRequestHandler<QuerySearchTargets, List<SearchTargetViewModel>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public QuerySearchTargetsHandler(IDbContextFactory<TvContext> dbContextFactory) =>
_dbContextFactory = dbContextFactory;
public async Task<List<SearchTargetViewModel>> Handle(
QuerySearchTargets request,
CancellationToken cancellationToken)
{
var result = new List<SearchTargetViewModel>();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
result.AddRange(
dbContext.Channels
.Map(c => new SearchTargetViewModel(c.Id, c.Name, SearchTargetKind.Channel)));
result.AddRange(
dbContext.FFmpegProfiles
.Map(f => new SearchTargetViewModel(f.Id, f.Name, SearchTargetKind.FFmpegProfile)));
result.AddRange(
dbContext.ChannelWatermarks
.Map(w => new SearchTargetViewModel(w.Id, w.Name, SearchTargetKind.ChannelWatermark)));
result.AddRange(
dbContext.Collections
.Map(c => new SearchTargetViewModel(c.Id, c.Name, SearchTargetKind.Collection)));
result.AddRange(
dbContext.MultiCollections
.Map(mc => new SearchTargetViewModel(mc.Id, mc.Name, SearchTargetKind.MultiCollection)));
result.AddRange(
dbContext.SmartCollections
.Map(sc => new SmartCollectionSearchTargetViewModel(sc.Id, sc.Name, sc.Query)));
var schedules = await dbContext.ProgramSchedules
.Map(s => new { s.Id, s.Name })
.ToListAsync(cancellationToken);
result.AddRange(
schedules.SelectMany(
s => new[]
{
new SearchTargetViewModel(s.Id, s.Name, SearchTargetKind.Schedule),
new SearchTargetViewModel(s.Id, s.Name, SearchTargetKind.ScheduleItems)
}));
return result;
}
}
@@ -0,0 +1,18 @@
namespace ErsatzTV.Application.Search;
public record SearchTargetViewModel(int Id, string Name, SearchTargetKind Kind);
public record SmartCollectionSearchTargetViewModel(int Id, string Name, string Query)
: SearchTargetViewModel(Id, Name, SearchTargetKind.SmartCollection);
public enum SearchTargetKind
{
Channel = 1,
FFmpegProfile = 2,
ChannelWatermark = 3,
Collection = 4,
MultiCollection = 5,
SmartCollection = 6,
Schedule = 7,
ScheduleItems = 8
}
@@ -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;
@@ -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);
}
@@ -27,6 +27,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
private readonly ILocalFileSystem _localFileSystem;
private readonly ILogger<GetPlayoutItemProcessByChannelNumberHandler> _logger;
private readonly IMediaCollectionRepository _mediaCollectionRepository;
private readonly IMusicVideoCreditsGenerator _musicVideoCreditsGenerator;
private readonly IPlexPathReplacementService _plexPathReplacementService;
private readonly ISongVideoGenerator _songVideoGenerator;
private readonly ITelevisionRepository _televisionRepository;
@@ -42,6 +43,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
ITelevisionRepository televisionRepository,
IArtistRepository artistRepository,
ISongVideoGenerator songVideoGenerator,
IMusicVideoCreditsGenerator musicVideoCreditsGenerator,
ILogger<GetPlayoutItemProcessByChannelNumberHandler> logger)
: base(dbContextFactory)
{
@@ -54,6 +56,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
_televisionRepository = televisionRepository;
_artistRepository = artistRepository;
_songVideoGenerator = songVideoGenerator;
_musicVideoCreditsGenerator = musicVideoCreditsGenerator;
_logger = logger;
}
@@ -96,6 +99,9 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
.ThenInclude(mi => (mi as MusicVideo).MediaVersions)
.ThenInclude(mv => mv.Streams)
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as MusicVideo).Artist)
.ThenInclude(mv => mv.ArtistMetadata)
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as OtherVideo).OtherVideoMetadata)
.ThenInclude(ovm => ovm.Subtitles)
.Include(i => i.MediaItem)
@@ -155,7 +161,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
.GetValue<bool>(ConfigElementKey.FFmpegSaveReports)
.Map(result => result.IfNone(false));
List<Subtitle> subtitles = GetSubtitles(playoutItemWithPath);
List<Subtitle> subtitles = await GetSubtitles(playoutItemWithPath, channel);
Command process = await _ffmpegProcessService.ForPlayoutItem(
ffmpegPath,
@@ -182,7 +188,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 +227,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 +239,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 +251,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);
}
@@ -249,22 +262,22 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
return BaseError.New($"Unexpected error locating playout item for channel {channel.Number}");
}
private static List<Subtitle> GetSubtitles(PlayoutItemWithPath playoutItemWithPath)
private async Task<List<Subtitle>> GetSubtitles(
PlayoutItemWithPath playoutItemWithPath,
Channel channel)
{
List<Subtitle> allSubtitles = playoutItemWithPath.PlayoutItem.MediaItem switch
{
Episode episode => Optional(episode.EpisodeMetadata).Flatten().HeadOrNone()
Episode episode => await Optional(episode.EpisodeMetadata).Flatten().HeadOrNone()
.Map(mm => mm.Subtitles ?? new List<Subtitle>())
.IfNone(new List<Subtitle>()),
Movie movie => Optional(movie.MovieMetadata).Flatten().HeadOrNone()
.IfNoneAsync(new List<Subtitle>()),
Movie movie => await Optional(movie.MovieMetadata).Flatten().HeadOrNone()
.Map(mm => mm.Subtitles ?? new List<Subtitle>())
.IfNone(new List<Subtitle>()),
MusicVideo musicVideo => Optional(musicVideo.MusicVideoMetadata).Flatten().HeadOrNone()
.IfNoneAsync(new List<Subtitle>()),
MusicVideo musicVideo => await GetMusicVideoSubtitles(musicVideo, channel),
OtherVideo otherVideo => await Optional(otherVideo.OtherVideoMetadata).Flatten().HeadOrNone()
.Map(mm => mm.Subtitles ?? new List<Subtitle>())
.IfNone(new List<Subtitle>()),
OtherVideo otherVideo => Optional(otherVideo.OtherVideoMetadata).Flatten().HeadOrNone()
.Map(mm => mm.Subtitles ?? new List<Subtitle>())
.IfNone(new List<Subtitle>()),
.IfNoneAsync(new List<Subtitle>()),
_ => new List<Subtitle>()
};
@@ -302,6 +315,27 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
return allSubtitles;
}
private async Task<List<Subtitle>> GetMusicVideoSubtitles(MusicVideo musicVideo, Channel channel)
{
var subtitles = new List<Subtitle>();
bool musicVideoCredits = channel.MusicVideoCreditsMode == ChannelMusicVideoCreditsMode.GenerateSubtitles;
if (musicVideoCredits)
{
subtitles.AddRange(
await _musicVideoCreditsGenerator.GenerateCreditsSubtitle(musicVideo, channel.FFmpegProfile));
}
else
{
subtitles.AddRange(
await Optional(musicVideo.MusicVideoMetadata).Flatten().HeadOrNone()
.Map(mm => mm.Subtitles)
.IfNoneAsync(new List<Subtitle>()));
}
return subtitles;
}
private async Task<Either<BaseError, PlayoutItemWithPath>> CheckForFallbackFiller(
TvContext dbContext,
Channel channel,
@@ -371,7 +405,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);
+14 -5
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>
@@ -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")
@@ -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)}");
@@ -565,10 +566,8 @@ public class TranscodingTests
Optional(version.Streams.First(s => s.MediaStreamKind == MediaStreamKind.Audio)).AsTask();
public Task<Option<Domain.Subtitle>> SelectSubtitleStream(
MediaVersion version,
List<Domain.Subtitle> subtitles,
StreamingMode streamingMode,
string channelNumber,
Channel channel,
string preferredSubtitleLanguage,
ChannelSubtitleMode subtitleMode) =>
subtitles.HeadOrNone().AsTask();
@@ -2,8 +2,11 @@
using Bugsnag;
using ErsatzTV.Core.Metadata.Nfo;
using FluentAssertions;
using Microsoft.Extensions.Logging;
using Microsoft.IO;
using Moq;
using NUnit.Framework;
using Serilog;
namespace ErsatzTV.Core.Tests.Metadata.Nfo;
@@ -11,7 +14,24 @@ namespace ErsatzTV.Core.Tests.Metadata.Nfo;
public class ArtistNfoReaderTests
{
[SetUp]
public void SetUp() => _artistNfoReader = new ArtistNfoReader(new Mock<IClient>().Object);
public void SetUp() => _artistNfoReader = new ArtistNfoReader(
new RecyclableMemoryStreamManager(),
new Mock<IClient>().Object,
_logger);
private readonly ILogger<ArtistNfoReader> _logger;
public ArtistNfoReaderTests()
{
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Debug()
.WriteTo.Console()
.CreateLogger();
ILoggerFactory loggerFactory = new LoggerFactory().AddSerilog(Log.Logger);
_logger = loggerFactory.CreateLogger<ArtistNfoReader>();
}
private ArtistNfoReader _artistNfoReader;
@@ -153,6 +173,42 @@ Joel attended Hicksville High School in 1967, but he did not graduate with his c
}
}
[Test]
public async Task Invalid_Characters_End_Should_Abort_And_Return_Nfo()
{
string sourceFile = Path.Combine(
TestContext.CurrentContext.TestDirectory,
"Resources",
"Nfo",
"ArtistInvalidCharacters1.nfo");
Either<BaseError, ArtistNfo> result = await _artistNfoReader.ReadFromFile(sourceFile);
result.IsRight.Should().BeTrue();
foreach (ArtistNfo nfo in result.RightToSeq())
{
nfo.Name.Should().Be("Test Name");
}
}
[Test]
public async Task Invalid_Characters_Middle_Should_Continue_And_Return_Nfo()
{
string sourceFile = Path.Combine(
TestContext.CurrentContext.TestDirectory,
"Resources",
"Nfo",
"ArtistInvalidCharacters2.nfo");
Either<BaseError, ArtistNfo> result = await _artistNfoReader.ReadFromFile(sourceFile);
result.IsRight.Should().BeTrue();
foreach (ArtistNfo nfo in result.RightToSeq())
{
nfo.Name.Should().Be("Test Name");
nfo.Moods.Should().BeEquivalentTo(new List<string> { "Test Mood" });
nfo.Styles.Count.Should().Be(1);
}
}
private static string NormalizeLineEndingsLF(string str) =>
str
.Replace("\r\n", "\n")
@@ -2,8 +2,11 @@
using Bugsnag;
using ErsatzTV.Core.Metadata.Nfo;
using FluentAssertions;
using Microsoft.Extensions.Logging;
using Microsoft.IO;
using Moq;
using NUnit.Framework;
using Serilog;
namespace ErsatzTV.Core.Tests.Metadata.Nfo;
@@ -11,7 +14,24 @@ namespace ErsatzTV.Core.Tests.Metadata.Nfo;
public class EpisodeNfoReaderTests
{
[SetUp]
public void SetUp() => _episodeNfoReader = new EpisodeNfoReader(new Mock<IClient>().Object);
public void SetUp() => _episodeNfoReader = new EpisodeNfoReader(
new RecyclableMemoryStreamManager(),
new Mock<IClient>().Object,
_logger);
private readonly ILogger<EpisodeNfoReader> _logger;
public EpisodeNfoReaderTests()
{
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Debug()
.WriteTo.Console()
.CreateLogger();
ILoggerFactory loggerFactory = new LoggerFactory().AddSerilog(Log.Logger);
_logger = loggerFactory.CreateLogger<EpisodeNfoReader>();
}
private EpisodeNfoReader _episodeNfoReader;
@@ -404,4 +424,22 @@ public class EpisodeNfoReaderTests
});
}
}
[Test]
public async Task Invalid_Characters_Should_Abort_And_Return_Nfo()
{
string sourceFile = Path.Combine(
TestContext.CurrentContext.TestDirectory,
"Resources",
"Nfo",
"EpisodeInvalidCharacters.nfo");
Either<BaseError, List<TvShowEpisodeNfo>> result = await _episodeNfoReader.ReadFromFile(sourceFile);
result.IsRight.Should().BeTrue();
foreach (List<TvShowEpisodeNfo> list in result.RightToSeq())
{
list.Count.Should().Be(1);
list[0].Title.Should().Be("Test Title");
}
}
}
@@ -2,6 +2,8 @@
using Bugsnag;
using ErsatzTV.Core.Metadata.Nfo;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.IO;
using Moq;
using NUnit.Framework;
@@ -11,7 +13,10 @@ namespace ErsatzTV.Core.Tests.Metadata.Nfo;
public class MovieNfoReaderTests
{
[SetUp]
public void SetUp() => _movieNfoReader = new MovieNfoReader(new Mock<IClient>().Object);
public void SetUp() => _movieNfoReader = new MovieNfoReader(
new RecyclableMemoryStreamManager(),
new Mock<IClient>().Object,
new NullLogger<MovieNfoReader>());
private MovieNfoReader _movieNfoReader;
@@ -2,6 +2,8 @@
using Bugsnag;
using ErsatzTV.Core.Metadata.Nfo;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.IO;
using Moq;
using NUnit.Framework;
@@ -11,7 +13,10 @@ namespace ErsatzTV.Core.Tests.Metadata.Nfo;
public class MusicVideoNfoReaderTests
{
[SetUp]
public void SetUp() => _musicVideoNfoReader = new MusicVideoNfoReader(new Mock<IClient>().Object);
public void SetUp() => _musicVideoNfoReader = new MusicVideoNfoReader(
new RecyclableMemoryStreamManager(),
new Mock<IClient>().Object,
new NullLogger<MusicVideoNfoReader>());
private MusicVideoNfoReader _musicVideoNfoReader;
@@ -124,6 +129,7 @@ Le groupe a également enregistré une version espagnole de ce titre, La reina d
nfo.Year.Should().Be(1976);
nfo.Aired.IsNone.Should().BeTrue();
nfo.Genres.Should().BeEquivalentTo(new List<string> { "Pop" });
nfo.Track.Should().Be(-1);
}
}
@@ -2,6 +2,8 @@
using Bugsnag;
using ErsatzTV.Core.Metadata.Nfo;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.IO;
using Moq;
using NUnit.Framework;
@@ -11,7 +13,10 @@ namespace ErsatzTV.Core.Tests.Metadata.Nfo;
public class OtherVideoNfoReaderTests
{
[SetUp]
public void SetUp() => _otherVideoNfoReader = new OtherVideoNfoReader(new Mock<IClient>().Object);
public void SetUp() => _otherVideoNfoReader = new OtherVideoNfoReader(
new RecyclableMemoryStreamManager(),
new Mock<IClient>().Object,
new NullLogger<OtherVideoNfoReader>());
private OtherVideoNfoReader _otherVideoNfoReader;
@@ -2,6 +2,8 @@
using Bugsnag;
using ErsatzTV.Core.Metadata.Nfo;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.IO;
using Moq;
using NUnit.Framework;
@@ -11,7 +13,10 @@ namespace ErsatzTV.Core.Tests.Metadata.Nfo;
public class TvShowNfoReaderTests
{
[SetUp]
public void SetUp() => _tvShowNfoReader = new TvShowNfoReader(new Mock<IClient>().Object);
public void SetUp() => _tvShowNfoReader = new TvShowNfoReader(
new RecyclableMemoryStreamManager(),
new Mock<IClient>().Object,
new NullLogger<TvShowNfoReader>());
private TvShowNfoReader _tvShowNfoReader;
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<!--created on whatever - comment-->
<artist>
<name>Test Name</name>
</artist>
ÐPS½NÞ5Þ*˜¡¡ã·Ýq×ÍâeVk—¯¬}É
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<!--created on whatever - comment-->
<artist>
<name>Test Name</name>
<style>ÐPS½NÞ5Þ*˜¡¡ã·Ýq×ÍâeVk—¯¬}É</style>
<mood>Test Mood</mood>
</artist>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<!--created on whatever - comment-->
<episodedetails>
<title>Test Title</title>
</episodedetails>
ÐPS½NÞ5Þ*˜¡¡ã·Ýq×ÍâeVk—¯¬}É
@@ -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>();
@@ -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);
}
}
@@ -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)
@@ -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)
@@ -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)
@@ -0,0 +1,21 @@
namespace ErsatzTV.Core.Api.FFmpegProfiles;
public record FFmpegFullProfileResponseModel(
int Id,
string Name,
int ThreadCount,
int HardwareAcceleration,
int VaapiDriver,
string VaapiDevice,
int ResolutionId,
int VideoFormat,
int VideoBitrate,
int VideoBufferSize,
int AudioFormat,
int AudioBitrate,
int AudioBufferSize,
bool NormalizeLoudness,
int AudioChannels,
int AudioSampleRate,
bool NormalizeFramerate,
bool? DeinterlaceVideo);
+1
View File
@@ -25,4 +25,5 @@ public class Channel
public string PreferredAudioLanguageCode { get; set; }
public string PreferredSubtitleLanguageCode { get; set; }
public ChannelSubtitleMode SubtitleMode { get; set; }
public ChannelMusicVideoCreditsMode MusicVideoCreditsMode { get; set; }
}
@@ -0,0 +1,7 @@
namespace ErsatzTV.Core.Domain;
public enum ChannelMusicVideoCreditsMode
{
None = 0,
GenerateSubtitles = 1
}
+1
View File
@@ -33,4 +33,5 @@ public class ConfigElementKey
public static ConfigElementKey FillerPresetsPageSize => new("pages.filler_presets.page_size");
public static ConfigElementKey LibraryRefreshInterval => new("scanner.library_refresh_interval");
public static ConfigElementKey PlayoutDaysToBuild => new("playout.days_to_build");
public static ConfigElementKey PlayoutSkipMissingItems => new("playout.skip_missing_items");
}
@@ -9,6 +9,7 @@ public class FillerPreset
public TimeSpan? Duration { get; set; }
public int? Count { get; set; }
public int? PadToNearestMinute { get; set; }
public bool AllowWatermarks { get; set; }
public ProgramScheduleItemCollectionType CollectionType { get; set; }
public int? CollectionId { get; set; }
public Collection Collection { get; set; }
@@ -4,6 +4,7 @@ public class MusicVideoMetadata : Metadata
{
public string Album { get; set; }
public string Plot { get; set; }
public int? Track { get; set; }
public int MusicVideoId { get; set; }
public MusicVideo MusicVideo { get; set; }
public List<MusicVideoArtist> Artists { get; set; }
@@ -3,5 +3,7 @@
public enum SubtitleKind
{
Embedded = 0,
Sidecar = 1
Sidecar = 1,
Generated = 99
}
+1
View File
@@ -22,6 +22,7 @@ public class PlayoutItem
public string ChapterTitle { get; set; }
public ChannelWatermark Watermark { get; set; }
public int? WatermarkId { get; set; }
public bool DisableWatermarks { get; set; }
public string PreferredAudioLanguageCode { get; set; }
public string PreferredSubtitleLanguageCode { get; set; }
public ChannelSubtitleMode? SubtitleMode { get; set; }
+1
View File
@@ -7,6 +7,7 @@ public class ProgramSchedule
public bool KeepMultiPartEpisodesTogether { get; set; }
public bool TreatCollectionsAsShows { get; set; }
public bool ShuffleScheduleItems { get; set; }
public bool RandomStartPoint { get; set; }
public List<ProgramScheduleItem> Items { get; set; }
public List<Playout> Playouts { get; set; }
}
+38 -41
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();
}
}
+11
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";
}
+10 -1
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(
@@ -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(
+4 -3
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>
@@ -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 =
@@ -70,10 +71,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
preferredAudioLanguage);
Option<Subtitle> maybeSubtitle =
await _ffmpegStreamSelector.SelectSubtitleStream(
videoVersion,
subtitles,
channel.StreamingMode,
channel.Number,
channel,
preferredSubtitleLanguage,
subtitleMode);
@@ -90,8 +89,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 +179,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 +225,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
outputFormat,
hlsPlaylistPath,
hlsSegmentTemplate,
ptsOffset);
ptsOffset,
playbackSettings.ThreadCount);
_logger.LogDebug("FFmpeg desired state {FrameState}", desiredState);
@@ -256,7 +250,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 +287,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
playbackSettings.AudioBufferSize,
playbackSettings.AudioSampleRate,
Option<TimeSpan>.None,
playbackSettings.NormalizeLoudness);
false);
var desiredState = new FrameState(
playbackSettings.RealtimeOutput,
@@ -325,18 +321,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 +344,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 +597,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
};
}
@@ -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) =>
+1 -1
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
+12 -7
View File
@@ -82,23 +82,28 @@ 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(
MediaVersion version,
List<Subtitle> subtitles,
StreamingMode streamingMode,
string channelNumber,
Channel channel,
string preferredSubtitleLanguage,
ChannelSubtitleMode subtitleMode)
{
if (channel.MusicVideoCreditsMode == ChannelMusicVideoCreditsMode.GenerateSubtitles &&
subtitles.FirstOrDefault(s => s.SubtitleKind == SubtitleKind.Generated) is { } generatedSubtitle)
{
_logger.LogDebug("Selecting generated subtitle for channel {Number}", channel.Number);
return Optional(generatedSubtitle);
}
if (subtitleMode == ChannelSubtitleMode.None)
{
return None;
}
if (streamingMode == StreamingMode.HttpLiveStreamingDirect &&
if (channel.StreamingMode == StreamingMode.HttpLiveStreamingDirect &&
string.IsNullOrWhiteSpace(preferredSubtitleLanguage))
{
// _logger.LogDebug(
@@ -110,7 +115,7 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector
string language = (preferredSubtitleLanguage ?? string.Empty).ToLowerInvariant();
if (string.IsNullOrWhiteSpace(language))
{
_logger.LogDebug("Channel {Number} has no preferred subtitle language code", channelNumber);
_logger.LogDebug("Channel {Number} has no preferred subtitle language code", channel.Number);
}
else
{
@@ -152,7 +157,7 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector
_logger.LogDebug(
"Found no subtitles for channel {ChannelNumber} with mode {Mode} matching language {Language}",
channelNumber,
channel.Number,
subtitleMode,
preferredSubtitleLanguage);
+111 -53
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);
@@ -0,0 +1,84 @@
using System.Text;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.FFmpeg;
namespace ErsatzTV.Core.FFmpeg;
public class MusicVideoCreditsGenerator : IMusicVideoCreditsGenerator
{
private readonly ITempFilePool _tempFilePool;
public MusicVideoCreditsGenerator(ITempFilePool tempFilePool) => _tempFilePool = tempFilePool;
public async Task<Option<Subtitle>> GenerateCreditsSubtitle(MusicVideo musicVideo, FFmpegProfile ffmpegProfile)
{
const int HORIZONTAL_MARGIN_PERCENT = 3;
const int VERTICAL_MARGIN_PERCENT = 5;
var fontSize = (int)Math.Round(ffmpegProfile.Resolution.Height / 20.0);
int leftMarginPercent = HORIZONTAL_MARGIN_PERCENT;
int rightMarginPercent = HORIZONTAL_MARGIN_PERCENT;
var leftMargin = (int)Math.Round(leftMarginPercent / 100.0 * ffmpegProfile.Resolution.Width);
var rightMargin = (int)Math.Round(rightMarginPercent / 100.0 * ffmpegProfile.Resolution.Width);
var verticalMargin =
(int)Math.Round(VERTICAL_MARGIN_PERCENT / 100.0 * ffmpegProfile.Resolution.Height);
foreach (MusicVideoMetadata metadata in musicVideo.MusicVideoMetadata)
{
var sb = new StringBuilder();
string artist = string.Empty;
foreach (ArtistMetadata artistMetadata in Optional(metadata.MusicVideo?.Artist?.ArtistMetadata).Flatten())
{
artist = artistMetadata.Title;
}
if (!string.IsNullOrWhiteSpace(artist))
{
sb.Append(artist);
}
if (!string.IsNullOrWhiteSpace(metadata.Title))
{
sb.Append($"\\N\"{metadata.Title}\"");
}
if (!string.IsNullOrWhiteSpace(metadata.Album))
{
sb.Append($"\\N{metadata.Album}");
}
string subtitles = await new SubtitleBuilder(_tempFilePool)
.WithResolution(ffmpegProfile.Resolution)
.WithFontName("OPTIKabel-Heavy")
.WithFontSize(fontSize)
.WithPrimaryColor("&HFFFFFF")
.WithOutlineColor("&H444444")
.WithAlignment(0)
.WithMarginRight(rightMargin)
.WithMarginLeft(leftMargin)
.WithMarginV(verticalMargin)
.WithBorderStyle(1)
.WithShadow(3)
.WithFormattedContent(sb.ToString())
.WithStartEnd(TimeSpan.FromSeconds(9), TimeSpan.FromSeconds(16))
.WithFade(true)
.BuildFile();
return new Subtitle
{
Codec = "ass",
Default = true,
Forced = true,
IsExtracted = false,
SubtitleKind = SubtitleKind.Generated,
Path = subtitles,
SDH = false
};
}
return None;
}
}
+31 -6
View File
@@ -9,6 +9,8 @@ public class SubtitleBuilder
private Option<int> _alignment;
private Option<int> _borderStyle;
private string _content;
private Option<TimeSpan> _end;
private bool _fade;
private Option<string> _fontName;
private Option<int> _fontSize;
private int _marginLeft;
@@ -18,6 +20,7 @@ public class SubtitleBuilder
private Option<string> _primaryColor;
private Option<IDisplaySize> _resolution = None;
private Option<int> _shadow;
private Option<TimeSpan> _start;
public SubtitleBuilder(ITempFilePool tempFilePool) => _tempFilePool = tempFilePool;
@@ -93,6 +96,19 @@ public class SubtitleBuilder
return this;
}
public SubtitleBuilder WithStartEnd(TimeSpan start, TimeSpan end)
{
_start = start;
_end = end;
return this;
}
public SubtitleBuilder WithFade(bool fade)
{
_fade = fade;
return this;
}
public async Task<string> BuildFile()
{
string fileName = _tempFilePool.GetNextTempFile(TempFileCategory.Subtitle);
@@ -116,15 +132,24 @@ public class SubtitleBuilder
sb.AppendLine(
$"Style: Default,{await _fontName.IfNoneAsync("")},{await _fontSize.IfNoneAsync(32)},{await _primaryColor.IfNoneAsync("")},{await _outlineColor.IfNoneAsync("")},{await _borderStyle.IfNoneAsync(0)},1,{await _shadow.IfNoneAsync(0)},{await _alignment.IfNoneAsync(0)},1");
var start = "0:00:00.00";
foreach (TimeSpan startTime in _start)
{
start = $"{(int)startTime.TotalHours:00}:{startTime.ToString(@"mm\:ss\.ff")}";
}
var end = "99:99:99.99";
foreach (TimeSpan endTime in _end)
{
end = $"{(int)endTime.TotalHours:00}:{endTime.ToString(@"mm\:ss\.ff")}";
}
string fade = _fade ? @"{\fad(1200, 1200)}" : string.Empty;
sb.AppendLine("[Events]");
sb.AppendLine("Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text");
sb.AppendLine(
$"Dialogue: 0,0:00:00.00,99:99:99.99,Default,,{_marginLeft},{_marginRight},{_marginV},,{_content}");
if (!string.IsNullOrWhiteSpace(_content))
{
sb.AppendLine(_content);
}
@$"Dialogue: 0,{start},{end},Default,,{_marginLeft},{_marginRight},{_marginV},,{fade}{_content}");
await File.WriteAllTextAsync(fileName, sb.ToString());
+18 -21
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);
}
@@ -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);
@@ -13,10 +13,8 @@ public interface IFFmpegStreamSelector
string preferredAudioLanguage);
Task<Option<Subtitle>> SelectSubtitleStream(
MediaVersion version,
List<Subtitle> subtitles,
StreamingMode streamingMode,
string channelNumber,
Channel channel,
string preferredSubtitleLanguage,
ChannelSubtitleMode subtitleMode);
}
@@ -0,0 +1,8 @@
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Interfaces.FFmpeg;
public interface IMusicVideoCreditsGenerator
{
Task<Option<Subtitle>> GenerateCreditsSubtitle(MusicVideo musicVideo, FFmpegProfile ffmpegProfile);
}
@@ -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);
}
@@ -4,5 +4,5 @@ namespace ErsatzTV.Core.Interfaces.Metadata.Nfo;
public interface IArtistNfoReader
{
Task<Either<BaseError, ArtistNfo>> Read(Stream input);
Task<Either<BaseError, ArtistNfo>> ReadFromFile(string fileName);
}
@@ -4,5 +4,5 @@ namespace ErsatzTV.Core.Interfaces.Metadata.Nfo;
public interface IEpisodeNfoReader
{
Task<Either<BaseError, List<TvShowEpisodeNfo>>> Read(Stream input);
Task<Either<BaseError, List<TvShowEpisodeNfo>>> ReadFromFile(string fileName);
}
@@ -4,5 +4,5 @@ namespace ErsatzTV.Core.Interfaces.Metadata.Nfo;
public interface IMovieNfoReader
{
Task<Either<BaseError, MovieNfo>> Read(Stream input);
Task<Either<BaseError, MovieNfo>> ReadFromFile(string fileName);
}
@@ -4,5 +4,5 @@ namespace ErsatzTV.Core.Interfaces.Metadata.Nfo;
public interface IMusicVideoNfoReader
{
Task<Either<BaseError, MusicVideoNfo>> Read(Stream input);
Task<Either<BaseError, MusicVideoNfo>> ReadFromFile(string fileName);
}
@@ -4,5 +4,5 @@ namespace ErsatzTV.Core.Interfaces.Metadata.Nfo;
public interface IOtherVideoNfoReader
{
Task<Either<BaseError, OtherVideoNfo>> Read(Stream input);
Task<Either<BaseError, OtherVideoNfo>> ReadFromFile(string fileName);
}
@@ -4,5 +4,5 @@ namespace ErsatzTV.Core.Interfaces.Metadata.Nfo;
public interface ITvShowNfoReader
{
Task<Either<BaseError, TvShowNfo>> Read(Stream input);
Task<Either<BaseError, TvShowNfo>> ReadFromFile(string fileName);
}
@@ -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);
}
@@ -19,6 +19,8 @@ public interface IMediaServerTelevisionRepository<in TLibrary, TShow, TSeason, T
Task<Unit> SetEtag(TSeason season, string etag);
Task<Unit> SetEtag(TEpisode episode, string etag);
Task<bool> FlagNormal(TLibrary library, TEpisode episode);
Task<bool> FlagNormal(TLibrary library, TSeason season);
Task<bool> FlagNormal(TLibrary library, TShow show);
Task<List<int>> FlagFileNotFoundShows(TLibrary library, List<string> showItemIds);
Task<List<int>> FlagFileNotFoundSeasons(TLibrary library, List<string> seasonItemIds);
Task<List<int>> FlagFileNotFoundEpisodes(TLibrary library, List<string> episodeItemIds);
+9 -2
View File
@@ -4,6 +4,7 @@ using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Emby;
using ErsatzTV.Core.Jellyfin;
using Microsoft.IO;
using Serilog;
namespace ErsatzTV.Core.Iptv;
@@ -12,10 +13,16 @@ public class ChannelGuide
{
private readonly List<Channel> _channels;
private readonly string _host;
private readonly RecyclableMemoryStreamManager _recyclableMemoryStreamManager;
private readonly string _scheme;
public ChannelGuide(string scheme, string host, List<Channel> channels)
public ChannelGuide(
RecyclableMemoryStreamManager recyclableMemoryStreamManager,
string scheme,
string host,
List<Channel> channels)
{
_recyclableMemoryStreamManager = recyclableMemoryStreamManager;
_scheme = scheme;
_host = host;
_channels = channels;
@@ -23,7 +30,7 @@ public class ChannelGuide
public string ToXml()
{
using var ms = new MemoryStream();
using MemoryStream ms = _recyclableMemoryStreamManager.GetStream();
using var xml = XmlWriter.Create(ms);
xml.WriteStartDocument();
@@ -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();
}
}
@@ -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";
}
@@ -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(
@@ -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,
@@ -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"));
}
@@ -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(

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