Compare commits
44 Commits
v0.5.5-bet
...
v0.6.0-bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0388425763 | ||
|
|
ca5d303ac7 | ||
|
|
18e66a92ad | ||
|
|
7d0a56ab98 | ||
|
|
5069792d12 | ||
|
|
c9789458b9 | ||
|
|
777a0d09ed | ||
|
|
4e2ebcc48a | ||
|
|
90fe1d7709 | ||
|
|
1576dd026e | ||
|
|
ee7a64eea9 | ||
|
|
9742e1eef7 | ||
|
|
a61c4b3472 | ||
|
|
ea0d43cf99 | ||
|
|
fd36ea51a7 | ||
|
|
5213b71d62 | ||
|
|
0ba3ac7f50 | ||
|
|
d960fec734 | ||
|
|
f272036c6f | ||
|
|
9fe03b6ef3 | ||
|
|
f895ab5304 | ||
|
|
07c54ff45f | ||
|
|
6a29ce2049 | ||
|
|
d19e95fb38 | ||
|
|
d78daf8735 | ||
|
|
4f6522379d | ||
|
|
9e0972fec0 | ||
|
|
6d564233ed | ||
|
|
47252b1243 | ||
|
|
bd5b52922d | ||
|
|
59c793b9be | ||
|
|
3ad1ba01f8 | ||
|
|
ab10f0ed81 | ||
|
|
44dd68fe59 | ||
|
|
6326189444 | ||
|
|
198c693208 | ||
|
|
1431b33a98 | ||
|
|
e81a8e58ea | ||
|
|
daf7114ce2 | ||
|
|
8542bc20b1 | ||
|
|
9decb91bf7 | ||
|
|
fcfd579b37 | ||
|
|
e9be182bed | ||
|
|
610e261cd7 |
79
CHANGELOG.md
79
CHANGELOG.md
@@ -5,6 +5,78 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.6.0-beta] - 2022-06-01
|
||||
### Fixed
|
||||
- Additional fix for duplicate `Other Videos` entries; trash may need to be emptied one last time after upgrading
|
||||
- Fix watermark opacity in cultures where `,` is a decimal separator
|
||||
- Rework playlist filtering to avoid empty playlist responses
|
||||
- Fix some QSV/VAAPI memory errors by always requesting 64 extra hardware frames
|
||||
|
||||
### Added
|
||||
- Enable QSV hardware acceleration for vaapi docker images
|
||||
|
||||
### Changed
|
||||
- Use paging to synchronize all media from Plex, Jellyfin and Emby
|
||||
- This will reduce memory use and improve reliability of synchronizing large libraries
|
||||
- Disable low power mode for `h264_qsv` and `hevc_qsv` encoders
|
||||
|
||||
## [0.5.8-beta] - 2022-05-20
|
||||
### Fixed
|
||||
- Fix error display with `HLS Segmenter` and `MPEG-TS` streaming modes
|
||||
- Remove erroneous log messages about normalizing framerate on channels where framerate normalization is disabled
|
||||
- Fix unscheduled filler gaps that sometimes happen as playouts are automatically extended each hour
|
||||
|
||||
### Added
|
||||
- Clean transcode cache folder on startup and after `HLS Segmenter` session terminates for any reason
|
||||
|
||||
### Changed
|
||||
- Remove thread limitation for scenarios where it is not required
|
||||
- This should give a performance boost to installations that don't use hardware acceleration
|
||||
- Use hardware acceleration to display error messages where configured
|
||||
|
||||
## [0.5.7-beta] - 2022-05-14
|
||||
### Fixed
|
||||
- Reduce memory use due to library scan operations
|
||||
- Fix some instances of filler getting "stuck" when a filler item is encountered that's too long for the gap
|
||||
- Properly ignore Plex `Other Videos` libraries (`movie` libraries where agent is `com.plexapp.agents.none`)
|
||||
- Fix `Custom Title` for schedule items with `One`, `Multiple` and `Flood` playout modes
|
||||
- Fix scheduling bug where flood items would sometimes fail to continue after midnight
|
||||
|
||||
### Added
|
||||
- Add `metadata_kind` field to search index to allow searching for items with a particular metdata source
|
||||
- Valid metadata kinds are `fallback`, `sidecar` (NFO), `external` (from a media server) and `embedded` (songs)
|
||||
- Add autocomplete functionality to search bar to quickly navigate to channels, ffmpeg profiles, collections and schedules by name
|
||||
- Add global setting to skip missing (file-not-found or unavailable) items when building playouts
|
||||
- Add filler preset option to allow watermarks to overlay on top of filler (disabled by default)
|
||||
- This option is applied when new items are added to a playout; rebuilding is needed if you want the change to take effect immediately
|
||||
- Read `track` field from music video NFO metadata and use it for chronological sorting (after release date)
|
||||
- Add `Random Start Point` option to schedules
|
||||
- When this option is enabled, all `Chronological` or `Shuffle In Order` content groups will have their start points randomized
|
||||
- When this option is disabled, all `Chronological` or `Shuffle In Order` content groups will start with the chronologically earliest item
|
||||
|
||||
### Changed
|
||||
- Replace invalid (control) characters in NFO metadata with replacement character `<60>` before parsing
|
||||
- Store partial (incomplete) NFO metadata results when invalid XML is encountered
|
||||
- Previously, no metadata would be stored if the XML within the NFO failed to validate
|
||||
|
||||
## [0.5.6-beta] - 2022-05-06
|
||||
### Fixed
|
||||
- Fix processing local movie NFO metadata without a `year` value
|
||||
- Fix processing local movie fallback metadata
|
||||
- Fix search edge case where very recently added items (hours) would not be returned by relative date queries
|
||||
- Fix search index validation on startup; improper validation was causing a rebuild with every startup
|
||||
- Block library scanning until search index has been recreated/upgraded
|
||||
- Fix occasional erroneous log messages when HLS channel playback times out because all clients have left
|
||||
- Fix fallback filler playback
|
||||
- Fix stream continuity when error messages are displayed
|
||||
- Fix duplicate scanning within `Other Video` libraries (i.e. folders would be scanned multiple times)
|
||||
|
||||
### Added
|
||||
- Add `show_genre` and `show_tag` to search index for seasons and episodes
|
||||
- Use `aired` value to source release date from music video nfo metadata
|
||||
- Add NFO metadata support to `Other Video` libraries
|
||||
- `Other Video` NFO metadata must be in the movie NFO metadata format
|
||||
|
||||
## [0.5.5-beta] - 2022-05-03
|
||||
### Fixed
|
||||
- Fix adding episodes with no title to the search index
|
||||
@@ -16,6 +88,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
- Properly extract embedded subtitles on playouts where subtitles are only enabled on schedule items (and not on the channel itself)
|
||||
|
||||
### Added
|
||||
- Add experimental `arm64` docker tags (`develop-arm64` and `latest-arm64`)
|
||||
- Use `Sort Title` from Movie NFO metadata if available
|
||||
- Support multiple `Artist` entries in music video NFO metadata
|
||||
|
||||
@@ -1152,7 +1225,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
- Initial release to facilitate testing outside of Docker.
|
||||
|
||||
|
||||
[Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.5.5-beta...HEAD
|
||||
[Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.6.0-beta...HEAD
|
||||
[0.6.0-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.5.8-beta...v0.6.0-beta
|
||||
[0.5.8-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.5.7-beta...v0.5.8-beta
|
||||
[0.5.7-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.5.6-beta...v0.5.7-beta
|
||||
[0.5.6-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.5.5-beta...v0.5.6-beta
|
||||
[0.5.5-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.5.4-beta...v0.5.5-beta
|
||||
[0.5.4-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.5.3-beta...v0.5.4-beta
|
||||
[0.5.3-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.5.2-beta...v0.5.3-beta
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -11,6 +11,7 @@ public record CreateFillerPreset(
|
||||
TimeSpan? Duration,
|
||||
int? Count,
|
||||
int? PadToNearestMinute,
|
||||
bool AllowWatermarks,
|
||||
ProgramScheduleItemCollectionType CollectionType,
|
||||
int? CollectionId,
|
||||
int? MediaItemId,
|
||||
|
||||
@@ -31,6 +31,7 @@ public class CreateFillerPresetHandler : IRequestHandler<CreateFillerPreset, Eit
|
||||
Duration = request.Duration,
|
||||
Count = request.Count,
|
||||
PadToNearestMinute = request.PadToNearestMinute,
|
||||
AllowWatermarks = request.AllowWatermarks,
|
||||
CollectionType = request.CollectionType,
|
||||
CollectionId = request.CollectionId,
|
||||
MediaItemId = request.MediaItemId,
|
||||
|
||||
@@ -12,6 +12,7 @@ public record UpdateFillerPreset(
|
||||
TimeSpan? Duration,
|
||||
int? Count,
|
||||
int? PadToNearestMinute,
|
||||
bool AllowWatermarks,
|
||||
ProgramScheduleItemCollectionType CollectionType,
|
||||
int? CollectionId,
|
||||
int? MediaItemId,
|
||||
|
||||
@@ -15,8 +15,7 @@ public class UpdateFillerPresetHandler : IRequestHandler<UpdateFillerPreset, Eit
|
||||
|
||||
public async Task<Either<BaseError, Unit>> Handle(UpdateFillerPreset request, CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, FillerPreset> validation = await FillerPresetMustExist(dbContext, request);
|
||||
return await LanguageExtensions.Apply(validation, ps => ApplyUpdateRequest(dbContext, ps, request));
|
||||
}
|
||||
@@ -32,6 +31,7 @@ public class UpdateFillerPresetHandler : IRequestHandler<UpdateFillerPreset, Eit
|
||||
existing.Duration = request.Duration;
|
||||
existing.Count = request.Count;
|
||||
existing.PadToNearestMinute = request.PadToNearestMinute;
|
||||
existing.AllowWatermarks = request.AllowWatermarks;
|
||||
existing.CollectionType = request.CollectionType;
|
||||
existing.CollectionId = request.CollectionId;
|
||||
existing.MediaItemId = request.MediaItemId;
|
||||
|
||||
@@ -11,6 +11,7 @@ public record FillerPresetViewModel(
|
||||
TimeSpan? Duration,
|
||||
int? Count,
|
||||
int? PadToNearestMinute,
|
||||
bool AllowWatermarks,
|
||||
ProgramScheduleItemCollectionType CollectionType,
|
||||
int? CollectionId,
|
||||
int? MediaItemId,
|
||||
|
||||
@@ -13,6 +13,7 @@ internal static class Mapper
|
||||
fillerPreset.Duration,
|
||||
fillerPreset.Count,
|
||||
fillerPreset.PadToNearestMinute,
|
||||
fillerPreset.AllowWatermarks,
|
||||
fillerPreset.CollectionType,
|
||||
fillerPreset.CollectionId,
|
||||
fillerPreset.MediaItemId,
|
||||
|
||||
@@ -5,6 +5,7 @@ using ErsatzTV.Core.Interfaces.Locking;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
using Humanizer;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Application.MediaSources;
|
||||
@@ -147,7 +148,7 @@ public class ScanLocalLibraryHandler : IRequestHandler<ForceScanLocalLibrary, Ei
|
||||
_logger.LogDebug(
|
||||
"Scan of library {Name} completed in {Duration}",
|
||||
localLibrary.Name,
|
||||
TimeSpan.FromMilliseconds(sw.ElapsedMilliseconds));
|
||||
sw.Elapsed.Humanize());
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
18
ErsatzTV.Application/Search/SearchTargetResultViewModel.cs
Normal file
18
ErsatzTV.Application/Search/SearchTargetResultViewModel.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
namespace ErsatzTV.Application.Search;
|
||||
|
||||
public record SearchTargetViewModel(int Id, string Name, SearchTargetKind Kind);
|
||||
|
||||
public record SmartCollectionSearchTargetViewModel(int Id, string Name, string Query)
|
||||
: SearchTargetViewModel(Id, Name, SearchTargetKind.SmartCollection);
|
||||
|
||||
public enum SearchTargetKind
|
||||
{
|
||||
Channel = 1,
|
||||
FFmpegProfile = 2,
|
||||
ChannelWatermark = 3,
|
||||
Collection = 4,
|
||||
MultiCollection = 5,
|
||||
SmartCollection = 6,
|
||||
Schedule = 7,
|
||||
ScheduleItems = 8
|
||||
}
|
||||
@@ -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(
|
||||
@@ -276,7 +288,7 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
return false;
|
||||
}
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
|
||||
{
|
||||
_logger.LogInformation("Terminating HLS process for channel {Channel}", _channelNumber);
|
||||
return false;
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ using ErsatzTV.Core.Scheduling;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Application.Streaming;
|
||||
|
||||
@@ -24,6 +25,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
||||
private readonly IFFmpegProcessService _ffmpegProcessService;
|
||||
private readonly IJellyfinPathReplacementService _jellyfinPathReplacementService;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly ILogger<GetPlayoutItemProcessByChannelNumberHandler> _logger;
|
||||
private readonly IMediaCollectionRepository _mediaCollectionRepository;
|
||||
private readonly IPlexPathReplacementService _plexPathReplacementService;
|
||||
private readonly ISongVideoGenerator _songVideoGenerator;
|
||||
@@ -39,7 +41,8 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
||||
IMediaCollectionRepository mediaCollectionRepository,
|
||||
ITelevisionRepository televisionRepository,
|
||||
IArtistRepository artistRepository,
|
||||
ISongVideoGenerator songVideoGenerator)
|
||||
ISongVideoGenerator songVideoGenerator,
|
||||
ILogger<GetPlayoutItemProcessByChannelNumberHandler> logger)
|
||||
: base(dbContextFactory)
|
||||
{
|
||||
_ffmpegProcessService = ffmpegProcessService;
|
||||
@@ -51,6 +54,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
||||
_televisionRepository = televisionRepository;
|
||||
_artistRepository = artistRepository;
|
||||
_songVideoGenerator = songVideoGenerator;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task<Either<BaseError, PlayoutItemProcessModel>> GetProcess(
|
||||
@@ -178,7 +182,8 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
||||
playoutItemWithPath.PlayoutItem.InPoint,
|
||||
playoutItemWithPath.PlayoutItem.OutPoint,
|
||||
request.PtsOffset,
|
||||
request.TargetFramerate);
|
||||
request.TargetFramerate,
|
||||
playoutItemWithPath.PlayoutItem.DisableWatermarks);
|
||||
|
||||
var result = new PlayoutItemProcessModel(
|
||||
process,
|
||||
@@ -201,6 +206,12 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
||||
|
||||
DateTimeOffset finish = maybeDuration.Match(d => now.Add(d), () => now);
|
||||
|
||||
_logger.LogWarning(
|
||||
"Error locating playout item {@Error}. Will display error from {Start} to {Finish}",
|
||||
error,
|
||||
now,
|
||||
finish);
|
||||
|
||||
switch (error)
|
||||
{
|
||||
case UnableToLocatePlayoutItem:
|
||||
@@ -210,7 +221,9 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
||||
maybeDuration,
|
||||
"Channel is Offline",
|
||||
request.HlsRealtime,
|
||||
request.PtsOffset);
|
||||
request.PtsOffset,
|
||||
channel.FFmpegProfile.VaapiDriver,
|
||||
channel.FFmpegProfile.VaapiDevice);
|
||||
|
||||
return new PlayoutItemProcessModel(offlineProcess, maybeDuration, finish);
|
||||
case PlayoutItemDoesNotExistOnDisk:
|
||||
@@ -220,7 +233,9 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
||||
maybeDuration,
|
||||
error.Value,
|
||||
request.HlsRealtime,
|
||||
request.PtsOffset);
|
||||
request.PtsOffset,
|
||||
channel.FFmpegProfile.VaapiDriver,
|
||||
channel.FFmpegProfile.VaapiDevice);
|
||||
|
||||
return new PlayoutItemProcessModel(doesNotExistProcess, maybeDuration, finish);
|
||||
default:
|
||||
@@ -230,7 +245,9 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
||||
maybeDuration,
|
||||
"Channel is Offline",
|
||||
request.HlsRealtime,
|
||||
request.PtsOffset);
|
||||
request.PtsOffset,
|
||||
channel.FFmpegProfile.VaapiDriver,
|
||||
channel.FFmpegProfile.VaapiDevice);
|
||||
|
||||
return new PlayoutItemProcessModel(errorProcess, maybeDuration, finish);
|
||||
}
|
||||
@@ -244,16 +261,16 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
||||
List<Subtitle> allSubtitles = playoutItemWithPath.PlayoutItem.MediaItem switch
|
||||
{
|
||||
Episode episode => Optional(episode.EpisodeMetadata).Flatten().HeadOrNone()
|
||||
.Map(mm => mm.Subtitles)
|
||||
.Map(mm => mm.Subtitles ?? new List<Subtitle>())
|
||||
.IfNone(new List<Subtitle>()),
|
||||
Movie movie => Optional(movie.MovieMetadata).Flatten().HeadOrNone()
|
||||
.Map(mm => mm.Subtitles)
|
||||
.Map(mm => mm.Subtitles ?? new List<Subtitle>())
|
||||
.IfNone(new List<Subtitle>()),
|
||||
MusicVideo musicVideo => Optional(musicVideo.MusicVideoMetadata).Flatten().HeadOrNone()
|
||||
.Map(mm => mm.Subtitles)
|
||||
.Map(mm => mm.Subtitles ?? new List<Subtitle>())
|
||||
.IfNone(new List<Subtitle>()),
|
||||
OtherVideo otherVideo => Optional(otherVideo.OtherVideoMetadata).Flatten().HeadOrNone()
|
||||
.Map(mm => mm.Subtitles)
|
||||
.Map(mm => mm.Subtitles ?? new List<Subtitle>())
|
||||
.IfNone(new List<Subtitle>()),
|
||||
_ => new List<Subtitle>()
|
||||
};
|
||||
@@ -361,7 +378,8 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
||||
Finish = finish.UtcDateTime,
|
||||
FillerKind = FillerKind.Fallback,
|
||||
InPoint = TimeSpan.Zero,
|
||||
OutPoint = version.Duration
|
||||
OutPoint = version.Duration,
|
||||
DisableWatermarks = !fallbackPreset.AllowWatermarks
|
||||
};
|
||||
|
||||
return await ValidatePlayoutItemPath(playoutItem);
|
||||
|
||||
@@ -155,7 +155,7 @@ public class ExtractEmbeddedSubtitlesHandler : IRequestHandler<ExtractEmbeddedSu
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
|
||||
{
|
||||
return Unit.Default;
|
||||
}
|
||||
@@ -210,7 +210,7 @@ public class ExtractEmbeddedSubtitlesHandler : IRequestHandler<ExtractEmbeddedSu
|
||||
.ToListAsync(cancellationToken);
|
||||
result.AddRange(otherVideoIds);
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -301,6 +301,7 @@ public class TranscodingTests
|
||||
oldService,
|
||||
new FFmpegPlaybackSettingsCalculator(),
|
||||
new FakeStreamSelector(),
|
||||
new Mock<ITempFilePool>().Object,
|
||||
LoggerFactory.CreateLogger<FFmpegLibraryProcessService>());
|
||||
|
||||
var v = new MediaVersion
|
||||
@@ -488,7 +489,8 @@ public class TranscodingTests
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.FromSeconds(5),
|
||||
0,
|
||||
None);
|
||||
None,
|
||||
false);
|
||||
|
||||
// Console.WriteLine($"ffmpeg arguments {string.Join(" ", process.StartInfo.ArgumentList)}");
|
||||
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
using Bugsnag;
|
||||
using ErsatzTV.Core.Metadata.Nfo;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.IO;
|
||||
using Moq;
|
||||
using NUnit.Framework;
|
||||
using Serilog;
|
||||
|
||||
namespace ErsatzTV.Core.Tests.Metadata.Nfo;
|
||||
|
||||
@@ -11,7 +14,24 @@ namespace ErsatzTV.Core.Tests.Metadata.Nfo;
|
||||
public class ArtistNfoReaderTests
|
||||
{
|
||||
[SetUp]
|
||||
public void SetUp() => _artistNfoReader = new ArtistNfoReader(new Mock<IClient>().Object);
|
||||
public void SetUp() => _artistNfoReader = new ArtistNfoReader(
|
||||
new RecyclableMemoryStreamManager(),
|
||||
new Mock<IClient>().Object,
|
||||
_logger);
|
||||
|
||||
private readonly ILogger<ArtistNfoReader> _logger;
|
||||
|
||||
public ArtistNfoReaderTests()
|
||||
{
|
||||
Log.Logger = new LoggerConfiguration()
|
||||
.MinimumLevel.Debug()
|
||||
.WriteTo.Console()
|
||||
.CreateLogger();
|
||||
|
||||
ILoggerFactory loggerFactory = new LoggerFactory().AddSerilog(Log.Logger);
|
||||
|
||||
_logger = loggerFactory.CreateLogger<ArtistNfoReader>();
|
||||
}
|
||||
|
||||
private ArtistNfoReader _artistNfoReader;
|
||||
|
||||
@@ -153,6 +173,42 @@ Joel attended Hicksville High School in 1967, but he did not graduate with his c
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Invalid_Characters_End_Should_Abort_And_Return_Nfo()
|
||||
{
|
||||
string sourceFile = Path.Combine(
|
||||
TestContext.CurrentContext.TestDirectory,
|
||||
"Resources",
|
||||
"Nfo",
|
||||
"ArtistInvalidCharacters1.nfo");
|
||||
Either<BaseError, ArtistNfo> result = await _artistNfoReader.ReadFromFile(sourceFile);
|
||||
|
||||
result.IsRight.Should().BeTrue();
|
||||
foreach (ArtistNfo nfo in result.RightToSeq())
|
||||
{
|
||||
nfo.Name.Should().Be("Test Name");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Invalid_Characters_Middle_Should_Continue_And_Return_Nfo()
|
||||
{
|
||||
string sourceFile = Path.Combine(
|
||||
TestContext.CurrentContext.TestDirectory,
|
||||
"Resources",
|
||||
"Nfo",
|
||||
"ArtistInvalidCharacters2.nfo");
|
||||
Either<BaseError, ArtistNfo> result = await _artistNfoReader.ReadFromFile(sourceFile);
|
||||
|
||||
result.IsRight.Should().BeTrue();
|
||||
foreach (ArtistNfo nfo in result.RightToSeq())
|
||||
{
|
||||
nfo.Name.Should().Be("Test Name");
|
||||
nfo.Moods.Should().BeEquivalentTo(new List<string> { "Test Mood" });
|
||||
nfo.Styles.Count.Should().Be(1);
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizeLineEndingsLF(string str) =>
|
||||
str
|
||||
.Replace("\r\n", "\n")
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
using Bugsnag;
|
||||
using ErsatzTV.Core.Metadata.Nfo;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.IO;
|
||||
using Moq;
|
||||
using NUnit.Framework;
|
||||
using Serilog;
|
||||
|
||||
namespace ErsatzTV.Core.Tests.Metadata.Nfo;
|
||||
|
||||
@@ -11,7 +14,24 @@ namespace ErsatzTV.Core.Tests.Metadata.Nfo;
|
||||
public class EpisodeNfoReaderTests
|
||||
{
|
||||
[SetUp]
|
||||
public void SetUp() => _episodeNfoReader = new EpisodeNfoReader(new Mock<IClient>().Object);
|
||||
public void SetUp() => _episodeNfoReader = new EpisodeNfoReader(
|
||||
new RecyclableMemoryStreamManager(),
|
||||
new Mock<IClient>().Object,
|
||||
_logger);
|
||||
|
||||
private readonly ILogger<EpisodeNfoReader> _logger;
|
||||
|
||||
public EpisodeNfoReaderTests()
|
||||
{
|
||||
Log.Logger = new LoggerConfiguration()
|
||||
.MinimumLevel.Debug()
|
||||
.WriteTo.Console()
|
||||
.CreateLogger();
|
||||
|
||||
ILoggerFactory loggerFactory = new LoggerFactory().AddSerilog(Log.Logger);
|
||||
|
||||
_logger = loggerFactory.CreateLogger<EpisodeNfoReader>();
|
||||
}
|
||||
|
||||
private EpisodeNfoReader _episodeNfoReader;
|
||||
|
||||
@@ -404,4 +424,22 @@ public class EpisodeNfoReaderTests
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Invalid_Characters_Should_Abort_And_Return_Nfo()
|
||||
{
|
||||
string sourceFile = Path.Combine(
|
||||
TestContext.CurrentContext.TestDirectory,
|
||||
"Resources",
|
||||
"Nfo",
|
||||
"EpisodeInvalidCharacters.nfo");
|
||||
Either<BaseError, List<TvShowEpisodeNfo>> result = await _episodeNfoReader.ReadFromFile(sourceFile);
|
||||
|
||||
result.IsRight.Should().BeTrue();
|
||||
foreach (List<TvShowEpisodeNfo> list in result.RightToSeq())
|
||||
{
|
||||
list.Count.Should().Be(1);
|
||||
list[0].Title.Should().Be("Test Title");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
using Bugsnag;
|
||||
using ErsatzTV.Core.Metadata.Nfo;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.IO;
|
||||
using Moq;
|
||||
using NUnit.Framework;
|
||||
|
||||
@@ -11,7 +13,10 @@ namespace ErsatzTV.Core.Tests.Metadata.Nfo;
|
||||
public class MovieNfoReaderTests
|
||||
{
|
||||
[SetUp]
|
||||
public void SetUp() => _movieNfoReader = new MovieNfoReader(new Mock<IClient>().Object);
|
||||
public void SetUp() => _movieNfoReader = new MovieNfoReader(
|
||||
new RecyclableMemoryStreamManager(),
|
||||
new Mock<IClient>().Object,
|
||||
new NullLogger<MovieNfoReader>());
|
||||
|
||||
private MovieNfoReader _movieNfoReader;
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
using Bugsnag;
|
||||
using ErsatzTV.Core.Metadata.Nfo;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.IO;
|
||||
using Moq;
|
||||
using NUnit.Framework;
|
||||
|
||||
@@ -11,7 +13,10 @@ namespace ErsatzTV.Core.Tests.Metadata.Nfo;
|
||||
public class MusicVideoNfoReaderTests
|
||||
{
|
||||
[SetUp]
|
||||
public void SetUp() => _musicVideoNfoReader = new MusicVideoNfoReader(new Mock<IClient>().Object);
|
||||
public void SetUp() => _musicVideoNfoReader = new MusicVideoNfoReader(
|
||||
new RecyclableMemoryStreamManager(),
|
||||
new Mock<IClient>().Object,
|
||||
new NullLogger<MusicVideoNfoReader>());
|
||||
|
||||
private MusicVideoNfoReader _musicVideoNfoReader;
|
||||
|
||||
@@ -122,7 +127,9 @@ https://www.themoviedb.org/movie/11-star-wars"));
|
||||
Le groupe a également enregistré une version espagnole de ce titre, La reina del baile, pour le marché d'Amérique latine. On peut retrouver ces versions en espagnol des succès de ABBA sur l'abum Oro. Le 18 juin 1976, ABBA a interprété cette chanson lors d'un spectacle télévisé organisé en l'honneur du roi Charles XVI Gustave de Suède, qui venait de se marier. Le titre sera repris en 2011 par Glee dans la saison 2, épisode 20."));
|
||||
|
||||
nfo.Year.Should().Be(1976);
|
||||
nfo.Aired.IsNone.Should().BeTrue();
|
||||
nfo.Genres.Should().BeEquivalentTo(new List<string> { "Pop" });
|
||||
nfo.Track.Should().Be(-1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,6 +148,25 @@ Le groupe a également enregistré une version espagnole de ce titre, La reina d
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task MetadataNfo_With_Aired_Should_Return_Nfo()
|
||||
{
|
||||
await using var stream = new MemoryStream(
|
||||
Encoding.UTF8.GetBytes(@"<musicvideo><aired>2022-02-03</aired></musicvideo>"));
|
||||
|
||||
Either<BaseError, MusicVideoNfo> result = await _musicVideoNfoReader.Read(stream);
|
||||
|
||||
result.IsRight.Should().BeTrue();
|
||||
foreach (MusicVideoNfo nfo in result.RightToSeq())
|
||||
{
|
||||
nfo.Aired.IsSome.Should().BeTrue();
|
||||
foreach (DateTime aired in nfo.Aired)
|
||||
{
|
||||
aired.Should().Be(new DateTime(2022, 02, 03));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task MetadataNfo_With_Studios_Should_Return_Nfo()
|
||||
{
|
||||
|
||||
252
ErsatzTV.Core.Tests/Metadata/Nfo/OtherVideoNfoReaderTests.cs
Normal file
252
ErsatzTV.Core.Tests/Metadata/Nfo/OtherVideoNfoReaderTests.cs
Normal file
@@ -0,0 +1,252 @@
|
||||
using System.Text;
|
||||
using Bugsnag;
|
||||
using ErsatzTV.Core.Metadata.Nfo;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.IO;
|
||||
using Moq;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace ErsatzTV.Core.Tests.Metadata.Nfo;
|
||||
|
||||
[TestFixture]
|
||||
public class OtherVideoNfoReaderTests
|
||||
{
|
||||
[SetUp]
|
||||
public void SetUp() => _otherVideoNfoReader = new OtherVideoNfoReader(
|
||||
new RecyclableMemoryStreamManager(),
|
||||
new Mock<IClient>().Object,
|
||||
new NullLogger<OtherVideoNfoReader>());
|
||||
|
||||
private OtherVideoNfoReader _otherVideoNfoReader;
|
||||
|
||||
[Test]
|
||||
public async Task ParsingNfo_Should_Return_Error()
|
||||
{
|
||||
await using var stream =
|
||||
new MemoryStream(Encoding.UTF8.GetBytes(@"https://www.themoviedb.org/movie/11-star-wars"));
|
||||
|
||||
Either<BaseError, OtherVideoNfo> result = await _otherVideoNfoReader.Read(stream);
|
||||
|
||||
result.IsLeft.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task MetadataNfo_Should_Return_Nfo()
|
||||
{
|
||||
await using var stream = new MemoryStream(Encoding.UTF8.GetBytes(@"<movie></movie>"));
|
||||
|
||||
Either<BaseError, OtherVideoNfo> result = await _otherVideoNfoReader.Read(stream);
|
||||
|
||||
result.IsRight.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task CombinationNfo_Should_Return_Nfo()
|
||||
{
|
||||
await using var stream = new MemoryStream(
|
||||
Encoding.UTF8.GetBytes(
|
||||
@"<movie></movie>
|
||||
https://www.themoviedb.org/movie/11-star-wars"));
|
||||
|
||||
Either<BaseError, OtherVideoNfo> result = await _otherVideoNfoReader.Read(stream);
|
||||
|
||||
result.IsRight.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task FullSample_Should_Return_Nfo()
|
||||
{
|
||||
await using var stream = new MemoryStream(
|
||||
Encoding.UTF8.GetBytes(
|
||||
@"<?xml version=""1.0"" encoding=""UTF-8"" standalone=""yes"" ?>
|
||||
<movie>
|
||||
<title>Zack Snyder's Justice League</title>
|
||||
<originaltitle>Zack Snyder's Justice League</originaltitle>
|
||||
<sorttitle>Justice League 2</sorttitle>
|
||||
<ratings>
|
||||
<rating name=""imdb"" max=""10"" default=""true"">
|
||||
<value>8.300000</value>
|
||||
<votes>197786</votes>
|
||||
</rating>
|
||||
<rating name=""themoviedb"" max=""10"">
|
||||
<value>8.700000</value>
|
||||
<votes>3461</votes>
|
||||
</rating>
|
||||
<rating name=""trakt"" max=""10"">
|
||||
<value>8.195670</value>
|
||||
<votes>4247</votes>
|
||||
</rating>
|
||||
</ratings>
|
||||
<userrating>0</userrating>
|
||||
<top250>140</top250>
|
||||
<outline></outline>
|
||||
<plot>Determined to ensure Superman's ultimate sacrifice was not in vain, Bruce Wayne aligns forces with Diana Prince with plans to recruit a team of metahumans to protect the world from an approaching threat of catastrophic proportions.</plot>
|
||||
<tagline></tagline>
|
||||
<runtime>242</runtime>
|
||||
<thumb spoof="""" cache="""" aspect=""poster"" preview="""">https://assets.fanart.tv/fanart/movies/791373/movieposter/zack-snyders-justice-league-603fdb873f474.jpg</thumb>
|
||||
<thumb spoof="""" cache="""" aspect=""poster"" preview="""">https://image.tmdb.org/t/p/original/tnAuB8q5vv7Ax9UAEje5Xi4BXik.jpg</thumb>
|
||||
<thumb spoof="""" cache="""" aspect=""landscape"" preview="""">https://assets.fanart.tv/fanart/movies/791373/moviethumb/zack-snyders-justice-league-6050310135cf6.jpg</thumb>
|
||||
<thumb spoof="""" cache="""" aspect=""landscape"" preview="""">https://image.tmdb.org/t/p/original/wcYBuOZDP6Vi8Ye4qax3Zx9dCan.jpg</thumb>
|
||||
<thumb spoof="""" cache="""" aspect=""keyart"" preview="""">https://assets.fanart.tv/fanart/movies/791373/movieposter/zack-snyders-justice-league-603fdba9bdd16.jpg</thumb>
|
||||
<thumb spoof="""" cache="""" aspect=""clearlogo"" preview="""">https://assets.fanart.tv/fanart/movies/791373/hdmovielogo/zack-snyders-justice-league-5ed3f2e4952e9.png</thumb>
|
||||
<thumb spoof="""" cache="""" aspect=""banner"" preview="""">https://assets.fanart.tv/fanart/movies/791373/moviebanner/zack-snyders-justice-league-6050049514d4c.jpg</thumb>
|
||||
<fanart>
|
||||
<thumb colors="""" preview=""https://assets.fanart.tv/preview/movies/791373/moviebackground/zack-snyders-justice-league-5fee5b9fe0e0d.jpg"">https://assets.fanart.tv/fanart/movies/791373/moviebackground/zack-snyders-justice-league-5fee5b9fe0e0d.jpg</thumb>
|
||||
<thumb colors="""" preview=""https://image.tmdb.org/t/p/w780/43NwryODVEsbBDC0jK3wYfVyb5q.jpg"">https://image.tmdb.org/t/p/original/43NwryODVEsbBDC0jK3wYfVyb5q.jpg</thumb>
|
||||
</fanart>
|
||||
<mpaa>Australia:M</mpaa>
|
||||
<playcount>0</playcount>
|
||||
<lastplayed></lastplayed>
|
||||
<id>791373</id>
|
||||
<uniqueid type=""imdb"">tt12361974</uniqueid>
|
||||
<uniqueid type=""tmdb"" default=""true"">791373</uniqueid>
|
||||
<genre>SuperHero</genre>
|
||||
<tag>TV Recording</tag>
|
||||
<set>
|
||||
<name>Justice League Collection</name>
|
||||
<overview>Based on the DC Comics superhero team</overview>
|
||||
</set>
|
||||
<country>USA</country>
|
||||
<credits>Chris Terrio</credits>
|
||||
<director>Zack Snyder</director>
|
||||
<premiered>2021-03-18</premiered>
|
||||
<year>2021</year>
|
||||
<status></status>
|
||||
<code></code>
|
||||
<aired></aired>
|
||||
<studio>Warner Bros. Pictures</studio>
|
||||
<trailer></trailer>
|
||||
<fileinfo>
|
||||
<streamdetails>
|
||||
<video>
|
||||
<codec>hevc</codec>
|
||||
<aspect>1.777778</aspect>
|
||||
<width>1920</width>
|
||||
<height>1080</height>
|
||||
<durationinseconds>14528</durationinseconds>
|
||||
<stereomode></stereomode>
|
||||
</video>
|
||||
<audio>
|
||||
<codec>ac3</codec>
|
||||
<language>eng</language>
|
||||
<channels>6</channels>
|
||||
</audio>
|
||||
<audio>
|
||||
<codec>ac3</codec>
|
||||
<language>fre</language>
|
||||
<channels>6</channels>
|
||||
</audio>
|
||||
<subtitle>
|
||||
<language>eng</language>
|
||||
</subtitle>
|
||||
</streamdetails>
|
||||
</fileinfo>
|
||||
<actor>
|
||||
<name>Ben Affleck</name>
|
||||
<role>Bruce Wayne / Batman</role>
|
||||
<order>0</order>
|
||||
<thumb>https://image.tmdb.org/t/p/original/u525jeDOzg9hVdvYfeehTGnw7Aa.jpg</thumb>
|
||||
</actor>
|
||||
<actor>
|
||||
<name>Henry Cavill</name>
|
||||
<role>Clark Kent / Superman / Kal-El</role>
|
||||
<order>1</order>
|
||||
<thumb>https://image.tmdb.org/t/p/original/hErUwonrQgY5Y7RfxOfv8Fq11MB.jpg</thumb>
|
||||
</actor>
|
||||
<actor>
|
||||
<name>Gal Gadot</name>
|
||||
<role>Diana Prince / Wonder Woman</role>
|
||||
<order>2</order>
|
||||
<thumb>https://image.tmdb.org/t/p/original/fysvehTvU6bE3JgxaOTRfvQJzJ4.jpg</thumb>
|
||||
</actor>
|
||||
<resume>
|
||||
<position>0.000000</position>
|
||||
<total>0.000000</total>
|
||||
</resume>
|
||||
<dateadded>2021-03-26 11:35:50</dateadded>
|
||||
</movie>"));
|
||||
|
||||
Either<BaseError, OtherVideoNfo> result = await _otherVideoNfoReader.Read(stream);
|
||||
|
||||
result.IsRight.Should().BeTrue();
|
||||
|
||||
foreach (OtherVideoNfo nfo in result.RightToSeq())
|
||||
{
|
||||
nfo.Title.Should().Be("Zack Snyder's Justice League");
|
||||
nfo.SortTitle.Should().Be("Justice League 2");
|
||||
nfo.Outline.Should().BeNullOrEmpty();
|
||||
nfo.Year.Should().Be(2021);
|
||||
nfo.ContentRating.Should().Be("Australia:M");
|
||||
|
||||
nfo.Premiered.IsSome.Should().BeTrue();
|
||||
foreach (DateTime premiered in nfo.Premiered)
|
||||
{
|
||||
premiered.Should().Be(new DateTime(2021, 03, 18));
|
||||
}
|
||||
|
||||
nfo.Plot.Should().Be(
|
||||
"Determined to ensure Superman's ultimate sacrifice was not in vain, Bruce Wayne aligns forces with Diana Prince with plans to recruit a team of metahumans to protect the world from an approaching threat of catastrophic proportions.");
|
||||
nfo.Tagline.Should().BeNullOrEmpty();
|
||||
nfo.Genres.Should().BeEquivalentTo(new List<string> { "SuperHero" });
|
||||
nfo.Tags.Should().BeEquivalentTo(new List<string> { "TV Recording" });
|
||||
nfo.Studios.Should().BeEquivalentTo(new List<string> { "Warner Bros. Pictures" });
|
||||
nfo.Actors.Should().BeEquivalentTo(
|
||||
new List<ActorNfo>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Name = "Ben Affleck", Order = 0, Role = "Bruce Wayne / Batman",
|
||||
Thumb = "https://image.tmdb.org/t/p/original/u525jeDOzg9hVdvYfeehTGnw7Aa.jpg"
|
||||
},
|
||||
new()
|
||||
{
|
||||
Name = "Henry Cavill", Order = 1, Role = "Clark Kent / Superman / Kal-El",
|
||||
Thumb = "https://image.tmdb.org/t/p/original/hErUwonrQgY5Y7RfxOfv8Fq11MB.jpg"
|
||||
},
|
||||
new()
|
||||
{
|
||||
Name = "Gal Gadot", Order = 2, Role = "Diana Prince / Wonder Woman",
|
||||
Thumb = "https://image.tmdb.org/t/p/original/fysvehTvU6bE3JgxaOTRfvQJzJ4.jpg"
|
||||
}
|
||||
});
|
||||
nfo.Writers.Should().BeEquivalentTo(new List<string> { "Chris Terrio" });
|
||||
nfo.Directors.Should().BeEquivalentTo(new List<string> { "Zack Snyder" });
|
||||
nfo.UniqueIds.Should().BeEquivalentTo(
|
||||
new List<UniqueIdNfo>
|
||||
{
|
||||
new() { Type = "imdb", Guid = "tt12361974", Default = false },
|
||||
new() { Type = "tmdb", Guid = "791373", Default = true }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task MetadataNfo_With_Tag_Should_Return_Nfo()
|
||||
{
|
||||
await using var stream = new MemoryStream(Encoding.UTF8.GetBytes(@"<movie><tag>Test Tag</tag></movie>"));
|
||||
|
||||
Either<BaseError, OtherVideoNfo> result = await _otherVideoNfoReader.Read(stream);
|
||||
|
||||
result.IsRight.Should().BeTrue();
|
||||
foreach (OtherVideoNfo nfo in result.RightToSeq())
|
||||
{
|
||||
nfo.Tags.Should().BeEquivalentTo(new List<string> { "Test Tag" });
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task MetadataNfo_With_Outline_Should_Return_Nfo()
|
||||
{
|
||||
await using var stream =
|
||||
new MemoryStream(Encoding.UTF8.GetBytes(@"<movie><outline>Test Outline</outline></movie>"));
|
||||
|
||||
Either<BaseError, OtherVideoNfo> result = await _otherVideoNfoReader.Read(stream);
|
||||
|
||||
result.IsRight.Should().BeTrue();
|
||||
foreach (OtherVideoNfo nfo in result.RightToSeq())
|
||||
{
|
||||
nfo.Outline.Should().Be("Test Outline");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -1,5 +1,31 @@
|
||||
namespace ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using ErsatzTV.FFmpeg.Format;
|
||||
|
||||
namespace ErsatzTV.Core.Domain;
|
||||
|
||||
public class BackgroundImageMediaVersion : MediaVersion
|
||||
{
|
||||
public static BackgroundImageMediaVersion ForPath(string path, IDisplaySize resolution) =>
|
||||
new()
|
||||
{
|
||||
Chapters = new List<MediaChapter>(),
|
||||
// image has been pre-generated with correct size
|
||||
Height = resolution.Height,
|
||||
Width = resolution.Width,
|
||||
SampleAspectRatio = "1:1",
|
||||
Streams = new List<MediaStream>
|
||||
{
|
||||
new()
|
||||
{
|
||||
MediaStreamKind = MediaStreamKind.Video,
|
||||
Index = 0,
|
||||
Codec = VideoFormat.GeneratedImage,
|
||||
PixelFormat = new PixelFormatUnknown().Name // the resulting pixel format is unknown
|
||||
}
|
||||
},
|
||||
MediaFiles = new List<MediaFile>
|
||||
{
|
||||
new() { Path = path }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
|
||||
public class OtherVideoMetadata : Metadata
|
||||
{
|
||||
public string ContentRating { get; set; }
|
||||
public string Outline { get; set; }
|
||||
public string Plot { get; set; }
|
||||
public string Tagline { get; set; }
|
||||
public int OtherVideoId { get; set; }
|
||||
public OtherVideo OtherVideo { get; set; }
|
||||
public List<Director> Directors { get; set; }
|
||||
public List<Writer> Writers { get; set; }
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ public class PlayoutItem
|
||||
public string ChapterTitle { get; set; }
|
||||
public ChannelWatermark Watermark { get; set; }
|
||||
public int? WatermarkId { get; set; }
|
||||
public bool DisableWatermarks { get; set; }
|
||||
public string PreferredAudioLanguageCode { get; set; }
|
||||
public string PreferredSubtitleLanguageCode { get; set; }
|
||||
public ChannelSubtitleMode? SubtitleMode { get; set; }
|
||||
|
||||
@@ -7,6 +7,7 @@ public class ProgramSchedule
|
||||
public bool KeepMultiPartEpisodesTogether { get; set; }
|
||||
public bool TreatCollectionsAsShows { get; set; }
|
||||
public bool ShuffleScheduleItems { get; set; }
|
||||
public bool RandomStartPoint { get; set; }
|
||||
public List<ProgramScheduleItem> Items { get; set; }
|
||||
public List<Playout> Playouts { get; set; }
|
||||
}
|
||||
|
||||
@@ -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
ErsatzTV.Core/Emby/EmbyItemType.cs
Normal file
11
ErsatzTV.Core/Emby/EmbyItemType.cs
Normal 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";
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -18,16 +18,19 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
private readonly IFFmpegStreamSelector _ffmpegStreamSelector;
|
||||
private readonly ILogger<FFmpegLibraryProcessService> _logger;
|
||||
private readonly FFmpegPlaybackSettingsCalculator _playbackSettingsCalculator;
|
||||
private readonly ITempFilePool _tempFilePool;
|
||||
|
||||
public FFmpegLibraryProcessService(
|
||||
FFmpegProcessService ffmpegProcessService,
|
||||
FFmpegPlaybackSettingsCalculator playbackSettingsCalculator,
|
||||
IFFmpegStreamSelector ffmpegStreamSelector,
|
||||
ITempFilePool tempFilePool,
|
||||
ILogger<FFmpegLibraryProcessService> logger)
|
||||
{
|
||||
_ffmpegProcessService = ffmpegProcessService;
|
||||
_playbackSettingsCalculator = playbackSettingsCalculator;
|
||||
_ffmpegStreamSelector = ffmpegStreamSelector;
|
||||
_tempFilePool = tempFilePool;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -56,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 =
|
||||
@@ -87,8 +91,9 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
hlsRealtime,
|
||||
targetFramerate);
|
||||
|
||||
Option<WatermarkOptions> watermarkOptions =
|
||||
await _ffmpegProcessService.GetWatermarkOptions(
|
||||
Option<WatermarkOptions> watermarkOptions = disableWatermarks
|
||||
? None
|
||||
: await _ffmpegProcessService.GetWatermarkOptions(
|
||||
ffprobePath,
|
||||
channel,
|
||||
playoutItemWatermark,
|
||||
@@ -174,23 +179,9 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
|
||||
Option<WatermarkInputFile> watermarkInputFile = GetWatermarkInputFile(watermarkOptions, maybeFadePoints);
|
||||
|
||||
string videoFormat = playbackSettings.VideoFormat switch
|
||||
{
|
||||
FFmpegProfileVideoFormat.Hevc => VideoFormat.Hevc,
|
||||
FFmpegProfileVideoFormat.H264 => VideoFormat.H264,
|
||||
FFmpegProfileVideoFormat.Mpeg2Video => VideoFormat.Mpeg2Video,
|
||||
FFmpegProfileVideoFormat.Copy => VideoFormat.Copy,
|
||||
_ => throw new ArgumentOutOfRangeException($"unexpected video format {playbackSettings.VideoFormat}")
|
||||
};
|
||||
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
|
||||
@@ -236,7 +227,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
outputFormat,
|
||||
hlsPlaylistPath,
|
||||
hlsSegmentTemplate,
|
||||
ptsOffset);
|
||||
ptsOffset,
|
||||
playbackSettings.ThreadCount);
|
||||
|
||||
_logger.LogDebug("FFmpeg desired state {FrameState}", desiredState);
|
||||
|
||||
@@ -254,14 +246,133 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
return GetCommand(ffmpegPath, videoInputFile, audioInputFile, watermarkInputFile, None, pipeline);
|
||||
}
|
||||
|
||||
public Task<Command> ForError(
|
||||
public async Task<Command> ForError(
|
||||
string ffmpegPath,
|
||||
Channel channel,
|
||||
Option<TimeSpan> duration,
|
||||
string errorMessage,
|
||||
bool hlsRealtime,
|
||||
long ptsOffset) =>
|
||||
_ffmpegProcessService.ForError(ffmpegPath, channel, duration, errorMessage, hlsRealtime, ptsOffset);
|
||||
long ptsOffset,
|
||||
VaapiDriver vaapiDriver,
|
||||
string vaapiDevice)
|
||||
{
|
||||
FFmpegPlaybackSettings playbackSettings = _playbackSettingsCalculator.CalculateErrorSettings(
|
||||
channel.StreamingMode,
|
||||
channel.FFmpegProfile,
|
||||
hlsRealtime);
|
||||
|
||||
IDisplaySize desiredResolution = channel.FFmpegProfile.Resolution;
|
||||
|
||||
var fontSize = (int)Math.Round(channel.FFmpegProfile.Resolution.Height / 20.0);
|
||||
var margin = (int)Math.Round(channel.FFmpegProfile.Resolution.Height * 0.05);
|
||||
|
||||
string subtitleFile = await new SubtitleBuilder(_tempFilePool)
|
||||
.WithResolution(desiredResolution)
|
||||
.WithFontName("Roboto")
|
||||
.WithFontSize(fontSize)
|
||||
.WithAlignment(2)
|
||||
.WithMarginV(margin)
|
||||
.WithPrimaryColor("&HFFFFFF")
|
||||
.WithFormattedContent(errorMessage.Replace(Environment.NewLine, "\\N"))
|
||||
.BuildFile();
|
||||
|
||||
string audioFormat = playbackSettings.AudioFormat switch
|
||||
{
|
||||
FFmpegProfileAudioFormat.Ac3 => AudioFormat.Ac3,
|
||||
_ => AudioFormat.Aac
|
||||
};
|
||||
|
||||
var audioState = new AudioState(
|
||||
audioFormat,
|
||||
playbackSettings.AudioChannels,
|
||||
playbackSettings.AudioBitrate,
|
||||
playbackSettings.AudioBufferSize,
|
||||
playbackSettings.AudioSampleRate,
|
||||
Option<TimeSpan>.None,
|
||||
false);
|
||||
|
||||
var desiredState = new FrameState(
|
||||
playbackSettings.RealtimeOutput,
|
||||
false,
|
||||
GetVideoFormat(playbackSettings),
|
||||
new PixelFormatYuv420P(),
|
||||
new FrameSize(desiredResolution.Width, desiredResolution.Height),
|
||||
new FrameSize(desiredResolution.Width, desiredResolution.Height),
|
||||
playbackSettings.FrameRate,
|
||||
playbackSettings.VideoBitrate,
|
||||
playbackSettings.VideoBufferSize,
|
||||
playbackSettings.VideoTrackTimeScale,
|
||||
playbackSettings.Deinterlace);
|
||||
|
||||
OutputFormatKind outputFormat = channel.StreamingMode == StreamingMode.HttpLiveStreamingSegmenter
|
||||
? OutputFormatKind.Hls
|
||||
: OutputFormatKind.MpegTs;
|
||||
|
||||
Option<string> hlsPlaylistPath = outputFormat == OutputFormatKind.Hls
|
||||
? Path.Combine(FileSystemLayout.TranscodeFolder, channel.Number, "live.m3u8")
|
||||
: Option<string>.None;
|
||||
|
||||
Option<string> hlsSegmentTemplate = outputFormat == OutputFormatKind.Hls
|
||||
? Path.Combine(FileSystemLayout.TranscodeFolder, channel.Number, "live%06d.ts")
|
||||
: Option<string>.None;
|
||||
|
||||
string videoPath = Path.Combine(FileSystemLayout.ResourcesCacheFolder, "background.png");
|
||||
|
||||
var videoVersion = BackgroundImageMediaVersion.ForPath(videoPath, desiredResolution);
|
||||
|
||||
var ffmpegVideoStream = new VideoStream(
|
||||
0,
|
||||
VideoFormat.GeneratedImage,
|
||||
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,
|
||||
hwAccel,
|
||||
VaapiDriverName(hwAccel, vaapiDriver),
|
||||
VaapiDeviceName(hwAccel, vaapiDevice),
|
||||
playbackSettings.StreamSeek,
|
||||
duration,
|
||||
channel.StreamingMode != StreamingMode.HttpLiveStreamingDirect,
|
||||
"ErsatzTV",
|
||||
channel.Name,
|
||||
None,
|
||||
outputFormat,
|
||||
hlsPlaylistPath,
|
||||
hlsSegmentTemplate,
|
||||
ptsOffset,
|
||||
Option<int>.None);
|
||||
|
||||
var ffmpegSubtitleStream = new ErsatzTV.FFmpeg.MediaStream(0, "ass", StreamKind.Video);
|
||||
|
||||
var audioInputFile = new NullAudioInputFile(audioState);
|
||||
|
||||
var subtitleInputFile = new SubtitleInputFile(
|
||||
subtitleFile,
|
||||
new List<ErsatzTV.FFmpeg.MediaStream> { ffmpegSubtitleStream },
|
||||
false);
|
||||
|
||||
_logger.LogDebug("FFmpeg desired error state {FrameState}", desiredState);
|
||||
|
||||
var pipelineBuilder = new PipelineBuilder(
|
||||
videoInputFile,
|
||||
audioInputFile,
|
||||
None,
|
||||
subtitleInputFile,
|
||||
FileSystemLayout.FFmpegReportsFolder,
|
||||
FileSystemLayout.FontsCacheFolder,
|
||||
_logger);
|
||||
|
||||
FFmpegPipeline pipeline = pipelineBuilder.Build(ffmpegState, desiredState);
|
||||
|
||||
return GetCommand(ffmpegPath, videoInputFile, audioInputFile, None, None, pipeline);
|
||||
}
|
||||
|
||||
public Command ConcatChannel(string ffmpegPath, bool saveReports, Channel channel, string scheme, string host)
|
||||
{
|
||||
@@ -478,4 +589,24 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
|
||||
private static Option<string> VaapiDeviceName(HardwareAccelerationMode accelerationMode, string vaapiDevice) =>
|
||||
accelerationMode == HardwareAccelerationMode.Vaapi ? vaapiDevice : Option<string>.None;
|
||||
|
||||
private static string GetVideoFormat(FFmpegPlaybackSettings playbackSettings) =>
|
||||
playbackSettings.VideoFormat switch
|
||||
{
|
||||
FFmpegProfileVideoFormat.Hevc => VideoFormat.Hevc,
|
||||
FFmpegProfileVideoFormat.H264 => VideoFormat.H264,
|
||||
FFmpegProfileVideoFormat.Mpeg2Video => VideoFormat.Mpeg2Video,
|
||||
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
|
||||
@@ -170,14 +170,29 @@ public class FFmpegPlaybackSettingsCalculator
|
||||
return result;
|
||||
}
|
||||
|
||||
public FFmpegPlaybackSettings CalculateErrorSettings(FFmpegProfile ffmpegProfile) =>
|
||||
public FFmpegPlaybackSettings CalculateErrorSettings(
|
||||
StreamingMode streamingMode,
|
||||
FFmpegProfile ffmpegProfile,
|
||||
bool hlsRealtime) =>
|
||||
new()
|
||||
{
|
||||
HardwareAcceleration = HardwareAccelerationKind.None,
|
||||
ThreadCount = ffmpegProfile.ThreadCount,
|
||||
HardwareAcceleration = ffmpegProfile.HardwareAcceleration,
|
||||
FormatFlags = CommonFormatFlags,
|
||||
VideoFormat = ffmpegProfile.VideoFormat,
|
||||
AudioFormat = ffmpegProfile.AudioFormat
|
||||
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,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
|
||||
|
||||
@@ -40,92 +40,6 @@ public class FFmpegProcessService
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Command> ForError(
|
||||
string ffmpegPath,
|
||||
Channel channel,
|
||||
Option<TimeSpan> duration,
|
||||
string errorMessage,
|
||||
bool hlsRealtime,
|
||||
long ptsOffset)
|
||||
{
|
||||
FFmpegPlaybackSettings playbackSettings =
|
||||
_playbackSettingsCalculator.CalculateErrorSettings(channel.FFmpegProfile);
|
||||
|
||||
IDisplaySize desiredResolution = channel.FFmpegProfile.Resolution;
|
||||
|
||||
var fontSize = (int)Math.Round(channel.FFmpegProfile.Resolution.Height / 20.0);
|
||||
var margin = (int)Math.Round(channel.FFmpegProfile.Resolution.Height * 0.05);
|
||||
|
||||
string subtitleFile = await new SubtitleBuilder(_tempFilePool)
|
||||
.WithResolution(desiredResolution)
|
||||
.WithFontName("Roboto")
|
||||
.WithFontSize(fontSize)
|
||||
.WithAlignment(2)
|
||||
.WithMarginV(margin)
|
||||
.WithPrimaryColor("&HFFFFFF")
|
||||
.WithFormattedContent(errorMessage.Replace(Environment.NewLine, "\\N"))
|
||||
.BuildFile();
|
||||
|
||||
var videoStream = new MediaStream { Index = 0 };
|
||||
var audioStream = new MediaStream { Index = 0 };
|
||||
|
||||
string videoCodec = playbackSettings.VideoFormat switch
|
||||
{
|
||||
FFmpegProfileVideoFormat.Hevc => "libx265",
|
||||
FFmpegProfileVideoFormat.Mpeg2Video => "mpeg2video",
|
||||
_ => "libx264"
|
||||
};
|
||||
|
||||
string audioCodec = playbackSettings.AudioFormat switch
|
||||
{
|
||||
FFmpegProfileAudioFormat.Ac3 => "ac3",
|
||||
_ => "aac"
|
||||
};
|
||||
|
||||
FFmpegProcessBuilder builder = new FFmpegProcessBuilder(ffmpegPath, false, _logger)
|
||||
.WithThreads(1)
|
||||
.WithQuiet()
|
||||
.WithFormatFlags(playbackSettings.FormatFlags)
|
||||
.WithRealtimeOutput(playbackSettings.RealtimeOutput)
|
||||
.WithLoopedImage(Path.Combine(FileSystemLayout.ResourcesCacheFolder, "background.png"))
|
||||
.WithLibavfilter()
|
||||
.WithInput("anullsrc")
|
||||
.WithSubtitleFile(subtitleFile)
|
||||
.WithFilterComplex(
|
||||
videoStream,
|
||||
audioStream,
|
||||
Path.Combine(FileSystemLayout.ResourcesCacheFolder, "background.png"),
|
||||
"fake-audio-path",
|
||||
playbackSettings.VideoFormat)
|
||||
.WithPixfmt("yuv420p")
|
||||
.WithPlaybackArgs(playbackSettings, videoCodec, audioCodec)
|
||||
.WithMetadata(channel, None);
|
||||
|
||||
await duration.IfSomeAsync(d => builder = builder.WithDuration(d));
|
||||
|
||||
Process process = channel.StreamingMode switch
|
||||
{
|
||||
// HLS needs to segment and generate playlist
|
||||
StreamingMode.HttpLiveStreamingSegmenter =>
|
||||
builder.WithHls(
|
||||
channel.Number,
|
||||
None,
|
||||
ptsOffset,
|
||||
playbackSettings.VideoTrackTimeScale,
|
||||
playbackSettings.FrameRate)
|
||||
.Build(),
|
||||
_ => builder.WithFormat("mpegts")
|
||||
.WithPipe()
|
||||
.Build()
|
||||
};
|
||||
|
||||
return Cli.Wrap(process.StartInfo.FileName)
|
||||
.WithArguments(process.StartInfo.ArgumentList)
|
||||
.WithValidation(CommandResultValidation.None)
|
||||
.WithEnvironmentVariables(process.StartInfo.Environment.ToDictionary(kvp => kvp.Key, kvp => kvp.Value))
|
||||
.WithStandardErrorPipe(PipeTarget.ToStream(Stream.Null));
|
||||
}
|
||||
|
||||
public Command WrapSegmenter(string ffmpegPath, bool saveReports, Channel channel, string scheme, string host)
|
||||
{
|
||||
FFmpegPlaybackSettings playbackSettings = _playbackSettingsCalculator.ConcatSettings;
|
||||
@@ -231,7 +145,10 @@ public class FFmpegProcessService
|
||||
watermarkPath);
|
||||
|
||||
FFmpegPlaybackSettings playbackSettings =
|
||||
_playbackSettingsCalculator.CalculateErrorSettings(channel.FFmpegProfile);
|
||||
_playbackSettingsCalculator.CalculateErrorSettings(
|
||||
StreamingMode.TransportStream,
|
||||
channel.FFmpegProfile,
|
||||
false);
|
||||
|
||||
FFmpegPlaybackSettings scalePlaybackSettings = _playbackSettingsCalculator.CalculateSettings(
|
||||
StreamingMode.TransportStream,
|
||||
|
||||
@@ -82,7 +82,7 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector
|
||||
"Unable to find audio stream with preferred audio language code(s) {Code}; selecting stream with most channels",
|
||||
allCodes);
|
||||
|
||||
return audioStreams.OrderByDescending(s => s.Channels).Head();
|
||||
return audioStreams.OrderByDescending(s => s.Channels).HeadOrNone();
|
||||
}
|
||||
|
||||
public async Task<Option<Subtitle>> SelectSubtitleStream(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Images;
|
||||
using ErsatzTV.FFmpeg.Format;
|
||||
using ErsatzTV.FFmpeg.State;
|
||||
|
||||
namespace ErsatzTV.Core.FFmpeg;
|
||||
@@ -193,7 +192,7 @@ public class SongVideoGenerator : ISongVideoGenerator
|
||||
{
|
||||
string hash = hashes[NextRandom(hashes.Count)];
|
||||
|
||||
backgroundPath = _imageCache.WriteBlurHash(hash, channel.FFmpegProfile.Resolution);
|
||||
backgroundPath = await _imageCache.WriteBlurHash(hash, channel.FFmpegProfile.Resolution);
|
||||
|
||||
videoVersion.Height = channel.FFmpegProfile.Resolution.Height;
|
||||
videoVersion.Width = channel.FFmpegProfile.Resolution.Width;
|
||||
@@ -233,28 +232,7 @@ public class SongVideoGenerator : ISongVideoGenerator
|
||||
foreach (string si in maybeSongImage.RightToSeq())
|
||||
{
|
||||
videoPath = si;
|
||||
videoVersion = new BackgroundImageMediaVersion
|
||||
{
|
||||
Chapters = new List<MediaChapter>(),
|
||||
// song image has been pre-generated with correct size
|
||||
Height = channel.FFmpegProfile.Resolution.Height,
|
||||
Width = channel.FFmpegProfile.Resolution.Width,
|
||||
SampleAspectRatio = "1:1",
|
||||
Streams = new List<MediaStream>
|
||||
{
|
||||
new()
|
||||
{
|
||||
MediaStreamKind = MediaStreamKind.Video,
|
||||
Index = 0,
|
||||
Codec = VideoFormat.GeneratedImage,
|
||||
PixelFormat = new PixelFormatUnknown().Name // the resulting pixel format is unknown
|
||||
}
|
||||
},
|
||||
MediaFiles = new List<MediaFile>
|
||||
{
|
||||
new() { Path = si }
|
||||
}
|
||||
};
|
||||
videoVersion = BackgroundImageMediaVersion.ForPath(si, channel.FFmpegProfile.Resolution);
|
||||
}
|
||||
|
||||
return Tuple(videoPath, videoVersion);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -8,6 +8,6 @@ public interface IImageCache
|
||||
Task<Either<BaseError, string>> SaveArtworkToCache(Stream stream, ArtworkKind artworkKind);
|
||||
Task<Either<BaseError, string>> CopyArtworkToCache(string path, ArtworkKind artworkKind);
|
||||
string GetPathForImage(string fileName, ArtworkKind artworkKind, Option<int> maybeMaxHeight);
|
||||
string CalculateBlurHash(string fileName, ArtworkKind artworkKind, int x, int y);
|
||||
string WriteBlurHash(string blurHash, IDisplaySize targetSize);
|
||||
Task<string> CalculateBlurHash(string fileName, ArtworkKind artworkKind, int x, int y);
|
||||
Task<string> WriteBlurHash(string blurHash, IDisplaySize targetSize);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ public interface ILocalMetadataProvider
|
||||
Task<bool> RefreshSidecarMetadata(Episode episode, string nfoFileName);
|
||||
Task<bool> RefreshSidecarMetadata(Artist artist, string nfoFileName);
|
||||
Task<bool> RefreshSidecarMetadata(MusicVideo musicVideo, string nfoFileName);
|
||||
Task<bool> RefreshSidecarMetadata(OtherVideo otherVideo, string nfoFileName);
|
||||
Task<bool> RefreshTagMetadata(Song song, string ffprobePath);
|
||||
Task<bool> RefreshFallbackMetadata(Movie movie);
|
||||
Task<bool> RefreshFallbackMetadata(Episode episode);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
using ErsatzTV.Core.Metadata.Nfo;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Metadata.Nfo;
|
||||
|
||||
public interface IOtherVideoNfoReader
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -8,9 +8,12 @@ public interface IOtherVideoRepository
|
||||
Task<Either<BaseError, MediaItemScanResult<OtherVideo>>> GetOrAdd(LibraryPath libraryPath, string path);
|
||||
Task<IEnumerable<string>> FindOtherVideoPaths(LibraryPath libraryPath);
|
||||
Task<List<int>> DeleteByPath(LibraryPath libraryPath, string path);
|
||||
Task<bool> AddGenre(OtherVideoMetadata metadata, Genre genre);
|
||||
Task<bool> AddTag(OtherVideoMetadata metadata, Tag tag);
|
||||
Task<bool> AddStudio(OtherVideoMetadata metadata, Studio studio);
|
||||
Task<bool> AddActor(OtherVideoMetadata metadata, Actor actor);
|
||||
Task<bool> AddDirector(OtherVideoMetadata metadata, Director director);
|
||||
Task<bool> AddWriter(OtherVideoMetadata metadata, Writer writer);
|
||||
|
||||
Task<List<OtherVideoMetadata>> GetOtherVideosForCards(List<int> ids);
|
||||
// Task<int> GetOtherVideoCount(int artistId);
|
||||
// Task<List<OtherVideoMetadata>> GetPagedOtherVideos(int artistId, int pageNumber, int pageSize);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
11
ErsatzTV.Core/Jellyfin/JellyfinItemType.cs
Normal file
11
ErsatzTV.Core/Jellyfin/JellyfinItemType.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
namespace ErsatzTV.Core.Jellyfin;
|
||||
|
||||
public static class JellyfinItemType
|
||||
{
|
||||
public static readonly string Movie = "Movie";
|
||||
public static readonly string Show = "Series";
|
||||
public static readonly string Season = "Season";
|
||||
public static readonly string Episode = "Episode";
|
||||
public static readonly string Collection = "BoxSet";
|
||||
public static readonly string CollectionItems = "Movie,Series,Season,Episode";
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -70,7 +70,8 @@ public class FallbackMetadataProvider : IFallbackMetadataProvider
|
||||
Studios = new List<Studio>(),
|
||||
Actors = new List<Actor>(),
|
||||
Directors = new List<Director>(),
|
||||
Writers = new List<Writer>()
|
||||
Writers = new List<Writer>(),
|
||||
Guids = new List<MetadataGuid>()
|
||||
};
|
||||
|
||||
return fileName != null ? GetMovieMetadata(fileName, metadata) : metadata;
|
||||
@@ -97,7 +98,14 @@ public class FallbackMetadataProvider : IFallbackMetadataProvider
|
||||
{
|
||||
MetadataKind = MetadataKind.Fallback,
|
||||
Title = fileName ?? path,
|
||||
OtherVideo = otherVideo
|
||||
OtherVideo = otherVideo,
|
||||
Genres = new List<Genre>(),
|
||||
Tags = new List<Tag>(),
|
||||
Studios = new List<Studio>(),
|
||||
Actors = new List<Actor>(),
|
||||
Directors = new List<Director>(),
|
||||
Writers = new List<Writer>(),
|
||||
Guids = new List<MetadataGuid>()
|
||||
};
|
||||
|
||||
return GetOtherVideoMetadata(path, metadata);
|
||||
|
||||
@@ -95,20 +95,23 @@ public abstract class LocalFolderScanner
|
||||
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", path);
|
||||
Either<BaseError, bool> refreshResult =
|
||||
await _localStatisticsProvider.RefreshStatistics(ffmpegPath, ffprobePath, mediaItem.Item);
|
||||
refreshResult.Match(
|
||||
result =>
|
||||
|
||||
foreach (BaseError error in refreshResult.LeftToSeq())
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Unable to refresh {Attribute} for media item {Path}. Error: {Error}",
|
||||
"Statistics",
|
||||
path,
|
||||
error.Value);
|
||||
}
|
||||
|
||||
foreach (bool result in refreshResult.RightToSeq())
|
||||
{
|
||||
if (result)
|
||||
{
|
||||
if (result)
|
||||
{
|
||||
mediaItem.IsUpdated = true;
|
||||
}
|
||||
},
|
||||
error =>
|
||||
_logger.LogWarning(
|
||||
"Unable to refresh {Attribute} for media item {Path}. Error: {Error}",
|
||||
"Statistics",
|
||||
path,
|
||||
error.Value));
|
||||
mediaItem.IsUpdated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return mediaItem;
|
||||
@@ -200,9 +203,21 @@ public abstract class LocalFolderScanner
|
||||
|
||||
if (metadata is SongMetadata)
|
||||
{
|
||||
artwork.BlurHash43 = _imageCache.CalculateBlurHash(cacheName, artworkKind, 4, 3);
|
||||
artwork.BlurHash54 = _imageCache.CalculateBlurHash(cacheName, artworkKind, 5, 4);
|
||||
artwork.BlurHash64 = _imageCache.CalculateBlurHash(cacheName, artworkKind, 6, 4);
|
||||
artwork.BlurHash43 = await _imageCache.CalculateBlurHash(
|
||||
cacheName,
|
||||
artworkKind,
|
||||
4,
|
||||
3);
|
||||
artwork.BlurHash54 = await _imageCache.CalculateBlurHash(
|
||||
cacheName,
|
||||
artworkKind,
|
||||
5,
|
||||
4);
|
||||
artwork.BlurHash64 = await _imageCache.CalculateBlurHash(
|
||||
cacheName,
|
||||
artworkKind,
|
||||
6,
|
||||
4);
|
||||
}
|
||||
|
||||
await _metadataRepository.UpdateArtworkPath(artwork);
|
||||
@@ -220,9 +235,21 @@ public abstract class LocalFolderScanner
|
||||
|
||||
if (metadata is SongMetadata)
|
||||
{
|
||||
artwork.BlurHash43 = _imageCache.CalculateBlurHash(cacheName, artworkKind, 4, 3);
|
||||
artwork.BlurHash54 = _imageCache.CalculateBlurHash(cacheName, artworkKind, 5, 4);
|
||||
artwork.BlurHash64 = _imageCache.CalculateBlurHash(cacheName, artworkKind, 6, 4);
|
||||
artwork.BlurHash43 = await _imageCache.CalculateBlurHash(
|
||||
cacheName,
|
||||
artworkKind,
|
||||
4,
|
||||
3);
|
||||
artwork.BlurHash54 = await _imageCache.CalculateBlurHash(
|
||||
cacheName,
|
||||
artworkKind,
|
||||
5,
|
||||
4);
|
||||
artwork.BlurHash64 = await _imageCache.CalculateBlurHash(
|
||||
cacheName,
|
||||
artworkKind,
|
||||
6,
|
||||
4);
|
||||
}
|
||||
|
||||
metadata.Artwork.Add(artwork);
|
||||
@@ -272,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"));
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ public class LocalMetadataProvider : ILocalMetadataProvider
|
||||
private readonly IMovieRepository _movieRepository;
|
||||
private readonly IMusicVideoNfoReader _musicVideoNfoReader;
|
||||
private readonly IMusicVideoRepository _musicVideoRepository;
|
||||
private readonly IOtherVideoNfoReader _otherVideoNfoReader;
|
||||
private readonly IOtherVideoRepository _otherVideoRepository;
|
||||
private readonly ISongRepository _songRepository;
|
||||
private readonly ITelevisionRepository _televisionRepository;
|
||||
@@ -44,6 +45,7 @@ public class LocalMetadataProvider : ILocalMetadataProvider
|
||||
IArtistNfoReader artistNfoReader,
|
||||
IMusicVideoNfoReader musicVideoNfoReader,
|
||||
ITvShowNfoReader tvShowNfoReader,
|
||||
IOtherVideoNfoReader otherVideoNfoReader,
|
||||
ILocalStatisticsProvider localStatisticsProvider,
|
||||
IClient client,
|
||||
ILogger<LocalMetadataProvider> logger)
|
||||
@@ -62,6 +64,7 @@ public class LocalMetadataProvider : ILocalMetadataProvider
|
||||
_artistNfoReader = artistNfoReader;
|
||||
_musicVideoNfoReader = musicVideoNfoReader;
|
||||
_tvShowNfoReader = tvShowNfoReader;
|
||||
_otherVideoNfoReader = otherVideoNfoReader;
|
||||
_localStatisticsProvider = localStatisticsProvider;
|
||||
_client = client;
|
||||
_logger = logger;
|
||||
@@ -107,38 +110,77 @@ public class LocalMetadataProvider : ILocalMetadataProvider
|
||||
return fallbackMetadata;
|
||||
}
|
||||
|
||||
public Task<bool> RefreshSidecarMetadata(Movie movie, string nfoFileName) =>
|
||||
LoadMovieMetadata(movie, nfoFileName).Bind(
|
||||
maybeMetadata => maybeMetadata.Match(
|
||||
metadata => ApplyMetadataUpdate(movie, metadata),
|
||||
() => Task.FromResult(false)));
|
||||
public async Task<bool> RefreshSidecarMetadata(Movie movie, string nfoFileName)
|
||||
{
|
||||
Option<MovieMetadata> maybeMetadata = await LoadMovieMetadata(movie, nfoFileName);
|
||||
foreach (MovieMetadata metadata in maybeMetadata)
|
||||
{
|
||||
return await ApplyMetadataUpdate(movie, metadata);
|
||||
}
|
||||
|
||||
public Task<bool> RefreshSidecarMetadata(Show televisionShow, string nfoFileName) =>
|
||||
LoadTelevisionShowMetadata(nfoFileName).Bind(
|
||||
maybeMetadata => maybeMetadata.Match(
|
||||
metadata => ApplyMetadataUpdate(televisionShow, metadata),
|
||||
() => Task.FromResult(false)));
|
||||
return false;
|
||||
}
|
||||
|
||||
public Task<bool> RefreshSidecarMetadata(Episode episode, string nfoFileName) =>
|
||||
LoadEpisodeMetadata(episode, nfoFileName).Bind(metadata => ApplyMetadataUpdate(episode, metadata));
|
||||
public async Task<bool> RefreshSidecarMetadata(Show televisionShow, string nfoFileName)
|
||||
{
|
||||
Option<ShowMetadata> maybeMetadata = await LoadTelevisionShowMetadata(nfoFileName);
|
||||
foreach (ShowMetadata metadata in maybeMetadata)
|
||||
{
|
||||
return await ApplyMetadataUpdate(televisionShow, metadata);
|
||||
}
|
||||
|
||||
public Task<bool> RefreshSidecarMetadata(Artist artist, string nfoFileName) =>
|
||||
LoadArtistMetadata(nfoFileName).Bind(
|
||||
maybeMetadata => maybeMetadata.Match(
|
||||
metadata => ApplyMetadataUpdate(artist, metadata),
|
||||
() => Task.FromResult(false)));
|
||||
return false;
|
||||
}
|
||||
|
||||
public Task<bool> RefreshSidecarMetadata(MusicVideo musicVideo, string nfoFileName) =>
|
||||
LoadMusicVideoMetadata(nfoFileName).Bind(
|
||||
maybeMetadata => maybeMetadata.Match(
|
||||
metadata => ApplyMetadataUpdate(musicVideo, metadata),
|
||||
() => RefreshFallbackMetadata(musicVideo)));
|
||||
public async Task<bool> RefreshSidecarMetadata(Episode episode, string nfoFileName)
|
||||
{
|
||||
List<EpisodeMetadata> metadata = await LoadEpisodeMetadata(episode, nfoFileName);
|
||||
return await ApplyMetadataUpdate(episode, metadata);
|
||||
}
|
||||
|
||||
public Task<bool> RefreshTagMetadata(Song song, string ffprobePath) =>
|
||||
LoadSongMetadata(song, ffprobePath).Bind(
|
||||
maybeMetadata => maybeMetadata.Match(
|
||||
metadata => ApplyMetadataUpdate(song, metadata),
|
||||
() => RefreshFallbackMetadata(song)));
|
||||
public async Task<bool> RefreshSidecarMetadata(Artist artist, string nfoFileName)
|
||||
{
|
||||
Option<ArtistMetadata> maybeMetadata = await LoadArtistMetadata(nfoFileName);
|
||||
foreach (ArtistMetadata metadata in maybeMetadata)
|
||||
{
|
||||
return await ApplyMetadataUpdate(artist, metadata);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public async Task<bool> RefreshSidecarMetadata(MusicVideo musicVideo, string nfoFileName)
|
||||
{
|
||||
Option<MusicVideoMetadata> maybeMetadata = await LoadMusicVideoMetadata(nfoFileName);
|
||||
foreach (MusicVideoMetadata metadata in maybeMetadata)
|
||||
{
|
||||
return await ApplyMetadataUpdate(musicVideo, metadata);
|
||||
}
|
||||
|
||||
return await RefreshFallbackMetadata(musicVideo);
|
||||
}
|
||||
|
||||
public async Task<bool> RefreshSidecarMetadata(OtherVideo otherVideo, string nfoFileName)
|
||||
{
|
||||
Option<OtherVideoMetadata> maybeMetadata = await LoadOtherVideoMetadata(nfoFileName);
|
||||
foreach (OtherVideoMetadata metadata in maybeMetadata)
|
||||
{
|
||||
return await ApplyMetadataUpdate(otherVideo, metadata);
|
||||
}
|
||||
|
||||
return await RefreshFallbackMetadata(otherVideo);
|
||||
}
|
||||
|
||||
public async Task<bool> RefreshTagMetadata(Song song, string ffprobePath)
|
||||
{
|
||||
Option<SongMetadata> maybeMetadata = await LoadSongMetadata(song, ffprobePath);
|
||||
foreach (SongMetadata metadata in maybeMetadata)
|
||||
{
|
||||
return await ApplyMetadataUpdate(song, metadata);
|
||||
}
|
||||
|
||||
return await RefreshFallbackMetadata(song);
|
||||
}
|
||||
|
||||
public Task<bool> RefreshFallbackMetadata(Movie movie) =>
|
||||
ApplyMetadataUpdate(movie, _fallbackMetadataProvider.GetFallbackMetadata(movie));
|
||||
@@ -149,20 +191,38 @@ public class LocalMetadataProvider : ILocalMetadataProvider
|
||||
public Task<bool> RefreshFallbackMetadata(Artist artist, string artistFolder) =>
|
||||
ApplyMetadataUpdate(artist, _fallbackMetadataProvider.GetFallbackMetadataForArtist(artistFolder));
|
||||
|
||||
public Task<bool> RefreshFallbackMetadata(OtherVideo otherVideo) =>
|
||||
_fallbackMetadataProvider.GetFallbackMetadata(otherVideo).Match(
|
||||
metadata => ApplyMetadataUpdate(otherVideo, metadata),
|
||||
() => Task.FromResult(false));
|
||||
public async Task<bool> RefreshFallbackMetadata(OtherVideo otherVideo)
|
||||
{
|
||||
Option<OtherVideoMetadata> maybeMetadata = _fallbackMetadataProvider.GetFallbackMetadata(otherVideo);
|
||||
foreach (OtherVideoMetadata metadata in maybeMetadata)
|
||||
{
|
||||
return await ApplyMetadataUpdate(otherVideo, metadata);
|
||||
}
|
||||
|
||||
public Task<bool> RefreshFallbackMetadata(Song song) =>
|
||||
_fallbackMetadataProvider.GetFallbackMetadata(song).Match(
|
||||
metadata => ApplyMetadataUpdate(song, metadata),
|
||||
() => Task.FromResult(false));
|
||||
return false;
|
||||
}
|
||||
|
||||
public Task<bool> RefreshFallbackMetadata(MusicVideo musicVideo) =>
|
||||
_fallbackMetadataProvider.GetFallbackMetadata(musicVideo).Match(
|
||||
metadata => ApplyMetadataUpdate(musicVideo, metadata),
|
||||
() => Task.FromResult(false));
|
||||
public async Task<bool> RefreshFallbackMetadata(Song song)
|
||||
{
|
||||
Option<SongMetadata> maybeMetadata = _fallbackMetadataProvider.GetFallbackMetadata(song);
|
||||
foreach (SongMetadata metadata in maybeMetadata)
|
||||
{
|
||||
return await ApplyMetadataUpdate(song, metadata);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public async Task<bool> RefreshFallbackMetadata(MusicVideo musicVideo)
|
||||
{
|
||||
Option<MusicVideoMetadata> maybeMetadata = _fallbackMetadataProvider.GetFallbackMetadata(musicVideo);
|
||||
foreach (MusicVideoMetadata metadata in maybeMetadata)
|
||||
{
|
||||
return await ApplyMetadataUpdate(musicVideo, metadata);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public Task<bool> RefreshFallbackMetadata(Show televisionShow, string showFolder) =>
|
||||
ApplyMetadataUpdate(televisionShow, _fallbackMetadataProvider.GetFallbackMetadataForShow(showFolder));
|
||||
@@ -171,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(
|
||||
@@ -191,8 +250,9 @@ public class LocalMetadataProvider : ILocalMetadataProvider
|
||||
Album = nfo.Album,
|
||||
Title = nfo.Title,
|
||||
Plot = nfo.Plot,
|
||||
Year = GetYear(nfo.Year, string.Empty),
|
||||
ReleaseDate = GetAired(nfo.Year, string.Empty),
|
||||
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(),
|
||||
Genres = nfo.Genres.Map(g => new Genre { Name = g }).ToList(),
|
||||
Tags = nfo.Tags.Map(t => new Tag { Name = t }).ToList(),
|
||||
@@ -705,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)
|
||||
@@ -767,6 +828,10 @@ public class LocalMetadataProvider : ILocalMetadataProvider
|
||||
Option<OtherVideoMetadata> maybeMetadata = Optional(otherVideo.OtherVideoMetadata).Flatten().HeadOrNone();
|
||||
foreach (OtherVideoMetadata existing in maybeMetadata)
|
||||
{
|
||||
existing.ContentRating = metadata.ContentRating;
|
||||
existing.Outline = metadata.Outline;
|
||||
existing.Plot = metadata.Plot;
|
||||
existing.Tagline = metadata.Tagline;
|
||||
existing.Title = metadata.Title;
|
||||
|
||||
if (existing.DateAdded == SystemTime.MinValueUtc)
|
||||
@@ -776,6 +841,9 @@ public class LocalMetadataProvider : ILocalMetadataProvider
|
||||
|
||||
existing.DateUpdated = metadata.DateUpdated;
|
||||
existing.MetadataKind = metadata.MetadataKind;
|
||||
existing.OriginalTitle = metadata.OriginalTitle;
|
||||
existing.ReleaseDate = metadata.ReleaseDate;
|
||||
existing.Year = metadata.Year;
|
||||
existing.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle)
|
||||
? _fallbackMetadataProvider.GetSortTitle(metadata.Title)
|
||||
: metadata.SortTitle;
|
||||
@@ -784,10 +852,70 @@ public class LocalMetadataProvider : ILocalMetadataProvider
|
||||
bool updated = await UpdateMetadataCollections(
|
||||
existing,
|
||||
metadata,
|
||||
(_, _) => Task.FromResult(false),
|
||||
_otherVideoRepository.AddGenre,
|
||||
_otherVideoRepository.AddTag,
|
||||
(_, _) => Task.FromResult(false),
|
||||
(_, _) => Task.FromResult(false));
|
||||
_otherVideoRepository.AddStudio,
|
||||
_otherVideoRepository.AddActor);
|
||||
|
||||
foreach (Director director in existing.Directors
|
||||
.Filter(d => metadata.Directors.All(d2 => d2.Name != d.Name)).ToList())
|
||||
{
|
||||
existing.Directors.Remove(director);
|
||||
if (await _metadataRepository.RemoveDirector(director))
|
||||
{
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (Director director in metadata.Directors
|
||||
.Filter(d => existing.Directors.All(d2 => d2.Name != d.Name)).ToList())
|
||||
{
|
||||
existing.Directors.Add(director);
|
||||
if (await _otherVideoRepository.AddDirector(existing, director))
|
||||
{
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (Writer writer in existing.Writers
|
||||
.Filter(w => metadata.Writers.All(w2 => w2.Name != w.Name)).ToList())
|
||||
{
|
||||
existing.Writers.Remove(writer);
|
||||
if (await _metadataRepository.RemoveWriter(writer))
|
||||
{
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (Writer writer in metadata.Writers
|
||||
.Filter(w => existing.Writers.All(w2 => w2.Name != w.Name)).ToList())
|
||||
{
|
||||
existing.Writers.Add(writer);
|
||||
if (await _otherVideoRepository.AddWriter(existing, writer))
|
||||
{
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (MetadataGuid guid in existing.Guids
|
||||
.Filter(g => metadata.Guids.All(g2 => g2.Guid != g.Guid)).ToList())
|
||||
{
|
||||
existing.Guids.Remove(guid);
|
||||
if (await _metadataRepository.RemoveGuid(guid))
|
||||
{
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (MetadataGuid guid in metadata.Guids
|
||||
.Filter(g => existing.Guids.All(g2 => g2.Guid != g.Guid)).ToList())
|
||||
{
|
||||
existing.Guids.Add(guid);
|
||||
if (await _metadataRepository.AddGuid(existing, guid))
|
||||
{
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
|
||||
return await _metadataRepository.Update(existing) || updated;
|
||||
}
|
||||
@@ -849,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(
|
||||
@@ -900,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(
|
||||
@@ -940,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(
|
||||
@@ -996,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(
|
||||
@@ -1019,7 +1143,9 @@ public class LocalMetadataProvider : ILocalMetadataProvider
|
||||
year = nfo.Year;
|
||||
}
|
||||
|
||||
DateTime releaseDate = new DateTimeOffset(year, 0, 0, 0, 0, 0, TimeSpan.Zero).UtcDateTime;
|
||||
DateTime releaseDate = year > 0
|
||||
? new DateTimeOffset(year, 1, 1, 0, 0, 0, TimeSpan.Zero).UtcDateTime
|
||||
: SystemTime.MinValueUtc;
|
||||
|
||||
foreach (DateTime premiered in nfo.Premiered)
|
||||
{
|
||||
@@ -1066,24 +1192,78 @@ public class LocalMetadataProvider : ILocalMetadataProvider
|
||||
}
|
||||
}
|
||||
|
||||
private static int? GetYear(int? year, string premiered)
|
||||
private async Task<Option<OtherVideoMetadata>> LoadOtherVideoMetadata(string nfoFileName)
|
||||
{
|
||||
if (year is > 1000)
|
||||
try
|
||||
{
|
||||
return year;
|
||||
Either<BaseError, OtherVideoNfo> maybeNfo = await _otherVideoNfoReader.ReadFromFile(nfoFileName);
|
||||
foreach (BaseError error in maybeNfo.LeftToSeq())
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Failed to read OtherVideo nfo metadata from {Path}: {Error}",
|
||||
nfoFileName,
|
||||
error.ToString());
|
||||
|
||||
return None;
|
||||
}
|
||||
|
||||
foreach (OtherVideoNfo nfo in maybeNfo.RightToSeq())
|
||||
{
|
||||
DateTime dateAdded = DateTime.UtcNow;
|
||||
DateTime dateUpdated = File.GetLastWriteTimeUtc(nfoFileName);
|
||||
|
||||
var year = 0;
|
||||
if (nfo.Year > 1000)
|
||||
{
|
||||
year = nfo.Year;
|
||||
}
|
||||
|
||||
DateTime releaseDate = year > 0
|
||||
? new DateTimeOffset(year, 1, 1, 0, 0, 0, TimeSpan.Zero).UtcDateTime
|
||||
: SystemTime.MinValueUtc;
|
||||
|
||||
foreach (DateTime premiered in nfo.Premiered)
|
||||
{
|
||||
if (year == 0)
|
||||
{
|
||||
year = premiered.Year;
|
||||
}
|
||||
|
||||
releaseDate = premiered;
|
||||
}
|
||||
|
||||
return new OtherVideoMetadata
|
||||
{
|
||||
MetadataKind = MetadataKind.Sidecar,
|
||||
DateAdded = dateAdded,
|
||||
DateUpdated = dateUpdated,
|
||||
Title = nfo.Title,
|
||||
SortTitle = nfo.SortTitle,
|
||||
Year = year,
|
||||
ContentRating = nfo.ContentRating,
|
||||
ReleaseDate = releaseDate,
|
||||
Plot = nfo.Plot,
|
||||
Outline = nfo.Outline,
|
||||
Tagline = nfo.Tagline,
|
||||
Genres = nfo.Genres.Map(g => new Genre { Name = g }).ToList(),
|
||||
Tags = nfo.Tags.Map(t => new Tag { Name = t }).ToList(),
|
||||
Studios = nfo.Studios.Map(s => new Studio { Name = s }).ToList(),
|
||||
Actors = Actors(nfo.Actors, dateAdded, dateUpdated),
|
||||
Directors = nfo.Directors.Map(d => new Director { Name = d }).ToList(),
|
||||
Writers = nfo.Writers.Map(w => new Writer { Name = w }).ToList(),
|
||||
Guids = nfo.UniqueIds
|
||||
.Map(id => new MetadataGuid { Guid = $"{id.Type}://{id.Guid}" })
|
||||
.ToList()
|
||||
};
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogInformation(ex, "Failed to read OtherVideo nfo metadata from {Path}", nfoFileName);
|
||||
_client.Notify(ex);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(premiered))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (DateTime.TryParse(premiered, out DateTime parsed))
|
||||
{
|
||||
return parsed.Year;
|
||||
}
|
||||
|
||||
return null;
|
||||
return None;
|
||||
}
|
||||
|
||||
private static int? GetYear(int? year, Option<DateTime> premiered)
|
||||
@@ -1101,18 +1281,6 @@ public class LocalMetadataProvider : ILocalMetadataProvider
|
||||
return null;
|
||||
}
|
||||
|
||||
private static DateTime? GetAired(int? year, string aired)
|
||||
{
|
||||
DateTime? fallback = year is > 1000 ? new DateTime(year.Value, 1, 1) : null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(aired))
|
||||
{
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return DateTime.TryParse(aired, out DateTime parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
private static DateTime? GetAired(int? year, Option<DateTime> aired)
|
||||
{
|
||||
DateTime? fallback = year is > 1000 ? new DateTime(year.Value, 1, 1) : null;
|
||||
|
||||
@@ -53,23 +53,29 @@ public abstract class MediaServerMovieLibraryScanner<TConnectionParameters, TLib
|
||||
{
|
||||
try
|
||||
{
|
||||
Either<BaseError, List<TMovie>> entries = await GetMovieLibraryItems(connectionParameters, library);
|
||||
|
||||
foreach (BaseError error in entries.LeftToSeq())
|
||||
Either<BaseError, int> maybeCount = await CountMovieLibraryItems(connectionParameters, library);
|
||||
foreach (BaseError error in maybeCount.LeftToSeq())
|
||||
{
|
||||
return error;
|
||||
}
|
||||
|
||||
return await ScanLibrary(
|
||||
movieRepository,
|
||||
connectionParameters,
|
||||
library,
|
||||
getLocalPath,
|
||||
ffmpegPath,
|
||||
ffprobePath,
|
||||
entries.RightToSeq().Flatten().ToList(),
|
||||
deepScan,
|
||||
cancellationToken);
|
||||
foreach (int count in maybeCount.RightToSeq())
|
||||
{
|
||||
return await ScanLibrary(
|
||||
movieRepository,
|
||||
connectionParameters,
|
||||
library,
|
||||
getLocalPath,
|
||||
ffmpegPath,
|
||||
ffprobePath,
|
||||
GetMovieLibraryItems(connectionParameters, library),
|
||||
count,
|
||||
deepScan,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
// this won't happen
|
||||
return Unit.Default;
|
||||
}
|
||||
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
|
||||
{
|
||||
@@ -88,21 +94,24 @@ public abstract class MediaServerMovieLibraryScanner<TConnectionParameters, TLib
|
||||
Func<TMovie, string> getLocalPath,
|
||||
string ffmpegPath,
|
||||
string ffprobePath,
|
||||
List<TMovie> movieEntries,
|
||||
IAsyncEnumerable<TMovie> movieEntries,
|
||||
int totalMovieCount,
|
||||
bool deepScan,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var incomingItemIds = new List<string>();
|
||||
List<TEtag> existingMovies = await movieRepository.GetExistingMovies(library);
|
||||
|
||||
var sortedMovies = movieEntries.OrderBy(m => m.MovieMetadata.Head().SortTitle).ToList();
|
||||
foreach (TMovie incoming in sortedMovies)
|
||||
await foreach (TMovie incoming in movieEntries.WithCancellation(cancellationToken))
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return new ScanCanceled();
|
||||
}
|
||||
|
||||
decimal percentCompletion = (decimal)sortedMovies.IndexOf(incoming) / sortedMovies.Count;
|
||||
incomingItemIds.Add(MediaServerItemId(incoming));
|
||||
|
||||
decimal percentCompletion = Math.Clamp((decimal)incomingItemIds.Count / totalMovieCount, 0, 1);
|
||||
await _mediator.Publish(new LibraryScanProgress(library.Id, percentCompletion), cancellationToken);
|
||||
|
||||
string localPath = getLocalPath(incoming);
|
||||
@@ -165,8 +174,7 @@ public abstract class MediaServerMovieLibraryScanner<TConnectionParameters, TLib
|
||||
}
|
||||
|
||||
// trash movies that are no longer present on the media server
|
||||
var fileNotFoundItemIds = existingMovies.Map(m => m.MediaServerItemId)
|
||||
.Except(movieEntries.Map(MediaServerItemId)).ToList();
|
||||
var fileNotFoundItemIds = existingMovies.Map(m => m.MediaServerItemId).Except(incomingItemIds).ToList();
|
||||
List<int> ids = await movieRepository.FlagFileNotFound(library, fileNotFoundItemIds);
|
||||
await _searchIndex.RebuildItems(_searchRepository, ids);
|
||||
|
||||
@@ -178,7 +186,11 @@ public abstract class MediaServerMovieLibraryScanner<TConnectionParameters, TLib
|
||||
protected abstract string MediaServerItemId(TMovie movie);
|
||||
protected abstract string MediaServerEtag(TMovie movie);
|
||||
|
||||
protected abstract Task<Either<BaseError, List<TMovie>>> GetMovieLibraryItems(
|
||||
protected abstract Task<Either<BaseError, int>> CountMovieLibraryItems(
|
||||
TConnectionParameters connectionParameters,
|
||||
TLibrary library);
|
||||
|
||||
protected abstract IAsyncEnumerable<TMovie> GetMovieLibraryItems(
|
||||
TConnectionParameters connectionParameters,
|
||||
TLibrary library);
|
||||
|
||||
|
||||
@@ -56,23 +56,31 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
|
||||
{
|
||||
try
|
||||
{
|
||||
Either<BaseError, List<TShow>> entries = await GetShowLibraryItems(connectionParameters, library);
|
||||
|
||||
foreach (BaseError error in entries.LeftToSeq())
|
||||
Either<BaseError, int> maybeCount = await CountShowLibraryItems(connectionParameters, library);
|
||||
foreach (BaseError error in maybeCount.LeftToSeq())
|
||||
{
|
||||
return error;
|
||||
}
|
||||
|
||||
return await ScanLibrary(
|
||||
televisionRepository,
|
||||
connectionParameters,
|
||||
library,
|
||||
getLocalPath,
|
||||
ffmpegPath,
|
||||
ffprobePath,
|
||||
entries.RightToSeq().Flatten().ToList(),
|
||||
deepScan,
|
||||
cancellationToken);
|
||||
foreach (int count in maybeCount.RightToSeq())
|
||||
{
|
||||
_logger.LogDebug("Library {Library} contains {Count} shows", library.Name, count);
|
||||
|
||||
return await ScanLibrary(
|
||||
televisionRepository,
|
||||
connectionParameters,
|
||||
library,
|
||||
getLocalPath,
|
||||
ffmpegPath,
|
||||
ffprobePath,
|
||||
GetShowLibraryItems(connectionParameters, library),
|
||||
count,
|
||||
deepScan,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
// this won't happen
|
||||
return Unit.Default;
|
||||
}
|
||||
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
|
||||
{
|
||||
@@ -84,7 +92,11 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
|
||||
}
|
||||
}
|
||||
|
||||
protected abstract Task<Either<BaseError, List<TShow>>> GetShowLibraryItems(
|
||||
protected abstract Task<Either<BaseError, int>> CountShowLibraryItems(
|
||||
TConnectionParameters connectionParameters,
|
||||
TLibrary library);
|
||||
|
||||
protected abstract IAsyncEnumerable<TShow> GetShowLibraryItems(
|
||||
TConnectionParameters connectionParameters,
|
||||
TLibrary library);
|
||||
|
||||
@@ -102,21 +114,24 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
|
||||
Func<TEpisode, string> getLocalPath,
|
||||
string ffmpegPath,
|
||||
string ffprobePath,
|
||||
List<TShow> showEntries,
|
||||
IAsyncEnumerable<TShow> showEntries,
|
||||
int totalShowCount,
|
||||
bool deepScan,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var incomingItemIds = new List<string>();
|
||||
List<TEtag> existingShows = await televisionRepository.GetExistingShows(library);
|
||||
|
||||
var sortedShows = showEntries.OrderBy(s => s.ShowMetadata.Head().SortTitle).ToList();
|
||||
foreach (TShow incoming in showEntries)
|
||||
await foreach (TShow incoming in showEntries.WithCancellation(cancellationToken))
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return new ScanCanceled();
|
||||
}
|
||||
|
||||
decimal percentCompletion = (decimal)sortedShows.IndexOf(incoming) / sortedShows.Count;
|
||||
incomingItemIds.Add(MediaServerItemId(incoming));
|
||||
|
||||
decimal percentCompletion = Math.Clamp((decimal)incomingItemIds.Count / totalShowCount, 0, 1);
|
||||
await _mediator.Publish(new LibraryScanProgress(library.Id, percentCompletion), cancellationToken);
|
||||
|
||||
Either<BaseError, MediaItemScanResult<TShow>> maybeShow = await televisionRepository
|
||||
@@ -138,16 +153,23 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
|
||||
|
||||
foreach (MediaItemScanResult<TShow> result in maybeShow.RightToSeq())
|
||||
{
|
||||
Either<BaseError, List<TSeason>> entries = await GetSeasonLibraryItems(
|
||||
library,
|
||||
Either<BaseError, int> maybeCount = await CountSeasonLibraryItems(
|
||||
connectionParameters,
|
||||
library,
|
||||
result.Item);
|
||||
|
||||
foreach (BaseError error in entries.LeftToSeq())
|
||||
foreach (BaseError error in maybeCount.LeftToSeq())
|
||||
{
|
||||
return error;
|
||||
}
|
||||
|
||||
foreach (int count in maybeCount.RightToSeq())
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Show {Title} contains {Count} seasons",
|
||||
result.Item.ShowMetadata.Head().Title,
|
||||
count);
|
||||
}
|
||||
|
||||
Either<BaseError, Unit> scanResult = await ScanSeasons(
|
||||
televisionRepository,
|
||||
library,
|
||||
@@ -156,7 +178,7 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
|
||||
connectionParameters,
|
||||
ffmpegPath,
|
||||
ffprobePath,
|
||||
entries.RightToSeq().Flatten().ToList(),
|
||||
GetSeasonLibraryItems(library, connectionParameters, result.Item),
|
||||
deepScan,
|
||||
cancellationToken);
|
||||
|
||||
@@ -175,8 +197,7 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
|
||||
}
|
||||
|
||||
// trash shows that are no longer present on the media server
|
||||
var fileNotFoundItemIds = existingShows.Map(s => s.MediaServerItemId)
|
||||
.Except(showEntries.Map(MediaServerItemId)).ToList();
|
||||
var fileNotFoundItemIds = existingShows.Map(s => s.MediaServerItemId).Except(incomingItemIds).ToList();
|
||||
List<int> ids = await televisionRepository.FlagFileNotFoundShows(library, fileNotFoundItemIds);
|
||||
await _searchIndex.RebuildItems(_searchRepository, ids);
|
||||
|
||||
@@ -185,14 +206,25 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
protected abstract Task<Either<BaseError, List<TSeason>>> GetSeasonLibraryItems(
|
||||
protected abstract Task<Either<BaseError, int>> CountSeasonLibraryItems(
|
||||
TConnectionParameters connectionParameters,
|
||||
TLibrary library,
|
||||
TShow show);
|
||||
|
||||
protected abstract IAsyncEnumerable<TSeason> GetSeasonLibraryItems(
|
||||
TLibrary library,
|
||||
TConnectionParameters connectionParameters,
|
||||
TShow show);
|
||||
|
||||
protected abstract Task<Either<BaseError, List<TEpisode>>> GetEpisodeLibraryItems(
|
||||
protected abstract Task<Either<BaseError, int>> CountEpisodeLibraryItems(
|
||||
TConnectionParameters connectionParameters,
|
||||
TLibrary library,
|
||||
TSeason season);
|
||||
|
||||
protected abstract IAsyncEnumerable<TEpisode> GetEpisodeLibraryItems(
|
||||
TLibrary library,
|
||||
TConnectionParameters connectionParameters,
|
||||
TShow show,
|
||||
TSeason season);
|
||||
|
||||
protected abstract Task<Option<ShowMetadata>> GetFullMetadata(
|
||||
@@ -236,14 +268,14 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
|
||||
TConnectionParameters connectionParameters,
|
||||
string ffmpegPath,
|
||||
string ffprobePath,
|
||||
List<TSeason> seasonEntries,
|
||||
IAsyncEnumerable<TSeason> seasonEntries,
|
||||
bool deepScan,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var incomingItemIds = new List<string>();
|
||||
List<TEtag> existingSeasons = await televisionRepository.GetExistingSeasons(library, show);
|
||||
|
||||
var sortedSeasons = seasonEntries.OrderBy(s => s.SeasonNumber).ToList();
|
||||
foreach (TSeason incoming in sortedSeasons)
|
||||
await foreach (TSeason incoming in seasonEntries.WithCancellation(cancellationToken))
|
||||
{
|
||||
incoming.ShowId = show.Id;
|
||||
|
||||
@@ -252,6 +284,8 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
|
||||
return new ScanCanceled();
|
||||
}
|
||||
|
||||
incomingItemIds.Add(MediaServerItemId(incoming));
|
||||
|
||||
Either<BaseError, MediaItemScanResult<TSeason>> maybeSeason = await televisionRepository
|
||||
.GetOrAdd(library, incoming)
|
||||
.BindT(existing => UpdateMetadata(connectionParameters, library, existing, incoming, deepScan));
|
||||
@@ -272,16 +306,24 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
|
||||
|
||||
foreach (MediaItemScanResult<TSeason> result in maybeSeason.RightToSeq())
|
||||
{
|
||||
Either<BaseError, List<TEpisode>> entries = await GetEpisodeLibraryItems(
|
||||
library,
|
||||
Either<BaseError, int> maybeCount = await CountEpisodeLibraryItems(
|
||||
connectionParameters,
|
||||
library,
|
||||
result.Item);
|
||||
|
||||
foreach (BaseError error in entries.LeftToSeq())
|
||||
foreach (BaseError error in maybeCount.LeftToSeq())
|
||||
{
|
||||
return error;
|
||||
}
|
||||
|
||||
foreach (int count in maybeCount.RightToSeq())
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Show {Title} season {Season} contains {Count} episodes",
|
||||
show.ShowMetadata.Head().Title,
|
||||
result.Item.SeasonNumber,
|
||||
count);
|
||||
}
|
||||
|
||||
Either<BaseError, Unit> scanResult = await ScanEpisodes(
|
||||
televisionRepository,
|
||||
library,
|
||||
@@ -291,7 +333,7 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
|
||||
connectionParameters,
|
||||
ffmpegPath,
|
||||
ffprobePath,
|
||||
entries.RightToSeq().Flatten().ToList(),
|
||||
GetEpisodeLibraryItems(library, connectionParameters, show, result.Item),
|
||||
deepScan,
|
||||
cancellationToken);
|
||||
|
||||
@@ -312,8 +354,7 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
|
||||
}
|
||||
|
||||
// trash seasons that are no longer present on the media server
|
||||
var fileNotFoundItemIds = existingSeasons.Map(s => s.MediaServerItemId)
|
||||
.Except(seasonEntries.Map(MediaServerItemId)).ToList();
|
||||
var fileNotFoundItemIds = existingSeasons.Map(s => s.MediaServerItemId).Except(incomingItemIds).ToList();
|
||||
List<int> ids = await televisionRepository.FlagFileNotFoundSeasons(library, fileNotFoundItemIds);
|
||||
await _searchIndex.RebuildItems(_searchRepository, ids);
|
||||
|
||||
@@ -329,20 +370,22 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
|
||||
TConnectionParameters connectionParameters,
|
||||
string ffmpegPath,
|
||||
string ffprobePath,
|
||||
List<TEpisode> episodeEntries,
|
||||
IAsyncEnumerable<TEpisode> episodeEntries,
|
||||
bool deepScan,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var incomingItemIds = new List<string>();
|
||||
List<TEtag> existingEpisodes = await televisionRepository.GetExistingEpisodes(library, season);
|
||||
|
||||
var sortedEpisodes = episodeEntries.OrderBy(s => s.EpisodeMetadata.Head().EpisodeNumber).ToList();
|
||||
foreach (TEpisode incoming in sortedEpisodes)
|
||||
await foreach (TEpisode incoming in episodeEntries.WithCancellation(cancellationToken))
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return new ScanCanceled();
|
||||
}
|
||||
|
||||
incomingItemIds.Add(MediaServerItemId(incoming));
|
||||
|
||||
string localPath = getLocalPath(incoming);
|
||||
if (await ShouldScanItem(
|
||||
televisionRepository,
|
||||
@@ -414,8 +457,7 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
|
||||
}
|
||||
|
||||
// trash episodes that are no longer present on the media server
|
||||
var fileNotFoundItemIds = existingEpisodes.Map(m => m.MediaServerItemId)
|
||||
.Except(episodeEntries.Map(MediaServerItemId)).ToList();
|
||||
var fileNotFoundItemIds = existingEpisodes.Map(m => m.MediaServerItemId).Except(incomingItemIds).ToList();
|
||||
List<int> ids = await televisionRepository.FlagFileNotFoundEpisodes(library, fileNotFoundItemIds);
|
||||
await _searchIndex.RebuildItems(_searchRepository, ids);
|
||||
|
||||
|
||||
@@ -2,22 +2,43 @@
|
||||
using Bugsnag;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Metadata.Nfo;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.IO;
|
||||
|
||||
namespace ErsatzTV.Core.Metadata.Nfo;
|
||||
|
||||
public class ArtistNfoReader : NfoReader<ArtistNfo>, IArtistNfoReader
|
||||
{
|
||||
private readonly IClient _client;
|
||||
private readonly ILogger<ArtistNfoReader> _logger;
|
||||
|
||||
public ArtistNfoReader(IClient client) => _client = client;
|
||||
|
||||
public async Task<Either<BaseError, ArtistNfo>> Read(Stream input)
|
||||
public ArtistNfoReader(
|
||||
RecyclableMemoryStreamManager recyclableMemoryStreamManager,
|
||||
IClient client,
|
||||
ILogger<ArtistNfoReader> logger)
|
||||
: base(recyclableMemoryStreamManager, logger)
|
||||
{
|
||||
_client = client;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, ArtistNfo>> ReadFromFile(string fileName)
|
||||
{
|
||||
// ReSharper disable once ConvertToUsingDeclaration
|
||||
await using (Stream s = await SanitizedStreamForFile(fileName))
|
||||
{
|
||||
return await Read(s);
|
||||
}
|
||||
}
|
||||
|
||||
internal async Task<Either<BaseError, ArtistNfo>> Read(Stream input)
|
||||
{
|
||||
ArtistNfo nfo = null;
|
||||
|
||||
try
|
||||
{
|
||||
var settings = new XmlReaderSettings { Async = true, ConformanceLevel = ConformanceLevel.Fragment };
|
||||
using var reader = XmlReader.Create(input, settings);
|
||||
ArtistNfo nfo = null;
|
||||
var done = false;
|
||||
|
||||
while (!done && await reader.ReadAsync())
|
||||
@@ -74,6 +95,11 @@ public class ArtistNfoReader : NfoReader<ArtistNfo>, IArtistNfoReader
|
||||
|
||||
return Optional(nfo).ToEither((BaseError)new FailedToReadNfo());
|
||||
}
|
||||
catch (XmlException)
|
||||
{
|
||||
_logger.LogWarning("Invalid XML detected; returning incomplete metadata");
|
||||
return Optional(nfo).ToEither((BaseError)new FailedToReadNfo());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_client.Notify(ex);
|
||||
|
||||
@@ -2,23 +2,42 @@
|
||||
using Bugsnag;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Metadata.Nfo;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.IO;
|
||||
|
||||
namespace ErsatzTV.Core.Metadata.Nfo;
|
||||
|
||||
public class EpisodeNfoReader : NfoReader<TvShowEpisodeNfo>, IEpisodeNfoReader
|
||||
{
|
||||
private readonly IClient _client;
|
||||
private readonly ILogger<EpisodeNfoReader> _logger;
|
||||
|
||||
public EpisodeNfoReader(IClient client) => _client = client;
|
||||
|
||||
public async Task<Either<BaseError, List<TvShowEpisodeNfo>>> Read(Stream input)
|
||||
public EpisodeNfoReader(
|
||||
RecyclableMemoryStreamManager recyclableMemoryStreamManager,
|
||||
IClient client,
|
||||
ILogger<EpisodeNfoReader> logger)
|
||||
: base(recyclableMemoryStreamManager, logger)
|
||||
{
|
||||
_client = client;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, List<TvShowEpisodeNfo>>> ReadFromFile(string fileName)
|
||||
{
|
||||
// ReSharper disable once ConvertToUsingDeclaration
|
||||
await using (Stream s = await SanitizedStreamForFile(fileName))
|
||||
{
|
||||
return await Read(s);
|
||||
}
|
||||
}
|
||||
|
||||
internal async Task<Either<BaseError, List<TvShowEpisodeNfo>>> Read(Stream input)
|
||||
{
|
||||
var result = new List<TvShowEpisodeNfo>();
|
||||
|
||||
try
|
||||
{
|
||||
var result = new List<TvShowEpisodeNfo>();
|
||||
|
||||
var settings = new XmlReaderSettings { Async = true, ConformanceLevel = ConformanceLevel.Fragment };
|
||||
using var reader = XmlReader.Create(input, settings);
|
||||
using var reader = XmlReader.Create(input, Settings);
|
||||
TvShowEpisodeNfo nfo = null;
|
||||
|
||||
while (await reader.ReadAsync())
|
||||
@@ -36,6 +55,8 @@ public class EpisodeNfoReader : NfoReader<TvShowEpisodeNfo>, IEpisodeNfoReader
|
||||
Writers = new List<string>(),
|
||||
Directors = new List<string>()
|
||||
};
|
||||
// immediately add so we have something to return if we encounter invalid characters
|
||||
result.Add(nfo);
|
||||
break;
|
||||
case "title":
|
||||
await ReadStringContent(reader, nfo, (episode, title) => episode.Title = title);
|
||||
@@ -87,25 +108,17 @@ public class EpisodeNfoReader : NfoReader<TvShowEpisodeNfo>, IEpisodeNfoReader
|
||||
break;
|
||||
}
|
||||
|
||||
break;
|
||||
case XmlNodeType.EndElement:
|
||||
switch (reader.Name.ToLowerInvariant())
|
||||
{
|
||||
case "episodedetails":
|
||||
if (nfo != null)
|
||||
{
|
||||
result.Add(nfo);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (XmlException)
|
||||
{
|
||||
_logger.LogWarning("Invalid XML detected; returning incomplete metadata");
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_client.Notify(ex);
|
||||
|
||||
@@ -2,22 +2,43 @@
|
||||
using Bugsnag;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Metadata.Nfo;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.IO;
|
||||
|
||||
namespace ErsatzTV.Core.Metadata.Nfo;
|
||||
|
||||
public class MovieNfoReader : NfoReader<MovieNfo>, IMovieNfoReader
|
||||
{
|
||||
private readonly IClient _client;
|
||||
private readonly ILogger<MovieNfoReader> _logger;
|
||||
|
||||
public MovieNfoReader(IClient client) => _client = client;
|
||||
|
||||
public async Task<Either<BaseError, MovieNfo>> Read(Stream input)
|
||||
public MovieNfoReader(
|
||||
RecyclableMemoryStreamManager recyclableMemoryStreamManager,
|
||||
IClient client,
|
||||
ILogger<MovieNfoReader> logger)
|
||||
: base(recyclableMemoryStreamManager, logger)
|
||||
{
|
||||
_client = client;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, MovieNfo>> ReadFromFile(string fileName)
|
||||
{
|
||||
// ReSharper disable once ConvertToUsingDeclaration
|
||||
await using (Stream s = await SanitizedStreamForFile(fileName))
|
||||
{
|
||||
return await Read(s);
|
||||
}
|
||||
}
|
||||
|
||||
internal async Task<Either<BaseError, MovieNfo>> Read(Stream input)
|
||||
{
|
||||
MovieNfo nfo = null;
|
||||
|
||||
try
|
||||
{
|
||||
var settings = new XmlReaderSettings { Async = true, ConformanceLevel = ConformanceLevel.Fragment };
|
||||
using var reader = XmlReader.Create(input, settings);
|
||||
MovieNfo nfo = null;
|
||||
var done = false;
|
||||
|
||||
while (!done && await reader.ReadAsync())
|
||||
@@ -105,6 +126,11 @@ public class MovieNfoReader : NfoReader<MovieNfo>, IMovieNfoReader
|
||||
|
||||
return Optional(nfo).ToEither((BaseError)new FailedToReadNfo());
|
||||
}
|
||||
catch (XmlException)
|
||||
{
|
||||
_logger.LogWarning("Invalid XML detected; returning incomplete metadata");
|
||||
return Optional(nfo).ToEither((BaseError)new FailedToReadNfo());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_client.Notify(ex);
|
||||
|
||||
@@ -17,6 +17,12 @@ public class MusicVideoNfo
|
||||
[XmlElement("plot")]
|
||||
public string Plot { get; set; }
|
||||
|
||||
[XmlElement("track")]
|
||||
public int Track { get; set; }
|
||||
|
||||
[XmlElement("aired")]
|
||||
public Option<DateTime> Aired { get; set; }
|
||||
|
||||
[XmlElement("year")]
|
||||
public int Year { get; set; }
|
||||
|
||||
|
||||
@@ -2,22 +2,43 @@
|
||||
using Bugsnag;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Metadata.Nfo;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.IO;
|
||||
|
||||
namespace ErsatzTV.Core.Metadata.Nfo;
|
||||
|
||||
public class MusicVideoNfoReader : NfoReader<MusicVideoNfo>, IMusicVideoNfoReader
|
||||
{
|
||||
private readonly IClient _client;
|
||||
private readonly ILogger<MusicVideoNfoReader> _logger;
|
||||
|
||||
public MusicVideoNfoReader(IClient client) => _client = client;
|
||||
|
||||
public async Task<Either<BaseError, MusicVideoNfo>> Read(Stream input)
|
||||
public MusicVideoNfoReader(
|
||||
RecyclableMemoryStreamManager recyclableMemoryStreamManager,
|
||||
IClient client,
|
||||
ILogger<MusicVideoNfoReader> logger)
|
||||
: base(recyclableMemoryStreamManager, logger)
|
||||
{
|
||||
_client = client;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, MusicVideoNfo>> ReadFromFile(string fileName)
|
||||
{
|
||||
// ReSharper disable once ConvertToUsingDeclaration
|
||||
await using (Stream s = await SanitizedStreamForFile(fileName))
|
||||
{
|
||||
return await Read(s);
|
||||
}
|
||||
}
|
||||
|
||||
internal async Task<Either<BaseError, MusicVideoNfo>> Read(Stream input)
|
||||
{
|
||||
MusicVideoNfo nfo = null;
|
||||
|
||||
try
|
||||
{
|
||||
var settings = new XmlReaderSettings { Async = true, ConformanceLevel = ConformanceLevel.Fragment };
|
||||
using var reader = XmlReader.Create(input, settings);
|
||||
MusicVideoNfo nfo = null;
|
||||
var done = false;
|
||||
|
||||
while (!done && await reader.ReadAsync())
|
||||
@@ -51,9 +72,15 @@ public class MusicVideoNfoReader : NfoReader<MusicVideoNfo>, IMusicVideoNfoReade
|
||||
case "plot":
|
||||
await ReadStringContent(reader, nfo, (musicVideo, plot) => musicVideo.Plot = plot);
|
||||
break;
|
||||
case "track":
|
||||
await ReadIntContent(reader, nfo, (musicVideo, track) => musicVideo.Track = track);
|
||||
break;
|
||||
case "year":
|
||||
await ReadIntContent(reader, nfo, (musicVideo, year) => musicVideo.Year = year);
|
||||
break;
|
||||
case "aired":
|
||||
await ReadDateTimeContent(reader, nfo, (show, aired) => show.Aired = aired);
|
||||
break;
|
||||
case "genre":
|
||||
await ReadStringContent(
|
||||
reader,
|
||||
@@ -84,6 +111,11 @@ public class MusicVideoNfoReader : NfoReader<MusicVideoNfo>, IMusicVideoNfoReade
|
||||
|
||||
return Optional(nfo).ToEither((BaseError)new FailedToReadNfo());
|
||||
}
|
||||
catch (XmlException)
|
||||
{
|
||||
_logger.LogWarning("Invalid XML detected; returning incomplete metadata");
|
||||
return Optional(nfo).ToEither((BaseError)new FailedToReadNfo());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_client.Notify(ex);
|
||||
|
||||
@@ -1,83 +1,166 @@
|
||||
using System.Xml;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Xml;
|
||||
using System.Xml.Linq;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.IO;
|
||||
|
||||
namespace ErsatzTV.Core.Metadata.Nfo;
|
||||
|
||||
public abstract class NfoReader<T>
|
||||
{
|
||||
protected static async Task ReadStringContent(XmlReader reader, T nfo, Action<T, string> action)
|
||||
{
|
||||
if (nfo != null)
|
||||
private static readonly byte[] Buffer = new byte[8 * 1024 * 1024];
|
||||
private static readonly Regex Pattern = new(@"[\p{C}-[\r\n\t]]+");
|
||||
|
||||
protected static readonly XmlReaderSettings Settings =
|
||||
new()
|
||||
{
|
||||
string result = await reader.ReadElementContentAsStringAsync();
|
||||
action(nfo, result);
|
||||
Async = true,
|
||||
ConformanceLevel = ConformanceLevel.Fragment,
|
||||
ValidationType = ValidationType.None,
|
||||
CheckCharacters = false,
|
||||
IgnoreProcessingInstructions = true,
|
||||
IgnoreComments = true
|
||||
};
|
||||
|
||||
private readonly ILogger _logger;
|
||||
|
||||
private readonly RecyclableMemoryStreamManager _recyclableMemoryStreamManager;
|
||||
|
||||
protected NfoReader(RecyclableMemoryStreamManager recyclableMemoryStreamManager, ILogger logger)
|
||||
{
|
||||
_recyclableMemoryStreamManager = recyclableMemoryStreamManager;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected async Task<Stream> SanitizedStreamForFile(string fileName)
|
||||
{
|
||||
using (var fs = new FileStream(fileName, FileMode.Open, FileAccess.Read, FileShare.Read, Buffer.Length, true))
|
||||
{
|
||||
while (await fs.ReadAsync(Buffer) > 0)
|
||||
{
|
||||
// read the file
|
||||
}
|
||||
|
||||
string text = Encoding.UTF8.GetString(Buffer);
|
||||
// trim BOM and zero width space, replace controls with replacement character
|
||||
string stripped = Pattern.Replace(text.Trim('\uFEFF', '\u200B'), "\ufffd");
|
||||
|
||||
MemoryStream ms = _recyclableMemoryStreamManager.GetStream();
|
||||
await ms.WriteAsync(Encoding.UTF8.GetBytes(stripped));
|
||||
ms.Position = 0;
|
||||
return ms;
|
||||
}
|
||||
}
|
||||
|
||||
protected static async Task ReadIntContent(XmlReader reader, T nfo, Action<T, int> action)
|
||||
protected async Task ReadStringContent(XmlReader reader, T nfo, Action<T, string> action)
|
||||
{
|
||||
if (nfo != null && int.TryParse(await reader.ReadElementContentAsStringAsync(), out int result))
|
||||
try
|
||||
{
|
||||
action(nfo, result);
|
||||
if (nfo != null)
|
||||
{
|
||||
string result = await reader.ReadElementContentAsStringAsync();
|
||||
action(nfo, result);
|
||||
}
|
||||
}
|
||||
catch (XmlException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Error reading string content from NFO {ElementName}", reader.Name);
|
||||
}
|
||||
}
|
||||
|
||||
protected static async Task ReadDateTimeContent(XmlReader reader, T nfo, Action<T, DateTime> action)
|
||||
protected async Task ReadIntContent(XmlReader reader, T nfo, Action<T, int> action)
|
||||
{
|
||||
if (nfo != null && DateTime.TryParse(await reader.ReadElementContentAsStringAsync(), out DateTime result))
|
||||
try
|
||||
{
|
||||
action(nfo, result);
|
||||
if (nfo != null && int.TryParse(await reader.ReadElementContentAsStringAsync(), out int result))
|
||||
{
|
||||
action(nfo, result);
|
||||
}
|
||||
}
|
||||
catch (XmlException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Error reading int content from NFO {ElementName}", reader.Name);
|
||||
}
|
||||
}
|
||||
|
||||
protected static void ReadActor(XmlReader reader, T nfo, Action<T, ActorNfo> action)
|
||||
protected async Task ReadDateTimeContent(XmlReader reader, T nfo, Action<T, DateTime> action)
|
||||
{
|
||||
if (nfo != null)
|
||||
try
|
||||
{
|
||||
var actor = new ActorNfo();
|
||||
var element = (XElement)XNode.ReadFrom(reader);
|
||||
|
||||
XElement name = element.Element("name");
|
||||
if (name != null)
|
||||
if (nfo != null && DateTime.TryParse(await reader.ReadElementContentAsStringAsync(), out DateTime result))
|
||||
{
|
||||
actor.Name = name.Value;
|
||||
action(nfo, result);
|
||||
}
|
||||
|
||||
XElement role = element.Element("role");
|
||||
if (role != null)
|
||||
{
|
||||
actor.Role = role.Value;
|
||||
}
|
||||
|
||||
XElement order = element.Element("order");
|
||||
if (order != null && int.TryParse(order.Value, out int orderValue))
|
||||
{
|
||||
actor.Order = orderValue;
|
||||
}
|
||||
|
||||
XElement thumb = element.Element("thumb");
|
||||
if (thumb != null)
|
||||
{
|
||||
actor.Thumb = thumb.Value;
|
||||
}
|
||||
|
||||
action(nfo, actor);
|
||||
}
|
||||
catch (XmlException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Error reading date content from NFO {ElementName}", reader.Name);
|
||||
}
|
||||
}
|
||||
|
||||
protected static async Task ReadUniqueId(XmlReader reader, T nfo, Action<T, UniqueIdNfo> action)
|
||||
protected void ReadActor(XmlReader reader, T nfo, Action<T, ActorNfo> action)
|
||||
{
|
||||
if (nfo != null)
|
||||
try
|
||||
{
|
||||
var uniqueId = new UniqueIdNfo();
|
||||
reader.MoveToAttribute("default");
|
||||
uniqueId.Default = bool.TryParse(reader.Value, out bool def) && def;
|
||||
reader.MoveToAttribute("type");
|
||||
uniqueId.Type = reader.Value;
|
||||
reader.MoveToElement();
|
||||
uniqueId.Guid = await reader.ReadElementContentAsStringAsync();
|
||||
if (nfo != null)
|
||||
{
|
||||
var actor = new ActorNfo();
|
||||
var element = (XElement)XNode.ReadFrom(reader);
|
||||
|
||||
action(nfo, uniqueId);
|
||||
XElement name = element.Element("name");
|
||||
if (name != null)
|
||||
{
|
||||
actor.Name = name.Value;
|
||||
}
|
||||
|
||||
XElement role = element.Element("role");
|
||||
if (role != null)
|
||||
{
|
||||
actor.Role = role.Value;
|
||||
}
|
||||
|
||||
XElement order = element.Element("order");
|
||||
if (order != null && int.TryParse(order.Value, out int orderValue))
|
||||
{
|
||||
actor.Order = orderValue;
|
||||
}
|
||||
|
||||
XElement thumb = element.Element("thumb");
|
||||
if (thumb != null)
|
||||
{
|
||||
actor.Thumb = thumb.Value;
|
||||
}
|
||||
|
||||
action(nfo, actor);
|
||||
}
|
||||
}
|
||||
catch (XmlException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Error reading actor content from NFO {ElementName}", reader.Name);
|
||||
}
|
||||
}
|
||||
|
||||
protected async Task ReadUniqueId(XmlReader reader, T nfo, Action<T, UniqueIdNfo> action)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (nfo != null)
|
||||
{
|
||||
var uniqueId = new UniqueIdNfo();
|
||||
reader.MoveToAttribute("default");
|
||||
uniqueId.Default = bool.TryParse(reader.Value, out bool def) && def;
|
||||
reader.MoveToAttribute("type");
|
||||
uniqueId.Type = reader.Value;
|
||||
reader.MoveToElement();
|
||||
uniqueId.Guid = await reader.ReadElementContentAsStringAsync();
|
||||
|
||||
action(nfo, uniqueId);
|
||||
}
|
||||
}
|
||||
catch (XmlException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Error reading uniqueid content from NFO {ElementName}", reader.Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
52
ErsatzTV.Core/Metadata/Nfo/OtherVideoNfo.cs
Normal file
52
ErsatzTV.Core/Metadata/Nfo/OtherVideoNfo.cs
Normal file
@@ -0,0 +1,52 @@
|
||||
using System.Xml.Serialization;
|
||||
|
||||
namespace ErsatzTV.Core.Metadata.Nfo;
|
||||
|
||||
[XmlRoot("movie")]
|
||||
public class OtherVideoNfo
|
||||
{
|
||||
[XmlElement("title")]
|
||||
public string Title { get; set; }
|
||||
|
||||
[XmlElement("sorttitle")]
|
||||
public string SortTitle { get; set; }
|
||||
|
||||
[XmlElement("outline")]
|
||||
public string Outline { get; set; }
|
||||
|
||||
[XmlElement("year")]
|
||||
public int Year { get; set; }
|
||||
|
||||
[XmlElement("mpaa")]
|
||||
public string ContentRating { get; set; }
|
||||
|
||||
[XmlElement("premiered")]
|
||||
public Option<DateTime> Premiered { get; set; }
|
||||
|
||||
[XmlElement("plot")]
|
||||
public string Plot { get; set; }
|
||||
|
||||
[XmlElement("tagline")]
|
||||
public string Tagline { get; set; }
|
||||
|
||||
[XmlElement("genre")]
|
||||
public List<string> Genres { get; set; }
|
||||
|
||||
[XmlElement("tag")]
|
||||
public List<string> Tags { get; set; }
|
||||
|
||||
[XmlElement("studio")]
|
||||
public List<string> Studios { get; set; }
|
||||
|
||||
[XmlElement("actor")]
|
||||
public List<ActorNfo> Actors { get; set; }
|
||||
|
||||
[XmlElement("credits")]
|
||||
public List<string> Writers { get; set; }
|
||||
|
||||
[XmlElement("director")]
|
||||
public List<string> Directors { get; set; }
|
||||
|
||||
[XmlElement("uniqueid")]
|
||||
public List<UniqueIdNfo> UniqueIds { get; set; }
|
||||
}
|
||||
140
ErsatzTV.Core/Metadata/Nfo/OtherVideoNfoReader.cs
Normal file
140
ErsatzTV.Core/Metadata/Nfo/OtherVideoNfoReader.cs
Normal file
@@ -0,0 +1,140 @@
|
||||
using System.Xml;
|
||||
using Bugsnag;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Metadata.Nfo;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.IO;
|
||||
|
||||
namespace ErsatzTV.Core.Metadata.Nfo;
|
||||
|
||||
public class OtherVideoNfoReader : NfoReader<OtherVideoNfo>, IOtherVideoNfoReader
|
||||
{
|
||||
private readonly IClient _client;
|
||||
private readonly ILogger<OtherVideoNfoReader> _logger;
|
||||
|
||||
public OtherVideoNfoReader(
|
||||
RecyclableMemoryStreamManager recyclableMemoryStreamManager,
|
||||
IClient client,
|
||||
ILogger<OtherVideoNfoReader> logger)
|
||||
: base(recyclableMemoryStreamManager, logger)
|
||||
{
|
||||
_client = client;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, OtherVideoNfo>> ReadFromFile(string fileName)
|
||||
{
|
||||
// ReSharper disable once ConvertToUsingDeclaration
|
||||
await using (Stream s = await SanitizedStreamForFile(fileName))
|
||||
{
|
||||
return await Read(s);
|
||||
}
|
||||
}
|
||||
|
||||
internal async Task<Either<BaseError, OtherVideoNfo>> Read(Stream input)
|
||||
{
|
||||
OtherVideoNfo nfo = null;
|
||||
|
||||
try
|
||||
{
|
||||
var settings = new XmlReaderSettings { Async = true, ConformanceLevel = ConformanceLevel.Fragment };
|
||||
using var reader = XmlReader.Create(input, settings);
|
||||
var done = false;
|
||||
|
||||
while (!done && await reader.ReadAsync())
|
||||
{
|
||||
switch (reader.NodeType)
|
||||
{
|
||||
case XmlNodeType.Element:
|
||||
switch (reader.Name.ToLowerInvariant())
|
||||
{
|
||||
case "movie":
|
||||
nfo = new OtherVideoNfo
|
||||
{
|
||||
Genres = new List<string>(),
|
||||
Tags = new List<string>(),
|
||||
Studios = new List<string>(),
|
||||
Actors = new List<ActorNfo>(),
|
||||
Writers = new List<string>(),
|
||||
Directors = new List<string>(),
|
||||
UniqueIds = new List<UniqueIdNfo>()
|
||||
};
|
||||
break;
|
||||
case "title":
|
||||
await ReadStringContent(reader, nfo, (movie, title) => movie.Title = title);
|
||||
break;
|
||||
case "sorttitle":
|
||||
await ReadStringContent(reader, nfo, (movie, sortTitle) => movie.SortTitle = sortTitle);
|
||||
break;
|
||||
case "outline":
|
||||
await ReadStringContent(reader, nfo, (movie, outline) => movie.Outline = outline);
|
||||
break;
|
||||
case "year":
|
||||
await ReadIntContent(reader, nfo, (movie, year) => movie.Year = year);
|
||||
break;
|
||||
case "mpaa":
|
||||
await ReadStringContent(
|
||||
reader,
|
||||
nfo,
|
||||
(movie, contentRating) => movie.ContentRating = contentRating);
|
||||
break;
|
||||
case "premiered":
|
||||
await ReadDateTimeContent(
|
||||
reader,
|
||||
nfo,
|
||||
(movie, premiered) => movie.Premiered = premiered);
|
||||
break;
|
||||
case "plot":
|
||||
await ReadStringContent(reader, nfo, (movie, plot) => movie.Plot = plot);
|
||||
break;
|
||||
case "genre":
|
||||
await ReadStringContent(reader, nfo, (movie, genre) => movie.Genres.Add(genre));
|
||||
break;
|
||||
case "tag":
|
||||
await ReadStringContent(reader, nfo, (movie, tag) => movie.Tags.Add(tag));
|
||||
break;
|
||||
case "studio":
|
||||
await ReadStringContent(reader, nfo, (movie, studio) => movie.Studios.Add(studio));
|
||||
break;
|
||||
case "actor":
|
||||
ReadActor(reader, nfo, (movie, actor) => movie.Actors.Add(actor));
|
||||
break;
|
||||
case "credits":
|
||||
await ReadStringContent(reader, nfo, (movie, writer) => movie.Writers.Add(writer));
|
||||
break;
|
||||
case "director":
|
||||
await ReadStringContent(
|
||||
reader,
|
||||
nfo,
|
||||
(movie, director) => movie.Directors.Add(director));
|
||||
break;
|
||||
case "uniqueid":
|
||||
await ReadUniqueId(reader, nfo, (movie, uniqueid) => movie.UniqueIds.Add(uniqueid));
|
||||
break;
|
||||
}
|
||||
|
||||
break;
|
||||
case XmlNodeType.EndElement:
|
||||
if (reader.Name == "movie")
|
||||
{
|
||||
done = true;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return Optional(nfo).ToEither((BaseError)new FailedToReadNfo());
|
||||
}
|
||||
catch (XmlException)
|
||||
{
|
||||
_logger.LogWarning("Invalid XML detected; returning incomplete metadata");
|
||||
return Optional(nfo).ToEither((BaseError)new FailedToReadNfo());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_client.Notify(ex);
|
||||
return new FailedToReadNfo(ex.ToString());
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user