Compare commits
76
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40ed4b8b0e | ||
|
|
b43d08ca67 | ||
|
|
5e7e386108 | ||
|
|
4176df9940 | ||
|
|
de2ef959fe | ||
|
|
b53cfebac1 | ||
|
|
6895b9cc6b | ||
|
|
c60d6e46f1 | ||
|
|
c66d190174 | ||
|
|
5e8da591be | ||
|
|
9c02a6738b | ||
|
|
5ed0184bca | ||
|
|
ae64ca4a93 | ||
|
|
c47099895e | ||
|
|
a2529febba | ||
|
|
521e0ba8b3 | ||
|
|
ee0efac9be | ||
|
|
bfe7635489 | ||
|
|
aa1735f024 | ||
|
|
8deae983c7 | ||
|
|
f349646703 | ||
|
|
5003e80500 | ||
|
|
940d9cd6b5 | ||
|
|
197c166789 | ||
|
|
d114db091e | ||
|
|
3204da8e43 | ||
|
|
100eb14408 | ||
|
|
025017ace5 | ||
|
|
6a690c7c10 | ||
|
|
dd7f77751c | ||
|
|
0c13b8ef1a | ||
|
|
c6ca58ab97 | ||
|
|
0846fc1d96 | ||
|
|
e41dd68ee0 | ||
|
|
0a92996da8 | ||
|
|
082bc6145c | ||
|
|
bf3f16451b | ||
|
|
3cb37003cb | ||
|
|
9acfd2cd06 | ||
|
|
3242e7ebb8 | ||
|
|
7644d628e7 | ||
|
|
b4f19e6de4 | ||
|
|
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 |
+1
-1
@@ -1,6 +1,6 @@
|
||||
|
||||
[*]
|
||||
charset=utf-8-bom
|
||||
charset=utf-8
|
||||
end_of_line=lf
|
||||
trim_trailing_whitespace=false
|
||||
insert_final_newline=false
|
||||
|
||||
@@ -41,18 +41,18 @@ jobs:
|
||||
target: osx-arm64
|
||||
steps:
|
||||
- name: Get the sources
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: true
|
||||
|
||||
- name: Setup .NET Core
|
||||
uses: actions/setup-dotnet@v1
|
||||
uses: actions/setup-dotnet@v2
|
||||
with:
|
||||
dotnet-version: 6.0.x
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v2
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '14'
|
||||
|
||||
@@ -108,15 +108,16 @@ jobs:
|
||||
--icon "ErsatzTV.app" 200 190 \
|
||||
--hide-extension "ErsatzTV.app" \
|
||||
--app-drop-link 600 185 \
|
||||
--skip-jenkins \
|
||||
"ErsatzTV.dmg" \
|
||||
"ErsatzTV.app/"
|
||||
|
||||
- name: Notarize
|
||||
shell: bash
|
||||
run: |
|
||||
curl -o gon.zip -L -s "https://github.com/mitchellh/gon/releases/latest/download/gon_macos.zip"
|
||||
unzip -o -q gon.zip
|
||||
./gon -log-level=debug -log-json ./gon.json
|
||||
brew tap mitchellh/gon
|
||||
brew install mitchellh/gon/gon
|
||||
gon -log-level=debug -log-json ./gon.json
|
||||
env:
|
||||
AC_USERNAME: ${{ secrets.ac_username }}
|
||||
AC_PASSWORD: ${{ secrets.ac_password }}
|
||||
@@ -167,17 +168,17 @@ jobs:
|
||||
target: win-x64
|
||||
steps:
|
||||
- name: Get the sources
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup .NET Core
|
||||
uses: actions/setup-dotnet@v1
|
||||
uses: actions/setup-dotnet@v2
|
||||
with:
|
||||
dotnet-version: 6.0.x
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v2
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '14'
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Get the sources
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Extract Docker Tag
|
||||
|
||||
@@ -39,32 +39,36 @@ jobs:
|
||||
path: 'vaapi/'
|
||||
suffix: '-vaapi'
|
||||
qemu: false
|
||||
- name: arm32v7
|
||||
path: 'arm32v7/'
|
||||
suffix: '-arm'
|
||||
qemu: true
|
||||
- name: arm64
|
||||
path: 'arm64/'
|
||||
suffix: '-arm64'
|
||||
qemu: true
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v1
|
||||
uses: docker/setup-qemu-action@v2
|
||||
if: ${{ matrix.qemu == true }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
uses: docker/setup-buildx-action@v2
|
||||
id: docker-buildx
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v1
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.docker_hub_username }}
|
||||
password: ${{ secrets.docker_hub_access_token }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v2
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
builder: ${{ steps.docker-buildx.outputs.name }}
|
||||
context: .
|
||||
@@ -75,10 +79,10 @@ jobs:
|
||||
tags: |
|
||||
jasongdove/ersatztv:${{ inputs.base_version }}${{ matrix.suffix }}
|
||||
jasongdove/ersatztv:${{ inputs.tag_version }}${{ matrix.suffix }}
|
||||
if: ${{ matrix.name != 'arm64' }}
|
||||
if: ${{ matrix.name != 'arm64' && matrix.name != 'arm32v7' }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v2
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
builder: ${{ steps.docker-buildx.outputs.name }}
|
||||
context: .
|
||||
@@ -91,3 +95,18 @@ jobs:
|
||||
jasongdove/ersatztv:${{ inputs.base_version }}${{ matrix.suffix }}
|
||||
jasongdove/ersatztv:${{ inputs.tag_version }}${{ matrix.suffix }}
|
||||
if: ${{ matrix.name == 'arm64' }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
builder: ${{ steps.docker-buildx.outputs.name }}
|
||||
context: .
|
||||
file: ./docker/${{ matrix.path }}Dockerfile
|
||||
push: true
|
||||
platforms: 'linux/arm/v7'
|
||||
build-args: |
|
||||
INFO_VERSION=${{ inputs.info_version }}-docker${{ matrix.suffix }}
|
||||
tags: |
|
||||
jasongdove/ersatztv:${{ inputs.base_version }}${{ matrix.suffix }}
|
||||
jasongdove/ersatztv:${{ inputs.tag_version }}${{ matrix.suffix }}
|
||||
if: ${{ matrix.name == 'arm32v7' }}
|
||||
|
||||
@@ -3,13 +3,16 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- docs/**
|
||||
- mkdocs.yml
|
||||
jobs:
|
||||
build:
|
||||
name: Deploy docs
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout master
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Deploy docs
|
||||
uses: mhausenblas/mkdocs-deploy-gh-pages@master
|
||||
|
||||
@@ -10,10 +10,10 @@ jobs:
|
||||
os: [ windows-latest, ubuntu-latest, macos-latest ]
|
||||
steps:
|
||||
- name: Get the sources
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Setup .NET Core
|
||||
uses: actions/setup-dotnet@v1
|
||||
uses: actions/setup-dotnet@v2
|
||||
with:
|
||||
dotnet-version: 6.0.x
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Get the sources
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Extract Docker Tag
|
||||
|
||||
@@ -7,10 +7,10 @@ jobs:
|
||||
steps:
|
||||
# Checkout the current repo
|
||||
- name: Checkout current repository
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v3
|
||||
# Setup NodeJS version 14
|
||||
- name: Setup NodeJS V14.x.x
|
||||
uses: actions/setup-node@v2
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '14'
|
||||
# CD into the current client directory and lint and build the client
|
||||
|
||||
+114
-2
@@ -5,6 +5,111 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.6.4-beta] - 2022-07-28
|
||||
### Fixed
|
||||
- Fix subtitle stream selection when subtitle language is different than audio language
|
||||
- Fix bug with unsupported AAC channel layouts
|
||||
- Fix NVIDIA second-gen maxwell capabilities detection
|
||||
- Return distinct search results for episodes and other videos that have the same title
|
||||
- For example, two other videos both named `Trailer` would previously have displayed as one item in search results
|
||||
- Fix schedules that would begin to repeat the same content in the same order after a couple of days
|
||||
|
||||
### Added
|
||||
- Add `640x480` resolution
|
||||
|
||||
## [0.6.3-beta] - 2022-07-04
|
||||
### Fixed
|
||||
- Maintain stream continuity when playout is rebuilt for a channel that is actively being streamed
|
||||
- Properly apply changes to episode title, sort title, outline and plot from Plex
|
||||
- Fix search index for other videos and songs
|
||||
- In previous versions, some libraries would incorrectly display only one item
|
||||
- Properly display old versions of renamed items in trash
|
||||
|
||||
### Added
|
||||
- Add `Minimum Log Level` option to `Settings` page
|
||||
- Other methods of configuring the log level will no longer work
|
||||
|
||||
## [0.6.2-beta] - 2022-06-18
|
||||
### Fixed
|
||||
- Fix content repeating for up to a minute near the top of every hour
|
||||
- Check whether hardware-accelerated hevc codecs are supported by the NVIDIA card
|
||||
- Software codecs will be used if they are unsupported by the NVIDIA card
|
||||
- Fix sorting of channel contents in EPG
|
||||
- Fix Jellyfin admin user id sync
|
||||
- Ignore disabled admins and admins who do not have access to all libraries
|
||||
|
||||
### Added
|
||||
- Add 32-bit `arm` docker tags (`develop-arm` and `latest-arm`)
|
||||
|
||||
### Changed
|
||||
- Regularly delete old segments from transcode folder while content is actively transcoding
|
||||
- This should help reduce required disk space
|
||||
- To further minimize required disk space, set `Work-Ahead HLS Segmenter Limit` to `0` in `Settings`
|
||||
|
||||
## [0.6.1-beta] - 2022-06-03
|
||||
### Fixed
|
||||
- Fix Jellyfin show library paging
|
||||
- Properly locate and identify multiple Plex servers
|
||||
- Properly restore `Unavailable`/`File Not Found` items when they are located on disk
|
||||
|
||||
### Added
|
||||
- Add basic music video credits subtitle generation
|
||||
- This can be enabled in channel settings
|
||||
|
||||
## [0.6.0-beta] - 2022-06-01
|
||||
### Fixed
|
||||
- Additional fix for duplicate `Other Videos` entries; trash may need to be emptied one last time after upgrading
|
||||
- Fix watermark opacity in cultures where `,` is a decimal separator
|
||||
- Rework playlist filtering to avoid empty playlist responses
|
||||
- Fix some QSV/VAAPI memory errors by always requesting 64 extra hardware frames
|
||||
|
||||
### Added
|
||||
- Enable QSV hardware acceleration for vaapi docker images
|
||||
|
||||
### Changed
|
||||
- Use paging to synchronize all media from Plex, Jellyfin and Emby
|
||||
- This will reduce memory use and improve reliability of synchronizing large libraries
|
||||
- Disable low power mode for `h264_qsv` and `hevc_qsv` encoders
|
||||
|
||||
## [0.5.8-beta] - 2022-05-20
|
||||
### Fixed
|
||||
- Fix error display with `HLS Segmenter` and `MPEG-TS` streaming modes
|
||||
- Remove erroneous log messages about normalizing framerate on channels where framerate normalization is disabled
|
||||
- Fix unscheduled filler gaps that sometimes happen as playouts are automatically extended each hour
|
||||
|
||||
### Added
|
||||
- Clean transcode cache folder on startup and after `HLS Segmenter` session terminates for any reason
|
||||
|
||||
### Changed
|
||||
- Remove thread limitation for scenarios where it is not required
|
||||
- This should give a performance boost to installations that don't use hardware acceleration
|
||||
- Use hardware acceleration to display error messages where configured
|
||||
|
||||
## [0.5.7-beta] - 2022-05-14
|
||||
### Fixed
|
||||
- Reduce memory use due to library scan operations
|
||||
- Fix some instances of filler getting "stuck" when a filler item is encountered that's too long for the gap
|
||||
- Properly ignore Plex `Other Videos` libraries (`movie` libraries where agent is `com.plexapp.agents.none`)
|
||||
- Fix `Custom Title` for schedule items with `One`, `Multiple` and `Flood` playout modes
|
||||
- Fix scheduling bug where flood items would sometimes fail to continue after midnight
|
||||
|
||||
### Added
|
||||
- Add `metadata_kind` field to search index to allow searching for items with a particular metdata source
|
||||
- Valid metadata kinds are `fallback`, `sidecar` (NFO), `external` (from a media server) and `embedded` (songs)
|
||||
- Add autocomplete functionality to search bar to quickly navigate to channels, ffmpeg profiles, collections and schedules by name
|
||||
- Add global setting to skip missing (file-not-found or unavailable) items when building playouts
|
||||
- Add filler preset option to allow watermarks to overlay on top of filler (disabled by default)
|
||||
- This option is applied when new items are added to a playout; rebuilding is needed if you want the change to take effect immediately
|
||||
- Read `track` field from music video NFO metadata and use it for chronological sorting (after release date)
|
||||
- Add `Random Start Point` option to schedules
|
||||
- When this option is enabled, all `Chronological` or `Shuffle In Order` content groups will have their start points randomized
|
||||
- When this option is disabled, all `Chronological` or `Shuffle In Order` content groups will start with the chronologically earliest item
|
||||
|
||||
### Changed
|
||||
- Replace invalid (control) characters in NFO metadata with replacement character `�` before parsing
|
||||
- Store partial (incomplete) NFO metadata results when invalid XML is encountered
|
||||
- Previously, no metadata would be stored if the XML within the NFO failed to validate
|
||||
|
||||
## [0.5.6-beta] - 2022-05-06
|
||||
### Fixed
|
||||
- Fix processing local movie NFO metadata without a `year` value
|
||||
@@ -1171,8 +1276,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
- Initial release to facilitate testing outside of Docker.
|
||||
|
||||
|
||||
[Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.5.6-beta...HEAD
|
||||
[0.5.5-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.5.5-beta...v0.5.6-beta
|
||||
[Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.6.4-beta...HEAD
|
||||
[0.6.4-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.6.3-beta...v0.6.4-beta
|
||||
[0.6.3-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.6.2-beta...v0.6.3-beta
|
||||
[0.6.2-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.6.1-beta...v0.6.2-beta
|
||||
[0.6.1-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.6.0-beta...v0.6.1-beta
|
||||
[0.6.0-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.5.8-beta...v0.6.0-beta
|
||||
[0.5.8-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.5.7-beta...v0.5.8-beta
|
||||
[0.5.7-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.5.6-beta...v0.5.7-beta
|
||||
[0.5.6-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.5.5-beta...v0.5.6-beta
|
||||
[0.5.5-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.5.4-beta...v0.5.5-beta
|
||||
[0.5.4-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.5.3-beta...v0.5.4-beta
|
||||
[0.5.3-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.5.2-beta...v0.5.3-beta
|
||||
|
||||
@@ -16,4 +16,5 @@ public record ChannelViewModel(
|
||||
int? FallbackFillerId,
|
||||
int PlayoutCount,
|
||||
string PreferredSubtitleLanguageCode,
|
||||
ChannelSubtitleMode SubtitleMode);
|
||||
ChannelSubtitleMode SubtitleMode,
|
||||
ChannelMusicVideoCreditsMode MusicVideoCreditsMode);
|
||||
|
||||
@@ -16,4 +16,5 @@ public record CreateChannel
|
||||
int? WatermarkId,
|
||||
int? FallbackFillerId,
|
||||
string PreferredSubtitleLanguageCode,
|
||||
ChannelSubtitleMode SubtitleMode) : IRequest<Either<BaseError, CreateChannelResult>>;
|
||||
ChannelSubtitleMode SubtitleMode,
|
||||
ChannelMusicVideoCreditsMode MusicVideoCreditsMode) : IRequest<Either<BaseError, CreateChannelResult>>;
|
||||
|
||||
@@ -21,7 +21,7 @@ public class CreateChannelHandler : IRequestHandler<CreateChannel, Either<BaseEr
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, Channel> validation = await Validate(dbContext, request);
|
||||
return await LanguageExtensions.Apply(validation, c => PersistChannel(dbContext, c));
|
||||
return await validation.Apply(c => PersistChannel(dbContext, c));
|
||||
}
|
||||
|
||||
private static async Task<CreateChannelResult> PersistChannel(TvContext dbContext, Channel channel)
|
||||
@@ -72,7 +72,8 @@ public class CreateChannelHandler : IRequestHandler<CreateChannel, Either<BaseEr
|
||||
Artwork = artwork,
|
||||
PreferredAudioLanguageCode = preferredAudioLanguageCode,
|
||||
PreferredSubtitleLanguageCode = preferredSubtitleLanguageCode,
|
||||
SubtitleMode = request.SubtitleMode
|
||||
SubtitleMode = request.SubtitleMode,
|
||||
MusicVideoCreditsMode = request.MusicVideoCreditsMode
|
||||
};
|
||||
|
||||
foreach (int id in watermarkId)
|
||||
|
||||
@@ -17,4 +17,5 @@ public record UpdateChannel
|
||||
int? WatermarkId,
|
||||
int? FallbackFillerId,
|
||||
string PreferredSubtitleLanguageCode,
|
||||
ChannelSubtitleMode SubtitleMode) : IRequest<Either<BaseError, ChannelViewModel>>;
|
||||
ChannelSubtitleMode SubtitleMode,
|
||||
ChannelMusicVideoCreditsMode MusicVideoCreditsMode) : IRequest<Either<BaseError, ChannelViewModel>>;
|
||||
|
||||
@@ -44,6 +44,7 @@ public class UpdateChannelHandler : IRequestHandler<UpdateChannel, Either<BaseEr
|
||||
c.PreferredAudioLanguageCode = update.PreferredAudioLanguageCode;
|
||||
c.PreferredSubtitleLanguageCode = update.PreferredSubtitleLanguageCode;
|
||||
c.SubtitleMode = update.SubtitleMode;
|
||||
c.MusicVideoCreditsMode = update.MusicVideoCreditsMode;
|
||||
c.Artwork ??= new List<Artwork>();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(update.Logo))
|
||||
|
||||
@@ -20,7 +20,8 @@ internal static class Mapper
|
||||
channel.FallbackFillerId,
|
||||
channel.Playouts?.Count ?? 0,
|
||||
channel.PreferredSubtitleLanguageCode,
|
||||
channel.SubtitleMode);
|
||||
channel.SubtitleMode,
|
||||
channel.MusicVideoCreditsMode);
|
||||
|
||||
internal static ChannelResponseModel ProjectToResponseModel(Channel channel) =>
|
||||
new(
|
||||
|
||||
@@ -21,11 +21,22 @@ public class GetChannelFramerateHandler : IRequestHandler<GetChannelFramerate, O
|
||||
|
||||
public async Task<Option<int>> Handle(GetChannelFramerate request, CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
FFmpegProfile ffmpegProfile = await dbContext.Channels
|
||||
.Filter(c => c.Number == request.ChannelNumber)
|
||||
.Include(c => c.FFmpegProfile)
|
||||
.Map(c => c.FFmpegProfile)
|
||||
.SingleAsync(cancellationToken);
|
||||
|
||||
if (!ffmpegProfile.NormalizeFramerate)
|
||||
{
|
||||
return Option<int>.None;
|
||||
}
|
||||
|
||||
// TODO: expand to check everything in collection rather than what's scheduled?
|
||||
_logger.LogDebug("Checking frame rates for channel {ChannelNumber}", request.ChannelNumber);
|
||||
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
List<Playout> playouts = await dbContext.Playouts
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(pi => pi.MediaItem)
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Iptv;
|
||||
using Microsoft.IO;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class GetChannelGuideHandler : IRequestHandler<GetChannelGuide, ChannelGuide>
|
||||
{
|
||||
private readonly IChannelRepository _channelRepository;
|
||||
private readonly RecyclableMemoryStreamManager _recyclableMemoryStreamManager;
|
||||
|
||||
public GetChannelGuideHandler(IChannelRepository channelRepository) => _channelRepository = channelRepository;
|
||||
public GetChannelGuideHandler(
|
||||
IChannelRepository channelRepository,
|
||||
RecyclableMemoryStreamManager recyclableMemoryStreamManager)
|
||||
{
|
||||
_channelRepository = channelRepository;
|
||||
_recyclableMemoryStreamManager = recyclableMemoryStreamManager;
|
||||
}
|
||||
|
||||
public Task<ChannelGuide> Handle(GetChannelGuide request, CancellationToken cancellationToken) =>
|
||||
_channelRepository.GetAllForGuide()
|
||||
.Map(channels => new ChannelGuide(request.Scheme, request.Host, channels));
|
||||
.Map(channels => new ChannelGuide(_recyclableMemoryStreamManager, request.Scheme, request.Host, channels));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public record UpdateGeneralSettings(GeneralSettingsViewModel GeneralSettings) : IRequest<Either<BaseError, Unit>>;
|
||||
@@ -0,0 +1,32 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using Serilog.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public class UpdateGeneralSettingsHandler : IRequestHandler<UpdateGeneralSettings, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
private readonly LoggingLevelSwitch _loggingLevelSwitch;
|
||||
|
||||
public UpdateGeneralSettingsHandler(
|
||||
LoggingLevelSwitch loggingLevelSwitch,
|
||||
IConfigElementRepository configElementRepository)
|
||||
{
|
||||
_loggingLevelSwitch = loggingLevelSwitch;
|
||||
_configElementRepository = configElementRepository;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Unit>> Handle(
|
||||
UpdateGeneralSettings request,
|
||||
CancellationToken cancellationToken) => await ApplyUpdate(request.GeneralSettings);
|
||||
|
||||
private async Task<Unit> ApplyUpdate(GeneralSettingsViewModel generalSettings)
|
||||
{
|
||||
await _configElementRepository.Upsert(ConfigElementKey.MinimumLogLevel, generalSettings.MinimumLogLevel);
|
||||
_loggingLevelSwitch.MinimumLevel = generalSettings.MinimumLogLevel;
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public record UpdatePlayoutDaysToBuild(int DaysToBuild) : IRequest<Either<BaseError, Unit>>;
|
||||
@@ -0,0 +1,5 @@
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public record UpdatePlayoutSettings(PlayoutSettingsViewModel PlayoutSettings) : IRequest<Either<BaseError, Unit>>;
|
||||
+12
-9
@@ -9,13 +9,13 @@ using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public class UpdatePlayoutDaysToBuildHandler : IRequestHandler<UpdatePlayoutDaysToBuild, Either<BaseError, Unit>>
|
||||
public class UpdatePlayoutSettingsHandler : IRequestHandler<UpdatePlayoutSettings, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
|
||||
|
||||
public UpdatePlayoutDaysToBuildHandler(
|
||||
public UpdatePlayoutSettingsHandler(
|
||||
IConfigElementRepository configElementRepository,
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ChannelWriter<IBackgroundServiceRequest> workerChannel)
|
||||
@@ -26,17 +26,20 @@ public class UpdatePlayoutDaysToBuildHandler : IRequestHandler<UpdatePlayoutDays
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Unit>> Handle(
|
||||
UpdatePlayoutDaysToBuild request,
|
||||
UpdatePlayoutSettings request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, Unit> validation = await Validate(request);
|
||||
return await validation.Apply<Unit, Unit>(_ => ApplyUpdate(dbContext, request.DaysToBuild));
|
||||
return await validation.Apply<Unit, Unit>(_ => ApplyUpdate(dbContext, request.PlayoutSettings));
|
||||
}
|
||||
|
||||
private async Task<Unit> ApplyUpdate(TvContext dbContext, int daysToBuild)
|
||||
private async Task<Unit> ApplyUpdate(TvContext dbContext, PlayoutSettingsViewModel playoutSettings)
|
||||
{
|
||||
await _configElementRepository.Upsert(ConfigElementKey.PlayoutDaysToBuild, daysToBuild);
|
||||
await _configElementRepository.Upsert(ConfigElementKey.PlayoutDaysToBuild, playoutSettings.DaysToBuild);
|
||||
await _configElementRepository.Upsert(
|
||||
ConfigElementKey.PlayoutSkipMissingItems,
|
||||
playoutSettings.SkipMissingItems);
|
||||
|
||||
// continue all playouts to proper number of days
|
||||
List<Playout> playouts = await dbContext.Playouts
|
||||
@@ -50,8 +53,8 @@ public class UpdatePlayoutDaysToBuildHandler : IRequestHandler<UpdatePlayoutDays
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private static Task<Validation<BaseError, Unit>> Validate(UpdatePlayoutDaysToBuild request) =>
|
||||
Optional(request.DaysToBuild)
|
||||
private static Task<Validation<BaseError, Unit>> Validate(UpdatePlayoutSettings request) =>
|
||||
Optional(request.PlayoutSettings.DaysToBuild)
|
||||
.Where(days => days > 0)
|
||||
.Map(_ => Unit.Default)
|
||||
.ToValidation<BaseError>("Days to build must be greater than zero")
|
||||
@@ -0,0 +1,8 @@
|
||||
using Serilog.Events;
|
||||
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public class GeneralSettingsViewModel
|
||||
{
|
||||
public LogEventLevel MinimumLogLevel { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public class PlayoutSettingsViewModel
|
||||
{
|
||||
public int DaysToBuild { get; set; }
|
||||
public bool SkipMissingItems { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public record GetGeneralSettings : IRequest<GeneralSettingsViewModel>;
|
||||
@@ -0,0 +1,24 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using Serilog.Events;
|
||||
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public class GetGeneralSettingsHandler : IRequestHandler<GetGeneralSettings, GeneralSettingsViewModel>
|
||||
{
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
|
||||
public GetGeneralSettingsHandler(IConfigElementRepository configElementRepository) =>
|
||||
_configElementRepository = configElementRepository;
|
||||
|
||||
public async Task<GeneralSettingsViewModel> Handle(GetGeneralSettings request, CancellationToken cancellationToken)
|
||||
{
|
||||
Option<LogEventLevel> maybeLogLevel =
|
||||
await _configElementRepository.GetValue<LogEventLevel>(ConfigElementKey.MinimumLogLevel);
|
||||
|
||||
return new GeneralSettingsViewModel
|
||||
{
|
||||
MinimumLogLevel = await maybeLogLevel.IfNoneAsync(LogEventLevel.Information)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -7,12 +7,12 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Bugsnag" Version="3.0.1" />
|
||||
<PackageReference Include="Bugsnag" Version="3.1.0" />
|
||||
<PackageReference Include="CliWrap" Version="3.4.4" />
|
||||
<PackageReference Include="Humanizer.Core" Version="2.14.1" />
|
||||
<PackageReference Include="MediatR" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="6.0.0" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.1.46">
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.2.32">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
|
||||
@@ -20,7 +20,7 @@ public class CreateFFmpegProfileHandler :
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, FFmpegProfile> validation = await Validate(dbContext, request);
|
||||
return await LanguageExtensions.Apply(validation, profile => PersistFFmpegProfile(dbContext, profile));
|
||||
return await validation.Apply(profile => PersistFFmpegProfile(dbContext, profile));
|
||||
}
|
||||
|
||||
private static async Task<CreateFFmpegProfileResult> PersistFFmpegProfile(
|
||||
|
||||
@@ -35,6 +35,27 @@ internal static class Mapper
|
||||
ffmpegProfile.VideoFormat.ToString().ToLowerInvariant(),
|
||||
ffmpegProfile.AudioFormat.ToString().ToLowerInvariant());
|
||||
|
||||
internal static FFmpegFullProfileResponseModel ProjectToFullResponseModel(FFmpegProfile ffmpegProfile) =>
|
||||
new(
|
||||
ffmpegProfile.Id,
|
||||
ffmpegProfile.Name,
|
||||
ffmpegProfile.ThreadCount,
|
||||
(int)ffmpegProfile.HardwareAcceleration,
|
||||
(int)ffmpegProfile.VaapiDriver,
|
||||
ffmpegProfile.VaapiDevice,
|
||||
ffmpegProfile.ResolutionId,
|
||||
(int)ffmpegProfile.VideoFormat,
|
||||
ffmpegProfile.VideoBitrate,
|
||||
ffmpegProfile.VideoBufferSize,
|
||||
(int)ffmpegProfile.AudioFormat,
|
||||
ffmpegProfile.AudioBitrate,
|
||||
ffmpegProfile.AudioBufferSize,
|
||||
ffmpegProfile.NormalizeLoudness,
|
||||
ffmpegProfile.AudioChannels,
|
||||
ffmpegProfile.AudioSampleRate,
|
||||
ffmpegProfile.NormalizeFramerate,
|
||||
ffmpegProfile.DeinterlaceVideo);
|
||||
|
||||
private static ResolutionViewModel Project(Resolution resolution) =>
|
||||
new(resolution.Id, resolution.Name, resolution.Width, resolution.Height);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
using ErsatzTV.Core.Api.FFmpegProfiles;
|
||||
|
||||
namespace ErsatzTV.Application.FFmpegProfiles;
|
||||
|
||||
public record GetFFmpegFullProfileByIdForApi(int Id) : IRequest<Option<FFmpegFullProfileResponseModel>>;
|
||||
@@ -0,0 +1,28 @@
|
||||
using ErsatzTV.Core.Api.FFmpegProfiles;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using static ErsatzTV.Application.FFmpegProfiles.Mapper;
|
||||
|
||||
namespace ErsatzTV.Application.FFmpegProfiles;
|
||||
|
||||
public class
|
||||
GetFFmpegProfileByIdForApiHandler : IRequestHandler<GetFFmpegFullProfileByIdForApi,
|
||||
Option<FFmpegFullProfileResponseModel>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
|
||||
public GetFFmpegProfileByIdForApiHandler(IDbContextFactory<TvContext> dbContextFactory) =>
|
||||
_dbContextFactory = dbContextFactory;
|
||||
|
||||
public async Task<Option<FFmpegFullProfileResponseModel>> Handle(
|
||||
GetFFmpegFullProfileByIdForApi request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
return await dbContext.FFmpegProfiles
|
||||
.Include(p => p.Resolution)
|
||||
.SelectOneAsync(p => p.Id, p => p.Id == request.Id)
|
||||
.MapT(ProjectToFullResponseModel);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ public record CreateFillerPreset(
|
||||
TimeSpan? Duration,
|
||||
int? Count,
|
||||
int? PadToNearestMinute,
|
||||
bool AllowWatermarks,
|
||||
ProgramScheduleItemCollectionType CollectionType,
|
||||
int? CollectionId,
|
||||
int? MediaItemId,
|
||||
|
||||
@@ -31,6 +31,7 @@ public class CreateFillerPresetHandler : IRequestHandler<CreateFillerPreset, Eit
|
||||
Duration = request.Duration,
|
||||
Count = request.Count,
|
||||
PadToNearestMinute = request.PadToNearestMinute,
|
||||
AllowWatermarks = request.AllowWatermarks,
|
||||
CollectionType = request.CollectionType,
|
||||
CollectionId = request.CollectionId,
|
||||
MediaItemId = request.MediaItemId,
|
||||
|
||||
@@ -12,6 +12,7 @@ public record UpdateFillerPreset(
|
||||
TimeSpan? Duration,
|
||||
int? Count,
|
||||
int? PadToNearestMinute,
|
||||
bool AllowWatermarks,
|
||||
ProgramScheduleItemCollectionType CollectionType,
|
||||
int? CollectionId,
|
||||
int? MediaItemId,
|
||||
|
||||
@@ -15,8 +15,7 @@ public class UpdateFillerPresetHandler : IRequestHandler<UpdateFillerPreset, Eit
|
||||
|
||||
public async Task<Either<BaseError, Unit>> Handle(UpdateFillerPreset request, CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, FillerPreset> validation = await FillerPresetMustExist(dbContext, request);
|
||||
return await LanguageExtensions.Apply(validation, ps => ApplyUpdateRequest(dbContext, ps, request));
|
||||
}
|
||||
@@ -32,6 +31,7 @@ public class UpdateFillerPresetHandler : IRequestHandler<UpdateFillerPreset, Eit
|
||||
existing.Duration = request.Duration;
|
||||
existing.Count = request.Count;
|
||||
existing.PadToNearestMinute = request.PadToNearestMinute;
|
||||
existing.AllowWatermarks = request.AllowWatermarks;
|
||||
existing.CollectionType = request.CollectionType;
|
||||
existing.CollectionId = request.CollectionId;
|
||||
existing.MediaItemId = request.MediaItemId;
|
||||
|
||||
@@ -11,6 +11,7 @@ public record FillerPresetViewModel(
|
||||
TimeSpan? Duration,
|
||||
int? Count,
|
||||
int? PadToNearestMinute,
|
||||
bool AllowWatermarks,
|
||||
ProgramScheduleItemCollectionType CollectionType,
|
||||
int? CollectionId,
|
||||
int? MediaItemId,
|
||||
|
||||
@@ -13,6 +13,7 @@ internal static class Mapper
|
||||
fillerPreset.Duration,
|
||||
fillerPreset.Count,
|
||||
fillerPreset.PadToNearestMinute,
|
||||
fillerPreset.AllowWatermarks,
|
||||
fillerPreset.CollectionType,
|
||||
fillerPreset.CollectionId,
|
||||
fillerPreset.MediaItemId,
|
||||
|
||||
@@ -69,7 +69,7 @@ public class
|
||||
|
||||
string originalPath = _imageCache.GetPathForImage(request.FileName, request.ArtworkKind, None);
|
||||
|
||||
Command process = _ffmpegProcessService.ResizeImage(
|
||||
Command process = await _ffmpegProcessService.ResizeImage(
|
||||
ffmpegPath,
|
||||
originalPath,
|
||||
withExtension,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
using Dapper;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories.Caching;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
@@ -13,18 +14,21 @@ namespace ErsatzTV.Application.Libraries;
|
||||
public class MoveLocalLibraryPathHandler : IRequestHandler<MoveLocalLibraryPath, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
|
||||
private readonly ILogger<MoveLocalLibraryPathHandler> _logger;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
private readonly ISearchRepository _searchRepository;
|
||||
private readonly ICachingSearchRepository _searchRepository;
|
||||
|
||||
public MoveLocalLibraryPathHandler(
|
||||
ISearchIndex searchIndex,
|
||||
ISearchRepository searchRepository,
|
||||
ICachingSearchRepository searchRepository,
|
||||
IFallbackMetadataProvider fallbackMetadataProvider,
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ILogger<MoveLocalLibraryPathHandler> logger)
|
||||
{
|
||||
_searchIndex = searchIndex;
|
||||
_searchRepository = searchRepository;
|
||||
_fallbackMetadataProvider = fallbackMetadataProvider;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
@@ -35,7 +39,7 @@ public class MoveLocalLibraryPathHandler : IRequestHandler<MoveLocalLibraryPath,
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, Parameters> validation = await Validate(dbContext, request);
|
||||
return await LanguageExtensions.Apply(validation, parameters => MovePath(dbContext, parameters));
|
||||
return await validation.Apply(parameters => MovePath(dbContext, parameters));
|
||||
}
|
||||
|
||||
private async Task<Unit> MovePath(TvContext dbContext, Parameters parameters)
|
||||
@@ -57,7 +61,10 @@ public class MoveLocalLibraryPathHandler : IRequestHandler<MoveLocalLibraryPath,
|
||||
foreach (MediaItem mediaItem in maybeMediaItem)
|
||||
{
|
||||
_logger.LogInformation("Moving item at {Path}", await GetPath(dbContext, mediaItem));
|
||||
await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { mediaItem });
|
||||
await _searchIndex.UpdateItems(
|
||||
_searchRepository,
|
||||
_fallbackMetadataProvider,
|
||||
new List<MediaItem> { mediaItem });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Interfaces.Locking;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories.Caching;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Interfaces.Trakt;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
@@ -17,12 +18,13 @@ public class AddTraktListHandler : TraktCommandBase, IRequestHandler<AddTraktLis
|
||||
|
||||
public AddTraktListHandler(
|
||||
ITraktApiClient traktApiClient,
|
||||
ISearchRepository searchRepository,
|
||||
ICachingSearchRepository searchRepository,
|
||||
ISearchIndex searchIndex,
|
||||
IFallbackMetadataProvider fallbackMetadataProvider,
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ILogger<AddTraktListHandler> logger,
|
||||
IEntityLocker entityLocker)
|
||||
: base(traktApiClient, searchRepository, searchIndex, logger)
|
||||
: base(traktApiClient, searchRepository, searchIndex, fallbackMetadataProvider, logger)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_entityLocker = entityLocker;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Locking;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories.Caching;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Interfaces.Trakt;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
@@ -14,20 +15,23 @@ public class DeleteTraktListHandler : TraktCommandBase, IRequestHandler<DeleteTr
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly IEntityLocker _entityLocker;
|
||||
private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
private readonly ISearchRepository _searchRepository;
|
||||
private readonly ICachingSearchRepository _searchRepository;
|
||||
|
||||
public DeleteTraktListHandler(
|
||||
ITraktApiClient traktApiClient,
|
||||
ISearchRepository searchRepository,
|
||||
ICachingSearchRepository searchRepository,
|
||||
ISearchIndex searchIndex,
|
||||
IFallbackMetadataProvider fallbackMetadataProvider,
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ILogger<DeleteTraktListHandler> logger,
|
||||
IEntityLocker entityLocker)
|
||||
: base(traktApiClient, searchRepository, searchIndex, logger)
|
||||
: base(traktApiClient, searchRepository, searchIndex, fallbackMetadataProvider, logger)
|
||||
{
|
||||
_searchRepository = searchRepository;
|
||||
_searchIndex = searchIndex;
|
||||
_fallbackMetadataProvider = fallbackMetadataProvider;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_entityLocker = entityLocker;
|
||||
}
|
||||
@@ -38,8 +42,7 @@ public class DeleteTraktListHandler : TraktCommandBase, IRequestHandler<DeleteTr
|
||||
{
|
||||
try
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, TraktList> validation = await TraktListMustExist(dbContext, request.TraktListId);
|
||||
return await LanguageExtensions.Apply(validation, c => DoDeletion(dbContext, c));
|
||||
}
|
||||
@@ -56,7 +59,7 @@ public class DeleteTraktListHandler : TraktCommandBase, IRequestHandler<DeleteTr
|
||||
dbContext.TraktLists.Remove(traktList);
|
||||
if (await dbContext.SaveChangesAsync() > 0)
|
||||
{
|
||||
await _searchIndex.RebuildItems(_searchRepository, mediaItemIds);
|
||||
await _searchIndex.RebuildItems(_searchRepository, _fallbackMetadataProvider, mediaItemIds);
|
||||
}
|
||||
|
||||
_searchIndex.Commit();
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Locking;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories.Caching;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Interfaces.Trakt;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
@@ -18,11 +19,17 @@ public class MatchTraktListItemsHandler : TraktCommandBase,
|
||||
|
||||
public MatchTraktListItemsHandler(
|
||||
ITraktApiClient traktApiClient,
|
||||
ISearchRepository searchRepository,
|
||||
ICachingSearchRepository searchRepository,
|
||||
ISearchIndex searchIndex,
|
||||
IFallbackMetadataProvider fallbackMetadataProvider,
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ILogger<MatchTraktListItemsHandler> logger,
|
||||
IEntityLocker entityLocker) : base(traktApiClient, searchRepository, searchIndex, logger)
|
||||
IEntityLocker entityLocker) : base(
|
||||
traktApiClient,
|
||||
searchRepository,
|
||||
searchIndex,
|
||||
fallbackMetadataProvider,
|
||||
logger)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_entityLocker = entityLocker;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories.Caching;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Interfaces.Trakt;
|
||||
using ErsatzTV.Core.Trakt;
|
||||
@@ -13,18 +14,21 @@ namespace ErsatzTV.Application.MediaCollections;
|
||||
|
||||
public abstract class TraktCommandBase
|
||||
{
|
||||
private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
|
||||
private readonly ILogger _logger;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
private readonly ISearchRepository _searchRepository;
|
||||
private readonly ICachingSearchRepository _searchRepository;
|
||||
|
||||
protected TraktCommandBase(
|
||||
ITraktApiClient traktApiClient,
|
||||
ISearchRepository searchRepository,
|
||||
ICachingSearchRepository searchRepository,
|
||||
ISearchIndex searchIndex,
|
||||
IFallbackMetadataProvider fallbackMetadataProvider,
|
||||
ILogger logger)
|
||||
{
|
||||
_searchRepository = searchRepository;
|
||||
_searchIndex = searchIndex;
|
||||
_fallbackMetadataProvider = fallbackMetadataProvider;
|
||||
_logger = logger;
|
||||
TraktApiClient = traktApiClient;
|
||||
}
|
||||
@@ -158,7 +162,7 @@ public abstract class TraktCommandBase
|
||||
|
||||
if (await dbContext.SaveChangesAsync() > 0)
|
||||
{
|
||||
await _searchIndex.RebuildItems(_searchRepository, ids.ToList());
|
||||
await _searchIndex.RebuildItems(_searchRepository, _fallbackMetadataProvider, ids.ToList());
|
||||
}
|
||||
|
||||
_searchIndex.Commit();
|
||||
|
||||
@@ -5,6 +5,7 @@ using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Scheduling;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -37,7 +38,7 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, Playout> validation = await Validate(dbContext, request);
|
||||
return await LanguageExtensions.Apply(validation, playout => ApplyUpdateRequest(dbContext, request, playout));
|
||||
return await validation.Apply(playout => ApplyUpdateRequest(dbContext, request, playout));
|
||||
}
|
||||
|
||||
private async Task<Unit> ApplyUpdateRequest(TvContext dbContext, BuildPlayout request, Playout playout)
|
||||
@@ -45,7 +46,12 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
|
||||
try
|
||||
{
|
||||
await _playoutBuilder.Build(playout, request.Mode);
|
||||
if (await dbContext.SaveChangesAsync() > 0)
|
||||
|
||||
// let any active segmenter processes know that the playout has been modified
|
||||
// and therefore the segmenter may need to seek into the next item instead of
|
||||
// starting at the beginning (if already working ahead)
|
||||
bool hasChanges = await dbContext.SaveChangesAsync() > 0;
|
||||
if (request.Mode != PlayoutBuildMode.Continue && hasChanges)
|
||||
{
|
||||
_ffmpegSegmenterService.PlayoutUpdated(playout.Channel.Number);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Repositories.Caching;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using Humanizer;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -12,16 +13,18 @@ namespace ErsatzTV.Application.Search;
|
||||
public class RebuildSearchIndexHandler : IRequestHandler<RebuildSearchIndex, Unit>
|
||||
{
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly ILogger<RebuildSearchIndexHandler> _logger;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
private readonly ISearchRepository _searchRepository;
|
||||
private readonly ICachingSearchRepository _searchRepository;
|
||||
|
||||
public RebuildSearchIndexHandler(
|
||||
ISearchIndex searchIndex,
|
||||
ISearchRepository searchRepository,
|
||||
ICachingSearchRepository searchRepository,
|
||||
IConfigElementRepository configElementRepository,
|
||||
ILocalFileSystem localFileSystem,
|
||||
IFallbackMetadataProvider fallbackMetadataProvider,
|
||||
ILogger<RebuildSearchIndexHandler> logger)
|
||||
{
|
||||
_searchIndex = searchIndex;
|
||||
@@ -29,14 +32,19 @@ public class RebuildSearchIndexHandler : IRequestHandler<RebuildSearchIndex, Uni
|
||||
_searchRepository = searchRepository;
|
||||
_configElementRepository = configElementRepository;
|
||||
_localFileSystem = localFileSystem;
|
||||
_fallbackMetadataProvider = fallbackMetadataProvider;
|
||||
}
|
||||
|
||||
public async Task<Unit> Handle(RebuildSearchIndex request, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("Initializing search index");
|
||||
|
||||
bool indexFolderExists = Directory.Exists(FileSystemLayout.SearchIndexFolder);
|
||||
|
||||
await _searchIndex.Initialize(_localFileSystem, _configElementRepository);
|
||||
|
||||
_logger.LogInformation("Done initializing search index");
|
||||
|
||||
if (!indexFolderExists ||
|
||||
await _configElementRepository.GetValue<int>(ConfigElementKey.SearchIndexVersion) <
|
||||
_searchIndex.Version)
|
||||
@@ -44,7 +52,7 @@ public class RebuildSearchIndexHandler : IRequestHandler<RebuildSearchIndex, Uni
|
||||
_logger.LogInformation("Migrating search index to version {Version}", _searchIndex.Version);
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
await _searchIndex.Rebuild(_searchRepository);
|
||||
await _searchIndex.Rebuild(_searchRepository, _fallbackMetadataProvider);
|
||||
|
||||
await _configElementRepository.Upsert(ConfigElementKey.SearchIndexVersion, _searchIndex.Version);
|
||||
sw.Stop();
|
||||
|
||||
@@ -3,10 +3,14 @@ using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Extensions;
|
||||
using ErsatzTV.Core.Interfaces.Emby;
|
||||
using ErsatzTV.Core.Interfaces.Jellyfin;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Plex;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Search;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using static ErsatzTV.Application.MediaCards.Mapper;
|
||||
|
||||
namespace ErsatzTV.Application.Search;
|
||||
@@ -14,7 +18,9 @@ namespace ErsatzTV.Application.Search;
|
||||
public class
|
||||
QuerySearchIndexEpisodesHandler : IRequestHandler<QuerySearchIndexEpisodes, TelevisionEpisodeCardResultsViewModel>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly IEmbyPathReplacementService _embyPathReplacementService;
|
||||
private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
|
||||
private readonly IJellyfinPathReplacementService _jellyfinPathReplacementService;
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
private readonly IPlexPathReplacementService _plexPathReplacementService;
|
||||
@@ -27,7 +33,9 @@ public class
|
||||
IMediaSourceRepository mediaSourceRepository,
|
||||
IPlexPathReplacementService plexPathReplacementService,
|
||||
IJellyfinPathReplacementService jellyfinPathReplacementService,
|
||||
IEmbyPathReplacementService embyPathReplacementService)
|
||||
IEmbyPathReplacementService embyPathReplacementService,
|
||||
IFallbackMetadataProvider fallbackMetadataProvider,
|
||||
IDbContextFactory<TvContext> dbContextFactory)
|
||||
{
|
||||
_searchIndex = searchIndex;
|
||||
_televisionRepository = televisionRepository;
|
||||
@@ -35,6 +43,8 @@ public class
|
||||
_plexPathReplacementService = plexPathReplacementService;
|
||||
_jellyfinPathReplacementService = jellyfinPathReplacementService;
|
||||
_embyPathReplacementService = embyPathReplacementService;
|
||||
_fallbackMetadataProvider = fallbackMetadataProvider;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
}
|
||||
|
||||
public async Task<TelevisionEpisodeCardResultsViewModel> Handle(
|
||||
@@ -52,8 +62,40 @@ public class
|
||||
Option<EmbyMediaSource> maybeEmby = await _mediaSourceRepository.GetAllEmby()
|
||||
.Map(list => list.HeadOrNone());
|
||||
|
||||
List<EpisodeMetadata> episodes = await _televisionRepository
|
||||
.GetEpisodesForCards(searchResult.Items.Map(i => i.Id).ToList());
|
||||
var episodeIds = searchResult.Items.Map(i => i.Id).ToList();
|
||||
|
||||
List<EpisodeMetadata> episodes = await _televisionRepository.GetEpisodesForCards(episodeIds);
|
||||
|
||||
// try to load fallback metadata for episodes that have none
|
||||
// this handles an edge case of trashed items with no saved metadata
|
||||
var missingEpisodes = episodeIds.Except(episodes.Map(e => e.EpisodeId)).ToList();
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
foreach (int missingEpisodeId in missingEpisodes)
|
||||
{
|
||||
Option<Episode> maybeEpisode = await dbContext.Episodes
|
||||
.AsNoTracking()
|
||||
.Include(e => e.MediaVersions)
|
||||
.ThenInclude(e => e.MediaFiles)
|
||||
.Include(e => e.Season)
|
||||
.ThenInclude(s => s.SeasonMetadata)
|
||||
.ThenInclude(sm => sm.Artwork)
|
||||
.Include(e => e.Season)
|
||||
.ThenInclude(s => s.Show)
|
||||
.ThenInclude(s => s.ShowMetadata)
|
||||
.ThenInclude(sm => sm.Artwork)
|
||||
.SelectOneAsync(e => e.Id, e => e.Id == missingEpisodeId);
|
||||
|
||||
foreach (Episode episode in maybeEpisode)
|
||||
{
|
||||
foreach (EpisodeMetadata headMetadata in _fallbackMetadataProvider.GetFallbackMetadata(episode)
|
||||
.HeadOrNone())
|
||||
{
|
||||
headMetadata.Episode = episode;
|
||||
episode.EpisodeMetadata = new List<EpisodeMetadata> { headMetadata };
|
||||
episodes.Add(headMetadata);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var items = new List<TelevisionEpisodeCardViewModel>();
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.Search;
|
||||
|
||||
public record QuerySearchTargets : IRequest<List<SearchTargetViewModel>>;
|
||||
@@ -0,0 +1,54 @@
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Search;
|
||||
|
||||
public class QuerySearchTargetsHandler : IRequestHandler<QuerySearchTargets, List<SearchTargetViewModel>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
|
||||
public QuerySearchTargetsHandler(IDbContextFactory<TvContext> dbContextFactory) =>
|
||||
_dbContextFactory = dbContextFactory;
|
||||
|
||||
public async Task<List<SearchTargetViewModel>> Handle(
|
||||
QuerySearchTargets request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = new List<SearchTargetViewModel>();
|
||||
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
result.AddRange(
|
||||
dbContext.Channels
|
||||
.Map(c => new SearchTargetViewModel(c.Id, c.Name, SearchTargetKind.Channel)));
|
||||
result.AddRange(
|
||||
dbContext.FFmpegProfiles
|
||||
.Map(f => new SearchTargetViewModel(f.Id, f.Name, SearchTargetKind.FFmpegProfile)));
|
||||
result.AddRange(
|
||||
dbContext.ChannelWatermarks
|
||||
.Map(w => new SearchTargetViewModel(w.Id, w.Name, SearchTargetKind.ChannelWatermark)));
|
||||
result.AddRange(
|
||||
dbContext.Collections
|
||||
.Map(c => new SearchTargetViewModel(c.Id, c.Name, SearchTargetKind.Collection)));
|
||||
result.AddRange(
|
||||
dbContext.MultiCollections
|
||||
.Map(mc => new SearchTargetViewModel(mc.Id, mc.Name, SearchTargetKind.MultiCollection)));
|
||||
result.AddRange(
|
||||
dbContext.SmartCollections
|
||||
.Map(sc => new SmartCollectionSearchTargetViewModel(sc.Id, sc.Name, sc.Query)));
|
||||
|
||||
var schedules = await dbContext.ProgramSchedules
|
||||
.Map(s => new { s.Id, s.Name })
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
result.AddRange(
|
||||
schedules.SelectMany(
|
||||
s => new[]
|
||||
{
|
||||
new SearchTargetViewModel(s.Id, s.Name, SearchTargetKind.Schedule),
|
||||
new SearchTargetViewModel(s.Id, s.Name, SearchTargetKind.ScheduleItems)
|
||||
}));
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace ErsatzTV.Application.Search;
|
||||
|
||||
public record SearchTargetViewModel(int Id, string Name, SearchTargetKind Kind);
|
||||
|
||||
public record SmartCollectionSearchTargetViewModel(int Id, string Name, string Query)
|
||||
: SearchTargetViewModel(Id, Name, SearchTargetKind.SmartCollection);
|
||||
|
||||
public enum SearchTargetKind
|
||||
{
|
||||
Channel = 1,
|
||||
FFmpegProfile = 2,
|
||||
ChannelWatermark = 3,
|
||||
Collection = 4,
|
||||
MultiCollection = 5,
|
||||
SmartCollection = 6,
|
||||
Schedule = 7,
|
||||
ScheduleItems = 8
|
||||
}
|
||||
@@ -21,12 +21,16 @@ 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();
|
||||
private string _channelNumber;
|
||||
private bool _firstProcess;
|
||||
private bool _hasWrittenSegments;
|
||||
private DateTimeOffset _lastAccess;
|
||||
private DateTimeOffset _lastDelete = DateTimeOffset.MinValue;
|
||||
private bool _seekNextItem;
|
||||
private Option<int> _targetFramerate;
|
||||
private Timer _timer;
|
||||
private DateTimeOffset _transcodedUntil;
|
||||
@@ -34,10 +38,12 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
public HlsSessionWorker(
|
||||
IHlsPlaylistFilter hlsPlaylistFilter,
|
||||
IServiceScopeFactory serviceScopeFactory,
|
||||
ILocalFileSystem localFileSystem,
|
||||
ILogger<HlsSessionWorker> logger)
|
||||
{
|
||||
_hlsPlaylistFilter = hlsPlaylistFilter;
|
||||
_serviceScopeFactory = serviceScopeFactory;
|
||||
_localFileSystem = localFileSystem;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -62,7 +68,19 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
try
|
||||
{
|
||||
Option<string[]> maybeLines = await ReadPlaylistLines(cancellationToken);
|
||||
return maybeLines.Map(input => _hlsPlaylistFilter.TrimPlaylist(PlaylistStart, filterBefore, input));
|
||||
foreach (string[] input in maybeLines)
|
||||
{
|
||||
TrimPlaylistResult trimResult = _hlsPlaylistFilter.TrimPlaylist(PlaylistStart, filterBefore, input);
|
||||
if (DateTimeOffset.Now > _lastDelete.AddSeconds(30))
|
||||
{
|
||||
DeleteOldSegments(trimResult);
|
||||
_lastDelete = DateTimeOffset.Now;
|
||||
}
|
||||
|
||||
return trimResult;
|
||||
}
|
||||
|
||||
return None;
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -70,7 +88,11 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
}
|
||||
}
|
||||
|
||||
public void PlayoutUpdated() => _firstProcess = true;
|
||||
public void PlayoutUpdated()
|
||||
{
|
||||
_firstProcess = true;
|
||||
_seekNextItem = true;
|
||||
}
|
||||
|
||||
public async Task Run(string channelNumber, TimeSpan idleTimeout, CancellationToken incomingCancellationToken)
|
||||
{
|
||||
@@ -153,6 +175,15 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
{
|
||||
_timer.Elapsed -= Cancel;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_localFileSystem.EmptyFolder(Path.Combine(FileSystemLayout.TranscodeFolder, _channelNumber));
|
||||
}
|
||||
catch
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,6 +256,13 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
_logger.LogInformation("HLS process has completed for channel {Channel}", _channelNumber);
|
||||
_transcodedUntil = processModel.Until;
|
||||
_firstProcess = false;
|
||||
if (_seekNextItem)
|
||||
{
|
||||
_firstProcess = true;
|
||||
_seekNextItem = false;
|
||||
}
|
||||
|
||||
_hasWrittenSegments = true;
|
||||
return true;
|
||||
}
|
||||
else
|
||||
@@ -269,6 +307,14 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
if (commandResult.ExitCode == 0)
|
||||
{
|
||||
_firstProcess = false;
|
||||
if (_seekNextItem)
|
||||
{
|
||||
_firstProcess = true;
|
||||
_seekNextItem = false;
|
||||
}
|
||||
|
||||
_hasWrittenSegments = true;
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -322,33 +368,7 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
lines);
|
||||
await WritePlaylist(trimResult.Playlist, cancellationToken);
|
||||
|
||||
// delete old segments
|
||||
var allSegments = Directory.GetFiles(
|
||||
Path.Combine(FileSystemLayout.TranscodeFolder, _channelNumber),
|
||||
"live*.ts")
|
||||
.Map(
|
||||
file =>
|
||||
{
|
||||
string fileName = Path.GetFileName(file);
|
||||
var sequenceNumber = int.Parse(fileName.Replace("live", string.Empty).Split('.')[0]);
|
||||
return new Segment(file, sequenceNumber);
|
||||
})
|
||||
.ToList();
|
||||
|
||||
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);
|
||||
// }
|
||||
|
||||
foreach (Segment segment in toDelete)
|
||||
{
|
||||
File.Delete(segment.File);
|
||||
}
|
||||
DeleteOldSegments(trimResult);
|
||||
|
||||
PlaylistStart = trimResult.PlaylistStart;
|
||||
}
|
||||
@@ -359,7 +379,38 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<long> GetPtsOffset(
|
||||
private void DeleteOldSegments(TrimPlaylistResult trimResult)
|
||||
{
|
||||
// delete old segments
|
||||
var allSegments = Directory.GetFiles(
|
||||
Path.Combine(FileSystemLayout.TranscodeFolder, _channelNumber),
|
||||
"live*.ts")
|
||||
.Map(
|
||||
file =>
|
||||
{
|
||||
string fileName = Path.GetFileName(file);
|
||||
var sequenceNumber = int.Parse(fileName.Replace("live", string.Empty).Split('.')[0]);
|
||||
return new Segment(file, sequenceNumber);
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var toDelete = allSegments.Filter(s => s.SequenceNumber < trimResult.Sequence).ToList();
|
||||
if (toDelete.Count > 0)
|
||||
{
|
||||
_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)
|
||||
{
|
||||
File.Delete(segment.File);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<long> GetPtsOffset(
|
||||
IMediator mediator,
|
||||
string channelNumber,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -369,10 +420,21 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
{
|
||||
long result = 0;
|
||||
|
||||
// if we haven't yet written any segments, start at zero
|
||||
if (!_hasWrittenSegments)
|
||||
{
|
||||
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;
|
||||
|
||||
@@ -30,7 +30,7 @@ public class GetConcatProcessByChannelNumberHandler : FFmpegProcessHandler<GetCo
|
||||
.GetValue<bool>(ConfigElementKey.FFmpegSaveReports)
|
||||
.Map(result => result.IfNone(false));
|
||||
|
||||
Command process = _ffmpegProcessService.ConcatChannel(
|
||||
Command process = await _ffmpegProcessService.ConcatChannel(
|
||||
ffmpegPath,
|
||||
saveReports,
|
||||
channel,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+51
-16
@@ -27,6 +27,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly ILogger<GetPlayoutItemProcessByChannelNumberHandler> _logger;
|
||||
private readonly IMediaCollectionRepository _mediaCollectionRepository;
|
||||
private readonly IMusicVideoCreditsGenerator _musicVideoCreditsGenerator;
|
||||
private readonly IPlexPathReplacementService _plexPathReplacementService;
|
||||
private readonly ISongVideoGenerator _songVideoGenerator;
|
||||
private readonly ITelevisionRepository _televisionRepository;
|
||||
@@ -42,6 +43,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
||||
ITelevisionRepository televisionRepository,
|
||||
IArtistRepository artistRepository,
|
||||
ISongVideoGenerator songVideoGenerator,
|
||||
IMusicVideoCreditsGenerator musicVideoCreditsGenerator,
|
||||
ILogger<GetPlayoutItemProcessByChannelNumberHandler> logger)
|
||||
: base(dbContextFactory)
|
||||
{
|
||||
@@ -54,6 +56,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
||||
_televisionRepository = televisionRepository;
|
||||
_artistRepository = artistRepository;
|
||||
_songVideoGenerator = songVideoGenerator;
|
||||
_musicVideoCreditsGenerator = musicVideoCreditsGenerator;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -96,6 +99,9 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
||||
.ThenInclude(mi => (mi as MusicVideo).MediaVersions)
|
||||
.ThenInclude(mv => mv.Streams)
|
||||
.Include(i => i.MediaItem)
|
||||
.ThenInclude(mi => (mi as MusicVideo).Artist)
|
||||
.ThenInclude(mv => mv.ArtistMetadata)
|
||||
.Include(i => i.MediaItem)
|
||||
.ThenInclude(mi => (mi as OtherVideo).OtherVideoMetadata)
|
||||
.ThenInclude(ovm => ovm.Subtitles)
|
||||
.Include(i => i.MediaItem)
|
||||
@@ -155,7 +161,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
||||
.GetValue<bool>(ConfigElementKey.FFmpegSaveReports)
|
||||
.Map(result => result.IfNone(false));
|
||||
|
||||
List<Subtitle> subtitles = GetSubtitles(playoutItemWithPath);
|
||||
List<Subtitle> subtitles = await GetSubtitles(playoutItemWithPath, channel);
|
||||
|
||||
Command process = await _ffmpegProcessService.ForPlayoutItem(
|
||||
ffmpegPath,
|
||||
@@ -182,7 +188,8 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
||||
playoutItemWithPath.PlayoutItem.InPoint,
|
||||
playoutItemWithPath.PlayoutItem.OutPoint,
|
||||
request.PtsOffset,
|
||||
request.TargetFramerate);
|
||||
request.TargetFramerate,
|
||||
playoutItemWithPath.PlayoutItem.DisableWatermarks);
|
||||
|
||||
var result = new PlayoutItemProcessModel(
|
||||
process,
|
||||
@@ -220,7 +227,9 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
||||
maybeDuration,
|
||||
"Channel is Offline",
|
||||
request.HlsRealtime,
|
||||
request.PtsOffset);
|
||||
request.PtsOffset,
|
||||
channel.FFmpegProfile.VaapiDriver,
|
||||
channel.FFmpegProfile.VaapiDevice);
|
||||
|
||||
return new PlayoutItemProcessModel(offlineProcess, maybeDuration, finish);
|
||||
case PlayoutItemDoesNotExistOnDisk:
|
||||
@@ -230,7 +239,9 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
||||
maybeDuration,
|
||||
error.Value,
|
||||
request.HlsRealtime,
|
||||
request.PtsOffset);
|
||||
request.PtsOffset,
|
||||
channel.FFmpegProfile.VaapiDriver,
|
||||
channel.FFmpegProfile.VaapiDevice);
|
||||
|
||||
return new PlayoutItemProcessModel(doesNotExistProcess, maybeDuration, finish);
|
||||
default:
|
||||
@@ -240,7 +251,9 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
||||
maybeDuration,
|
||||
"Channel is Offline",
|
||||
request.HlsRealtime,
|
||||
request.PtsOffset);
|
||||
request.PtsOffset,
|
||||
channel.FFmpegProfile.VaapiDriver,
|
||||
channel.FFmpegProfile.VaapiDevice);
|
||||
|
||||
return new PlayoutItemProcessModel(errorProcess, maybeDuration, finish);
|
||||
}
|
||||
@@ -249,22 +262,22 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
||||
return BaseError.New($"Unexpected error locating playout item for channel {channel.Number}");
|
||||
}
|
||||
|
||||
private static List<Subtitle> GetSubtitles(PlayoutItemWithPath playoutItemWithPath)
|
||||
private async Task<List<Subtitle>> GetSubtitles(
|
||||
PlayoutItemWithPath playoutItemWithPath,
|
||||
Channel channel)
|
||||
{
|
||||
List<Subtitle> allSubtitles = playoutItemWithPath.PlayoutItem.MediaItem switch
|
||||
{
|
||||
Episode episode => Optional(episode.EpisodeMetadata).Flatten().HeadOrNone()
|
||||
Episode episode => await Optional(episode.EpisodeMetadata).Flatten().HeadOrNone()
|
||||
.Map(mm => mm.Subtitles ?? new List<Subtitle>())
|
||||
.IfNone(new List<Subtitle>()),
|
||||
Movie movie => Optional(movie.MovieMetadata).Flatten().HeadOrNone()
|
||||
.IfNoneAsync(new List<Subtitle>()),
|
||||
Movie movie => await Optional(movie.MovieMetadata).Flatten().HeadOrNone()
|
||||
.Map(mm => mm.Subtitles ?? new List<Subtitle>())
|
||||
.IfNone(new List<Subtitle>()),
|
||||
MusicVideo musicVideo => Optional(musicVideo.MusicVideoMetadata).Flatten().HeadOrNone()
|
||||
.IfNoneAsync(new List<Subtitle>()),
|
||||
MusicVideo musicVideo => await GetMusicVideoSubtitles(musicVideo, channel),
|
||||
OtherVideo otherVideo => await Optional(otherVideo.OtherVideoMetadata).Flatten().HeadOrNone()
|
||||
.Map(mm => mm.Subtitles ?? new List<Subtitle>())
|
||||
.IfNone(new List<Subtitle>()),
|
||||
OtherVideo otherVideo => Optional(otherVideo.OtherVideoMetadata).Flatten().HeadOrNone()
|
||||
.Map(mm => mm.Subtitles ?? new List<Subtitle>())
|
||||
.IfNone(new List<Subtitle>()),
|
||||
.IfNoneAsync(new List<Subtitle>()),
|
||||
_ => new List<Subtitle>()
|
||||
};
|
||||
|
||||
@@ -302,6 +315,27 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
||||
return allSubtitles;
|
||||
}
|
||||
|
||||
private async Task<List<Subtitle>> GetMusicVideoSubtitles(MusicVideo musicVideo, Channel channel)
|
||||
{
|
||||
var subtitles = new List<Subtitle>();
|
||||
|
||||
bool musicVideoCredits = channel.MusicVideoCreditsMode == ChannelMusicVideoCreditsMode.GenerateSubtitles;
|
||||
if (musicVideoCredits)
|
||||
{
|
||||
subtitles.AddRange(
|
||||
await _musicVideoCreditsGenerator.GenerateCreditsSubtitle(musicVideo, channel.FFmpegProfile));
|
||||
}
|
||||
else
|
||||
{
|
||||
subtitles.AddRange(
|
||||
await Optional(musicVideo.MusicVideoMetadata).Flatten().HeadOrNone()
|
||||
.Map(mm => mm.Subtitles)
|
||||
.IfNoneAsync(new List<Subtitle>()));
|
||||
}
|
||||
|
||||
return subtitles;
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, PlayoutItemWithPath>> CheckForFallbackFiller(
|
||||
TvContext dbContext,
|
||||
Channel channel,
|
||||
@@ -371,7 +405,8 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
||||
Finish = finish.UtcDateTime,
|
||||
FillerKind = FillerKind.Fallback,
|
||||
InPoint = TimeSpan.Zero,
|
||||
OutPoint = version.Duration
|
||||
OutPoint = version.Duration,
|
||||
DisableWatermarks = !fallbackPreset.AllowWatermarks
|
||||
};
|
||||
|
||||
return await ValidatePlayoutItemPath(playoutItem);
|
||||
|
||||
@@ -7,21 +7,21 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Bugsnag" Version="3.0.1" />
|
||||
<PackageReference Include="Bugsnag" Version="3.1.0" />
|
||||
<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.2.9" />
|
||||
<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" />
|
||||
@@ -31,18 +31,28 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ErsatzTV.Core\ErsatzTV.Core.csproj" />
|
||||
<ProjectReference Include="..\ErsatzTV.Infrastructure\ErsatzTV.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<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")
|
||||
|
||||
@@ -10,6 +10,8 @@ using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Images;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
using ErsatzTV.FFmpeg;
|
||||
using ErsatzTV.FFmpeg.Capabilities;
|
||||
using ErsatzTV.FFmpeg.State;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
@@ -17,6 +19,7 @@ using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using NUnit.Framework;
|
||||
using Serilog;
|
||||
using MediaStream = ErsatzTV.Core.Domain.MediaStream;
|
||||
|
||||
namespace ErsatzTV.Core.Tests.FFmpeg;
|
||||
|
||||
@@ -302,6 +305,10 @@ public class TranscodingTests
|
||||
new FFmpegPlaybackSettingsCalculator(),
|
||||
new FakeStreamSelector(),
|
||||
new Mock<ITempFilePool>().Object,
|
||||
new FakeNvidiaCapabilitiesFactory(),
|
||||
// new HardwareCapabilitiesFactory(
|
||||
// new MemoryCache(new MemoryCacheOptions()),
|
||||
// LoggerFactory.CreateLogger<HardwareCapabilitiesFactory>()),
|
||||
LoggerFactory.CreateLogger<FFmpegLibraryProcessService>());
|
||||
|
||||
var v = new MediaVersion
|
||||
@@ -489,7 +496,8 @@ public class TranscodingTests
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.FromSeconds(5),
|
||||
0,
|
||||
None);
|
||||
None,
|
||||
false);
|
||||
|
||||
// Console.WriteLine($"ffmpeg arguments {string.Join(" ", process.StartInfo.ArgumentList)}");
|
||||
|
||||
@@ -565,15 +573,21 @@ public class TranscodingTests
|
||||
Optional(version.Streams.First(s => s.MediaStreamKind == MediaStreamKind.Audio)).AsTask();
|
||||
|
||||
public Task<Option<Domain.Subtitle>> SelectSubtitleStream(
|
||||
MediaVersion version,
|
||||
List<Domain.Subtitle> subtitles,
|
||||
StreamingMode streamingMode,
|
||||
string channelNumber,
|
||||
Channel channel,
|
||||
string preferredSubtitleLanguage,
|
||||
ChannelSubtitleMode subtitleMode) =>
|
||||
subtitles.HeadOrNone().AsTask();
|
||||
}
|
||||
|
||||
private class FakeNvidiaCapabilitiesFactory : IHardwareCapabilitiesFactory
|
||||
{
|
||||
public Task<IHardwareCapabilities> GetHardwareCapabilities(
|
||||
string ffmpegPath,
|
||||
HardwareAccelerationMode hardwareAccelerationMode) =>
|
||||
Task.FromResult<IHardwareCapabilities>(new NvidiaHardwareCapabilities(61, string.Empty));
|
||||
}
|
||||
|
||||
private static string ExecutableName(string baseName) =>
|
||||
OperatingSystem.IsWindows() ? $"{baseName}.exe" : baseName;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
using ErsatzTV.Core.Plex;
|
||||
|
||||
namespace ErsatzTV.Core.Tests.Fakes;
|
||||
|
||||
@@ -42,8 +41,7 @@ public class FakeTelevisionRepository : ITelevisionRepository
|
||||
public Task<Option<Show>> GetShowByMetadata(int libraryPathId, ShowMetadata metadata) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public Task<Either<BaseError, MediaItemScanResult<Show>>>
|
||||
AddShow(int libraryPathId, string showFolder, ShowMetadata metadata) =>
|
||||
public Task<Either<BaseError, MediaItemScanResult<Show>>> AddShow(int libraryPathId, ShowMetadata metadata) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public Task<Either<BaseError, Season>> GetOrAddSeason(Show show, int libraryPathId, int seasonNumber) =>
|
||||
@@ -74,36 +72,11 @@ public class FakeTelevisionRepository : ITelevisionRepository
|
||||
public Task<bool> AddDirector(EpisodeMetadata metadata, Director director) => throw new NotSupportedException();
|
||||
|
||||
public Task<bool> AddWriter(EpisodeMetadata metadata, Writer writer) => throw new NotSupportedException();
|
||||
public Task<Unit> UpdatePath(int mediaFileId, string path) => throw new NotSupportedException();
|
||||
|
||||
public Task<Either<BaseError, MediaItemScanResult<PlexShow>>> GetOrAddPlexShow(
|
||||
PlexLibrary library,
|
||||
PlexShow item) =>
|
||||
public Task<bool> UpdateTitles(EpisodeMetadata metadata, string title, string sortTitle) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public Task<Either<BaseError, PlexSeason>> GetOrAddPlexSeason(PlexLibrary library, PlexSeason item) =>
|
||||
throw new NotSupportedException();
|
||||
public Task<bool> UpdateOutline(EpisodeMetadata metadata, string outline) => throw new NotSupportedException();
|
||||
|
||||
public Task<Either<BaseError, MediaItemScanResult<PlexEpisode>>> GetOrAddPlexEpisode(
|
||||
PlexLibrary library,
|
||||
PlexEpisode item) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public Task<List<int>> RemoveMissingPlexShows(PlexLibrary library, List<string> showKeys) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public Task<Unit> RemoveMissingPlexSeasons(string showKey, List<string> seasonKeys) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public Task<List<int>> RemoveMissingPlexEpisodes(string seasonKey, List<string> episodeKeys) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public Task<Unit> SetPlexEtag(PlexShow show, string etag) => throw new NotSupportedException();
|
||||
|
||||
public Task<Unit> SetPlexEtag(PlexSeason season, string etag) => throw new NotSupportedException();
|
||||
|
||||
public Task<Unit> SetPlexEtag(PlexEpisode episode, string etag) => throw new NotSupportedException();
|
||||
|
||||
public Task<List<PlexItemEtag>> GetExistingPlexEpisodes(PlexLibrary library, PlexSeason season) =>
|
||||
throw new NotSupportedException();
|
||||
public Task<bool> UpdatePlot(EpisodeMetadata metadata, string plot) => throw new NotSupportedException();
|
||||
}
|
||||
|
||||
@@ -229,6 +229,38 @@ public class JellyfinPathReplacementServiceTests
|
||||
result.Should().Be(@"/mnt/something else/Some Shared Folder/Some Movie/Some Movie.mkv");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task JellyfinLinux_To_EtvLinux_UncPath()
|
||||
{
|
||||
var replacements = new List<JellyfinPathReplacement>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = 1,
|
||||
JellyfinPath = @"\\192.168.1.100\Something\Some Shared Folder",
|
||||
LocalPath = @"/mnt/something else/Some Shared Folder",
|
||||
JellyfinMediaSource = new JellyfinMediaSource { OperatingSystem = "Linux" }
|
||||
}
|
||||
};
|
||||
|
||||
var repo = new Mock<IMediaSourceRepository>();
|
||||
repo.Setup(x => x.GetJellyfinPathReplacementsByLibraryId(It.IsAny<int>())).Returns(replacements.AsTask());
|
||||
|
||||
var runtime = new Mock<IRuntimeInfo>();
|
||||
runtime.Setup(x => x.IsOSPlatform(OSPlatform.Windows)).Returns(false);
|
||||
|
||||
var service = new JellyfinPathReplacementService(
|
||||
repo.Object,
|
||||
runtime.Object,
|
||||
new Mock<ILogger<JellyfinPathReplacementService>>().Object);
|
||||
|
||||
string result = await service.GetReplacementJellyfinPath(
|
||||
0,
|
||||
@"\\192.168.1.100\Something\Some Shared Folder\Some Movie\Some Movie.mkv");
|
||||
|
||||
result.Should().Be(@"/mnt/something else/Some Shared Folder/Some Movie/Some Movie.mkv");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Not_Throw_For_Null_JellyfinPath()
|
||||
{
|
||||
|
||||
@@ -40,6 +40,13 @@ public class FallbackMetadataProviderTests
|
||||
"Awesome.Show.S01E02.Description.more.Description.QUAlity.codec.CODEC-GROUP.mkv",
|
||||
1,
|
||||
2)]
|
||||
[TestCase("Awesome Show - s01.e02.mkv", 1, 2)]
|
||||
[TestCase("Awesome Show - S01.E02.mkv", 1, 2)]
|
||||
[TestCase("Awesome Show - s01_e02.mkv", 1, 2)]
|
||||
[TestCase("Awesome Show - S01_E02.mkv", 1, 2)]
|
||||
[TestCase("Awesome Show - s01xe02.mkv", 1, 2)]
|
||||
[TestCase("Awesome Show - S01XE02.mkv", 1, 2)]
|
||||
[TestCase("Awesome Show - 1x02.mkv", 1, 2)]
|
||||
public void GetFallbackMetadata_ShouldHandleVariousFormats(string path, int season, int episode)
|
||||
{
|
||||
List<EpisodeMetadata> metadata = _fallbackMetadataProvider.GetFallbackMetadata(
|
||||
|
||||
@@ -5,6 +5,7 @@ using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Images;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Repositories.Caching;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
using ErsatzTV.Core.Tests.Fakes;
|
||||
@@ -622,7 +623,8 @@ public class MovieFolderScannerTests
|
||||
new Mock<IMetadataRepository>().Object,
|
||||
_imageCache.Object,
|
||||
new Mock<ISearchIndex>().Object,
|
||||
new Mock<ISearchRepository>().Object,
|
||||
new Mock<ICachingSearchRepository>().Object,
|
||||
new Mock<IFallbackMetadataProvider>().Object,
|
||||
new Mock<ILibraryRepository>().Object,
|
||||
_mediaItemRepository.Object,
|
||||
new Mock<IMediator>().Object,
|
||||
@@ -642,7 +644,8 @@ public class MovieFolderScannerTests
|
||||
new Mock<IMetadataRepository>().Object,
|
||||
_imageCache.Object,
|
||||
new Mock<ISearchIndex>().Object,
|
||||
new Mock<ISearchRepository>().Object,
|
||||
new Mock<ICachingSearchRepository>().Object,
|
||||
new Mock<IFallbackMetadataProvider>().Object,
|
||||
new Mock<ILibraryRepository>().Object,
|
||||
_mediaItemRepository.Object,
|
||||
new Mock<IMediator>().Object,
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
using Bugsnag;
|
||||
using ErsatzTV.Core.Metadata.Nfo;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.IO;
|
||||
using Moq;
|
||||
using NUnit.Framework;
|
||||
using Serilog;
|
||||
|
||||
namespace ErsatzTV.Core.Tests.Metadata.Nfo;
|
||||
|
||||
@@ -11,7 +14,24 @@ namespace ErsatzTV.Core.Tests.Metadata.Nfo;
|
||||
public class ArtistNfoReaderTests
|
||||
{
|
||||
[SetUp]
|
||||
public void SetUp() => _artistNfoReader = new ArtistNfoReader(new Mock<IClient>().Object);
|
||||
public void SetUp() => _artistNfoReader = new ArtistNfoReader(
|
||||
new RecyclableMemoryStreamManager(),
|
||||
new Mock<IClient>().Object,
|
||||
_logger);
|
||||
|
||||
private readonly ILogger<ArtistNfoReader> _logger;
|
||||
|
||||
public ArtistNfoReaderTests()
|
||||
{
|
||||
Log.Logger = new LoggerConfiguration()
|
||||
.MinimumLevel.Debug()
|
||||
.WriteTo.Console()
|
||||
.CreateLogger();
|
||||
|
||||
ILoggerFactory loggerFactory = new LoggerFactory().AddSerilog(Log.Logger);
|
||||
|
||||
_logger = loggerFactory.CreateLogger<ArtistNfoReader>();
|
||||
}
|
||||
|
||||
private ArtistNfoReader _artistNfoReader;
|
||||
|
||||
@@ -153,6 +173,42 @@ Joel attended Hicksville High School in 1967, but he did not graduate with his c
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Invalid_Characters_End_Should_Abort_And_Return_Nfo()
|
||||
{
|
||||
string sourceFile = Path.Combine(
|
||||
TestContext.CurrentContext.TestDirectory,
|
||||
"Resources",
|
||||
"Nfo",
|
||||
"ArtistInvalidCharacters1.nfo");
|
||||
Either<BaseError, ArtistNfo> result = await _artistNfoReader.ReadFromFile(sourceFile);
|
||||
|
||||
result.IsRight.Should().BeTrue();
|
||||
foreach (ArtistNfo nfo in result.RightToSeq())
|
||||
{
|
||||
nfo.Name.Should().Be("Test Name");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Invalid_Characters_Middle_Should_Continue_And_Return_Nfo()
|
||||
{
|
||||
string sourceFile = Path.Combine(
|
||||
TestContext.CurrentContext.TestDirectory,
|
||||
"Resources",
|
||||
"Nfo",
|
||||
"ArtistInvalidCharacters2.nfo");
|
||||
Either<BaseError, ArtistNfo> result = await _artistNfoReader.ReadFromFile(sourceFile);
|
||||
|
||||
result.IsRight.Should().BeTrue();
|
||||
foreach (ArtistNfo nfo in result.RightToSeq())
|
||||
{
|
||||
nfo.Name.Should().Be("Test Name");
|
||||
nfo.Moods.Should().BeEquivalentTo(new List<string> { "Test Mood" });
|
||||
nfo.Styles.Count.Should().Be(1);
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizeLineEndingsLF(string str) =>
|
||||
str
|
||||
.Replace("\r\n", "\n")
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
using Bugsnag;
|
||||
using ErsatzTV.Core.Metadata.Nfo;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.IO;
|
||||
using Moq;
|
||||
using NUnit.Framework;
|
||||
using Serilog;
|
||||
|
||||
namespace ErsatzTV.Core.Tests.Metadata.Nfo;
|
||||
|
||||
@@ -11,7 +14,24 @@ namespace ErsatzTV.Core.Tests.Metadata.Nfo;
|
||||
public class EpisodeNfoReaderTests
|
||||
{
|
||||
[SetUp]
|
||||
public void SetUp() => _episodeNfoReader = new EpisodeNfoReader(new Mock<IClient>().Object);
|
||||
public void SetUp() => _episodeNfoReader = new EpisodeNfoReader(
|
||||
new RecyclableMemoryStreamManager(),
|
||||
new Mock<IClient>().Object,
|
||||
_logger);
|
||||
|
||||
private readonly ILogger<EpisodeNfoReader> _logger;
|
||||
|
||||
public EpisodeNfoReaderTests()
|
||||
{
|
||||
Log.Logger = new LoggerConfiguration()
|
||||
.MinimumLevel.Debug()
|
||||
.WriteTo.Console()
|
||||
.CreateLogger();
|
||||
|
||||
ILoggerFactory loggerFactory = new LoggerFactory().AddSerilog(Log.Logger);
|
||||
|
||||
_logger = loggerFactory.CreateLogger<EpisodeNfoReader>();
|
||||
}
|
||||
|
||||
private EpisodeNfoReader _episodeNfoReader;
|
||||
|
||||
@@ -404,4 +424,22 @@ public class EpisodeNfoReaderTests
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Invalid_Characters_Should_Abort_And_Return_Nfo()
|
||||
{
|
||||
string sourceFile = Path.Combine(
|
||||
TestContext.CurrentContext.TestDirectory,
|
||||
"Resources",
|
||||
"Nfo",
|
||||
"EpisodeInvalidCharacters.nfo");
|
||||
Either<BaseError, List<TvShowEpisodeNfo>> result = await _episodeNfoReader.ReadFromFile(sourceFile);
|
||||
|
||||
result.IsRight.Should().BeTrue();
|
||||
foreach (List<TvShowEpisodeNfo> list in result.RightToSeq())
|
||||
{
|
||||
list.Count.Should().Be(1);
|
||||
list[0].Title.Should().Be("Test Title");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
using Bugsnag;
|
||||
using ErsatzTV.Core.Metadata.Nfo;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.IO;
|
||||
using Moq;
|
||||
using NUnit.Framework;
|
||||
|
||||
@@ -11,7 +13,10 @@ namespace ErsatzTV.Core.Tests.Metadata.Nfo;
|
||||
public class MovieNfoReaderTests
|
||||
{
|
||||
[SetUp]
|
||||
public void SetUp() => _movieNfoReader = new MovieNfoReader(new Mock<IClient>().Object);
|
||||
public void SetUp() => _movieNfoReader = new MovieNfoReader(
|
||||
new RecyclableMemoryStreamManager(),
|
||||
new Mock<IClient>().Object,
|
||||
new NullLogger<MovieNfoReader>());
|
||||
|
||||
private MovieNfoReader _movieNfoReader;
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
using Bugsnag;
|
||||
using ErsatzTV.Core.Metadata.Nfo;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.IO;
|
||||
using Moq;
|
||||
using NUnit.Framework;
|
||||
|
||||
@@ -11,7 +13,10 @@ namespace ErsatzTV.Core.Tests.Metadata.Nfo;
|
||||
public class MusicVideoNfoReaderTests
|
||||
{
|
||||
[SetUp]
|
||||
public void SetUp() => _musicVideoNfoReader = new MusicVideoNfoReader(new Mock<IClient>().Object);
|
||||
public void SetUp() => _musicVideoNfoReader = new MusicVideoNfoReader(
|
||||
new RecyclableMemoryStreamManager(),
|
||||
new Mock<IClient>().Object,
|
||||
new NullLogger<MusicVideoNfoReader>());
|
||||
|
||||
private MusicVideoNfoReader _musicVideoNfoReader;
|
||||
|
||||
@@ -124,6 +129,7 @@ Le groupe a également enregistré une version espagnole de ce titre, La reina d
|
||||
nfo.Year.Should().Be(1976);
|
||||
nfo.Aired.IsNone.Should().BeTrue();
|
||||
nfo.Genres.Should().BeEquivalentTo(new List<string> { "Pop" });
|
||||
nfo.Track.Should().Be(-1);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
using Bugsnag;
|
||||
using ErsatzTV.Core.Metadata.Nfo;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.IO;
|
||||
using Moq;
|
||||
using NUnit.Framework;
|
||||
|
||||
@@ -11,7 +13,10 @@ namespace ErsatzTV.Core.Tests.Metadata.Nfo;
|
||||
public class OtherVideoNfoReaderTests
|
||||
{
|
||||
[SetUp]
|
||||
public void SetUp() => _otherVideoNfoReader = new OtherVideoNfoReader(new Mock<IClient>().Object);
|
||||
public void SetUp() => _otherVideoNfoReader = new OtherVideoNfoReader(
|
||||
new RecyclableMemoryStreamManager(),
|
||||
new Mock<IClient>().Object,
|
||||
new NullLogger<OtherVideoNfoReader>());
|
||||
|
||||
private OtherVideoNfoReader _otherVideoNfoReader;
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
using Bugsnag;
|
||||
using ErsatzTV.Core.Metadata.Nfo;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.IO;
|
||||
using Moq;
|
||||
using NUnit.Framework;
|
||||
|
||||
@@ -11,7 +13,10 @@ namespace ErsatzTV.Core.Tests.Metadata.Nfo;
|
||||
public class TvShowNfoReaderTests
|
||||
{
|
||||
[SetUp]
|
||||
public void SetUp() => _tvShowNfoReader = new TvShowNfoReader(new Mock<IClient>().Object);
|
||||
public void SetUp() => _tvShowNfoReader = new TvShowNfoReader(
|
||||
new RecyclableMemoryStreamManager(),
|
||||
new Mock<IClient>().Object,
|
||||
new NullLogger<TvShowNfoReader>());
|
||||
|
||||
private TvShowNfoReader _tvShowNfoReader;
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<!--created on whatever - comment-->
|
||||
<artist>
|
||||
<name>Test Name</name>
|
||||
</artist>
|
||||
ÐPS½NÞ5Þ*¡¡ã·Ýq×ÍâeVk¯¬}É
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<!--created on whatever - comment-->
|
||||
<artist>
|
||||
<name>Test Name</name>
|
||||
<style>ÐPS½NÞ5Þ*¡¡ã·Ýq×ÍâeVk¯¬}É</style>
|
||||
<mood>Test Mood</mood>
|
||||
</artist>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<!--created on whatever - comment-->
|
||||
<episodedetails>
|
||||
<title>Test Title</title>
|
||||
</episodedetails>
|
||||
ÐPS½NÞ5Þ*¡¡ã·Ýq×ÍâeVk¯¬}É
|
||||
@@ -68,6 +68,190 @@ public class PlayoutBuilderTests
|
||||
result.Items.Head().MediaItemId.Should().Be(2);
|
||||
result.Items.Head().StartOffset.TimeOfDay.Should().Be(TimeSpan.Zero);
|
||||
result.Items.Head().FinishOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(6));
|
||||
|
||||
result.Anchor.NextStartOffset.Should().Be(HoursAfterMidnight(6));
|
||||
}
|
||||
|
||||
[Test]
|
||||
[Timeout(2000)]
|
||||
public async Task OnlyFileNotFoundItem_Should_Abort()
|
||||
{
|
||||
var mediaItems = new List<MediaItem>
|
||||
{
|
||||
TestMovie(1, TimeSpan.FromHours(1), DateTime.Today)
|
||||
};
|
||||
|
||||
mediaItems[0].State = MediaItemState.FileNotFound;
|
||||
|
||||
var configRepo = new Mock<IConfigElementRepository>();
|
||||
configRepo.Setup(
|
||||
repo => repo.GetValue<bool>(
|
||||
It.Is<ConfigElementKey>(k => k.Key == ConfigElementKey.PlayoutSkipMissingItems.Key)))
|
||||
.ReturnsAsync(Some(true));
|
||||
|
||||
(PlayoutBuilder builder, Playout playout) =
|
||||
TestDataFloodForItems(mediaItems, PlaybackOrder.Random, configRepo);
|
||||
|
||||
Playout result = await builder.Build(playout, PlayoutBuildMode.Reset);
|
||||
|
||||
configRepo.Verify();
|
||||
|
||||
result.Items.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task FileNotFoundItem_Should_BeSkipped()
|
||||
{
|
||||
var mediaItems = new List<MediaItem>
|
||||
{
|
||||
TestMovie(1, TimeSpan.FromHours(1), DateTime.Today),
|
||||
TestMovie(2, TimeSpan.FromHours(6), DateTime.Today)
|
||||
};
|
||||
|
||||
mediaItems[0].State = MediaItemState.FileNotFound;
|
||||
|
||||
var configRepo = new Mock<IConfigElementRepository>();
|
||||
configRepo.Setup(
|
||||
repo => repo.GetValue<bool>(
|
||||
It.Is<ConfigElementKey>(k => k.Key == ConfigElementKey.PlayoutSkipMissingItems.Key)))
|
||||
.ReturnsAsync(Some(true));
|
||||
|
||||
(PlayoutBuilder builder, Playout playout) =
|
||||
TestDataFloodForItems(mediaItems, PlaybackOrder.Random, configRepo);
|
||||
DateTimeOffset start = HoursAfterMidnight(0);
|
||||
DateTimeOffset finish = start + TimeSpan.FromHours(6);
|
||||
|
||||
Playout result = await builder.Build(playout, PlayoutBuildMode.Reset, start, finish);
|
||||
|
||||
configRepo.Verify();
|
||||
|
||||
result.Items.Count.Should().Be(1);
|
||||
result.Items.Head().MediaItemId.Should().Be(2);
|
||||
result.Items.Head().StartOffset.TimeOfDay.Should().Be(TimeSpan.Zero);
|
||||
result.Items.Head().FinishOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(6));
|
||||
|
||||
result.Anchor.NextStartOffset.Should().Be(HoursAfterMidnight(6));
|
||||
}
|
||||
|
||||
[Test]
|
||||
[Timeout(2000)]
|
||||
public async Task OnlyUnavailableItem_Should_Abort()
|
||||
{
|
||||
var mediaItems = new List<MediaItem>
|
||||
{
|
||||
TestMovie(1, TimeSpan.FromHours(1), DateTime.Today)
|
||||
};
|
||||
|
||||
mediaItems[0].State = MediaItemState.Unavailable;
|
||||
|
||||
var configRepo = new Mock<IConfigElementRepository>();
|
||||
configRepo.Setup(
|
||||
repo => repo.GetValue<bool>(
|
||||
It.Is<ConfigElementKey>(k => k.Key == ConfigElementKey.PlayoutSkipMissingItems.Key)))
|
||||
.ReturnsAsync(Some(true));
|
||||
|
||||
(PlayoutBuilder builder, Playout playout) =
|
||||
TestDataFloodForItems(mediaItems, PlaybackOrder.Random, configRepo);
|
||||
|
||||
Playout result = await builder.Build(playout, PlayoutBuildMode.Reset);
|
||||
|
||||
configRepo.Verify();
|
||||
|
||||
result.Items.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task UnavailableItem_Should_BeSkipped()
|
||||
{
|
||||
var mediaItems = new List<MediaItem>
|
||||
{
|
||||
TestMovie(1, TimeSpan.FromHours(1), DateTime.Today),
|
||||
TestMovie(2, TimeSpan.FromHours(6), DateTime.Today)
|
||||
};
|
||||
|
||||
mediaItems[0].State = MediaItemState.Unavailable;
|
||||
|
||||
var configRepo = new Mock<IConfigElementRepository>();
|
||||
configRepo.Setup(
|
||||
repo => repo.GetValue<bool>(
|
||||
It.Is<ConfigElementKey>(k => k.Key == ConfigElementKey.PlayoutSkipMissingItems.Key)))
|
||||
.ReturnsAsync(Some(true));
|
||||
|
||||
(PlayoutBuilder builder, Playout playout) =
|
||||
TestDataFloodForItems(mediaItems, PlaybackOrder.Random, configRepo);
|
||||
DateTimeOffset start = HoursAfterMidnight(0);
|
||||
DateTimeOffset finish = start + TimeSpan.FromHours(6);
|
||||
|
||||
Playout result = await builder.Build(playout, PlayoutBuildMode.Reset, start, finish);
|
||||
|
||||
configRepo.Verify();
|
||||
|
||||
result.Items.Count.Should().Be(1);
|
||||
result.Items.Head().MediaItemId.Should().Be(2);
|
||||
result.Items.Head().StartOffset.TimeOfDay.Should().Be(TimeSpan.Zero);
|
||||
result.Items.Head().FinishOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(6));
|
||||
|
||||
result.Anchor.NextStartOffset.Should().Be(HoursAfterMidnight(6));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task FileNotFound_Should_NotBeSkippedIfConfigured()
|
||||
{
|
||||
var mediaItems = new List<MediaItem>
|
||||
{
|
||||
TestMovie(1, TimeSpan.FromHours(6), DateTime.Today)
|
||||
};
|
||||
|
||||
mediaItems[0].State = MediaItemState.FileNotFound;
|
||||
|
||||
var configRepo = new Mock<IConfigElementRepository>();
|
||||
configRepo.Setup(
|
||||
repo => repo.GetValue<bool>(
|
||||
It.Is<ConfigElementKey>(k => k.Key == ConfigElementKey.PlayoutSkipMissingItems.Key)))
|
||||
.ReturnsAsync(Some(false));
|
||||
|
||||
(PlayoutBuilder builder, Playout playout) =
|
||||
TestDataFloodForItems(mediaItems, PlaybackOrder.Random, configRepo);
|
||||
DateTimeOffset start = HoursAfterMidnight(0);
|
||||
DateTimeOffset finish = start + TimeSpan.FromHours(6);
|
||||
|
||||
Playout result = await builder.Build(playout, PlayoutBuildMode.Reset, start, finish);
|
||||
|
||||
result.Items.Count.Should().Be(1);
|
||||
result.Items.Head().StartOffset.TimeOfDay.Should().Be(TimeSpan.Zero);
|
||||
result.Items.Head().FinishOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(6));
|
||||
|
||||
result.Anchor.NextStartOffset.Should().Be(HoursAfterMidnight(6));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Unavailable_Should_NotBeSkippedIfConfigured()
|
||||
{
|
||||
var mediaItems = new List<MediaItem>
|
||||
{
|
||||
TestMovie(1, TimeSpan.FromHours(6), DateTime.Today)
|
||||
};
|
||||
|
||||
mediaItems[0].State = MediaItemState.Unavailable;
|
||||
|
||||
var configRepo = new Mock<IConfigElementRepository>();
|
||||
configRepo.Setup(
|
||||
repo => repo.GetValue<bool>(
|
||||
It.Is<ConfigElementKey>(k => k.Key == ConfigElementKey.PlayoutSkipMissingItems.Key)))
|
||||
.ReturnsAsync(Some(false));
|
||||
|
||||
(PlayoutBuilder builder, Playout playout) =
|
||||
TestDataFloodForItems(mediaItems, PlaybackOrder.Random, configRepo);
|
||||
DateTimeOffset start = HoursAfterMidnight(0);
|
||||
DateTimeOffset finish = start + TimeSpan.FromHours(6);
|
||||
|
||||
Playout result = await builder.Build(playout, PlayoutBuildMode.Reset, start, finish);
|
||||
|
||||
result.Items.Count.Should().Be(1);
|
||||
result.Items.Head().StartOffset.TimeOfDay.Should().Be(TimeSpan.Zero);
|
||||
result.Items.Head().FinishOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(6));
|
||||
|
||||
result.Anchor.NextStartOffset.Should().Be(HoursAfterMidnight(6));
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -87,6 +271,8 @@ public class PlayoutBuilderTests
|
||||
result.Items.Count.Should().Be(1);
|
||||
result.Items.Head().StartOffset.TimeOfDay.Should().Be(TimeSpan.Zero);
|
||||
result.Items.Head().FinishOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(6));
|
||||
|
||||
result.Anchor.NextStartOffset.Should().Be(HoursAfterMidnight(6));
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -107,6 +293,8 @@ public class PlayoutBuilderTests
|
||||
result.Items[0].StartOffset.TimeOfDay.Should().Be(TimeSpan.Zero);
|
||||
result.Items[1].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(6));
|
||||
result.Items[1].FinishOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(12));
|
||||
|
||||
result.Anchor.NextStartOffset.Should().Be(HoursAfterMidnight(12));
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -134,6 +322,8 @@ public class PlayoutBuilderTests
|
||||
result.Items[2].MediaItemId.Should().Be(1);
|
||||
result.Items[3].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(3));
|
||||
result.Items[3].MediaItemId.Should().Be(2);
|
||||
|
||||
result.Anchor.NextStartOffset.Should().Be(HoursAfterMidnight(4));
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -155,7 +345,7 @@ public class PlayoutBuilderTests
|
||||
result.Items.Count.Should().Be(1);
|
||||
result.Items.Head().MediaItemId.Should().Be(1);
|
||||
|
||||
result.Anchor.NextStartOffset.Should().Be(DateTime.Today.AddHours(6));
|
||||
result.Anchor.NextStartOffset.Should().Be(HoursAfterMidnight(6));
|
||||
|
||||
result.ProgramScheduleAnchors.Count.Should().Be(1);
|
||||
result.ProgramScheduleAnchors.Head().EnumeratorState.Index.Should().Be(1);
|
||||
@@ -169,7 +359,7 @@ public class PlayoutBuilderTests
|
||||
result2.Items.Last().StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(6));
|
||||
result2.Items.Last().MediaItemId.Should().Be(2);
|
||||
|
||||
result2.Anchor.NextStartOffset.Should().Be(DateTime.Today.AddHours(12));
|
||||
result2.Anchor.NextStartOffset.Should().Be(HoursAfterMidnight(12));
|
||||
result2.ProgramScheduleAnchors.Count.Should().Be(1);
|
||||
result2.ProgramScheduleAnchors.Head().EnumeratorState.Index.Should().Be(0);
|
||||
}
|
||||
@@ -278,6 +468,8 @@ public class PlayoutBuilderTests
|
||||
|
||||
int firstSeedValue = result.ProgramScheduleAnchors.Head().EnumeratorState.Seed;
|
||||
|
||||
result.Anchor.NextStartOffset.Should().Be(HoursAfterMidnight(6));
|
||||
|
||||
DateTimeOffset start2 = HoursAfterMidnight(0);
|
||||
DateTimeOffset finish2 = start2 + TimeSpan.FromHours(6);
|
||||
|
||||
@@ -286,6 +478,8 @@ public class PlayoutBuilderTests
|
||||
int secondSeedValue = result2.ProgramScheduleAnchors.Head().EnumeratorState.Seed;
|
||||
|
||||
firstSeedValue.Should().Be(secondSeedValue);
|
||||
|
||||
result2.Anchor.NextStartOffset.Should().Be(HoursAfterMidnight(6));
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -374,6 +568,144 @@ public class PlayoutBuilderTests
|
||||
result.Items[3].MediaItemId.Should().Be(3);
|
||||
result.Items[4].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(5));
|
||||
result.Items[4].MediaItemId.Should().Be(2);
|
||||
|
||||
result.Anchor.NextStartOffset.Should().Be(HoursAfterMidnight(6));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task FloodContent_Should_FloodAroundFixedContent_One_Multiple_Days()
|
||||
{
|
||||
var floodCollection = new Collection
|
||||
{
|
||||
Id = 1,
|
||||
Name = "Flood Items",
|
||||
MediaItems = new List<MediaItem>
|
||||
{
|
||||
TestMovie(1, TimeSpan.FromHours(1), new DateTime(2020, 1, 1)),
|
||||
TestMovie(2, TimeSpan.FromHours(1), new DateTime(2020, 2, 1))
|
||||
}
|
||||
};
|
||||
|
||||
var fixedCollection = new Collection
|
||||
{
|
||||
Id = 2,
|
||||
Name = "Fixed Items",
|
||||
MediaItems = new List<MediaItem>
|
||||
{
|
||||
TestMovie(3, TimeSpan.FromHours(2), new DateTime(2020, 1, 1))
|
||||
}
|
||||
};
|
||||
|
||||
var fakeRepository = new FakeMediaCollectionRepository(
|
||||
Map(
|
||||
(floodCollection.Id, floodCollection.MediaItems.ToList()),
|
||||
(fixedCollection.Id, fixedCollection.MediaItems.ToList())));
|
||||
|
||||
var items = new List<ProgramScheduleItem>
|
||||
{
|
||||
new ProgramScheduleItemFlood
|
||||
{
|
||||
Index = 1,
|
||||
Collection = floodCollection,
|
||||
CollectionId = floodCollection.Id,
|
||||
StartTime = null,
|
||||
PlaybackOrder = PlaybackOrder.Chronological
|
||||
},
|
||||
new ProgramScheduleItemOne
|
||||
{
|
||||
Index = 2,
|
||||
Collection = fixedCollection,
|
||||
CollectionId = fixedCollection.Id,
|
||||
StartTime = TimeSpan.FromHours(3),
|
||||
PlaybackOrder = PlaybackOrder.Chronological
|
||||
}
|
||||
};
|
||||
|
||||
var playout = new Playout
|
||||
{
|
||||
ProgramSchedule = new ProgramSchedule
|
||||
{
|
||||
Items = items
|
||||
},
|
||||
Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" },
|
||||
ProgramScheduleAnchors = new List<PlayoutProgramScheduleAnchor>(),
|
||||
Items = new List<PlayoutItem>()
|
||||
};
|
||||
|
||||
var configRepo = new Mock<IConfigElementRepository>();
|
||||
var televisionRepo = new FakeTelevisionRepository();
|
||||
var artistRepo = new Mock<IArtistRepository>();
|
||||
var builder = new PlayoutBuilder(
|
||||
configRepo.Object,
|
||||
fakeRepository,
|
||||
televisionRepo,
|
||||
artistRepo.Object,
|
||||
_logger);
|
||||
|
||||
DateTimeOffset start = HoursAfterMidnight(0);
|
||||
DateTimeOffset finish = start + TimeSpan.FromHours(30);
|
||||
|
||||
Playout result = await builder.Build(playout, PlayoutBuildMode.Reset, start, finish);
|
||||
|
||||
result.Items.Count.Should().Be(28);
|
||||
result.Items[0].StartOffset.TimeOfDay.Should().Be(TimeSpan.Zero);
|
||||
result.Items[0].MediaItemId.Should().Be(1);
|
||||
result.Items[1].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(1));
|
||||
result.Items[1].MediaItemId.Should().Be(2);
|
||||
result.Items[2].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(2));
|
||||
result.Items[2].MediaItemId.Should().Be(1);
|
||||
result.Items[3].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(3));
|
||||
result.Items[3].MediaItemId.Should().Be(3);
|
||||
result.Items[4].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(5));
|
||||
result.Items[4].MediaItemId.Should().Be(2);
|
||||
result.Items[5].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(6));
|
||||
result.Items[5].MediaItemId.Should().Be(1);
|
||||
result.Items[6].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(7));
|
||||
result.Items[6].MediaItemId.Should().Be(2);
|
||||
result.Items[7].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(8));
|
||||
result.Items[7].MediaItemId.Should().Be(1);
|
||||
result.Items[8].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(9));
|
||||
result.Items[8].MediaItemId.Should().Be(2);
|
||||
result.Items[9].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(10));
|
||||
result.Items[9].MediaItemId.Should().Be(1);
|
||||
result.Items[10].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(11));
|
||||
result.Items[10].MediaItemId.Should().Be(2);
|
||||
result.Items[11].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(12));
|
||||
result.Items[11].MediaItemId.Should().Be(1);
|
||||
result.Items[12].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(13));
|
||||
result.Items[12].MediaItemId.Should().Be(2);
|
||||
result.Items[13].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(14));
|
||||
result.Items[13].MediaItemId.Should().Be(1);
|
||||
result.Items[14].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(15));
|
||||
result.Items[14].MediaItemId.Should().Be(2);
|
||||
result.Items[15].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(16));
|
||||
result.Items[15].MediaItemId.Should().Be(1);
|
||||
result.Items[16].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(17));
|
||||
result.Items[16].MediaItemId.Should().Be(2);
|
||||
result.Items[17].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(18));
|
||||
result.Items[17].MediaItemId.Should().Be(1);
|
||||
result.Items[18].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(19));
|
||||
result.Items[18].MediaItemId.Should().Be(2);
|
||||
result.Items[19].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(20));
|
||||
result.Items[19].MediaItemId.Should().Be(1);
|
||||
result.Items[20].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(21));
|
||||
result.Items[20].MediaItemId.Should().Be(2);
|
||||
result.Items[21].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(22));
|
||||
result.Items[21].MediaItemId.Should().Be(1);
|
||||
result.Items[22].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(23));
|
||||
result.Items[22].MediaItemId.Should().Be(2);
|
||||
result.Items[23].StartOffset.TimeOfDay.Should().Be(TimeSpan.Zero);
|
||||
result.Items[23].MediaItemId.Should().Be(1);
|
||||
result.Items[24].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(1));
|
||||
result.Items[24].MediaItemId.Should().Be(2);
|
||||
result.Items[25].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(2));
|
||||
result.Items[25].MediaItemId.Should().Be(1);
|
||||
result.Items[26].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(3));
|
||||
result.Items[26].MediaItemId.Should().Be(3);
|
||||
result.Items[27].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(5));
|
||||
result.Items[27].MediaItemId.Should().Be(2);
|
||||
|
||||
result.Anchor.NextStartOffset.Should().Be(HoursAfterMidnight(30));
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -469,6 +801,8 @@ public class PlayoutBuilderTests
|
||||
|
||||
result.Items[5].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(6));
|
||||
result.Items[5].MediaItemId.Should().Be(2);
|
||||
|
||||
result.Anchor.NextStartOffset.Should().Be(HoursAfterMidnight(7));
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -562,6 +896,8 @@ public class PlayoutBuilderTests
|
||||
|
||||
result.Items[5].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(12));
|
||||
result.Items[5].MediaItemId.Should().Be(3);
|
||||
|
||||
result.Anchor.NextStartOffset.Should().Be(HoursAfterMidnight(31));
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -666,6 +1002,8 @@ public class PlayoutBuilderTests
|
||||
result.Items[4].MediaItemId.Should().Be(2);
|
||||
|
||||
result.Anchor.InFlood.Should().BeTrue();
|
||||
|
||||
result.Anchor.NextStartOffset.Should().Be(HoursAfterMidnight(32));
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -765,6 +1103,8 @@ public class PlayoutBuilderTests
|
||||
result.Items[5].MediaItemId.Should().Be(1);
|
||||
result.Items[6].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(5.75));
|
||||
result.Items[6].MediaItemId.Should().Be(2);
|
||||
|
||||
result.Anchor.NextStartOffset.Should().Be(DateTime.Today.AddHours(6.75));
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -863,6 +1203,8 @@ public class PlayoutBuilderTests
|
||||
|
||||
result.Items[5].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(4.75));
|
||||
result.Items[5].MediaItemId.Should().Be(4);
|
||||
|
||||
result.Anchor.NextStartOffset.Should().Be(DateTime.Today.AddHours(6.25));
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -967,6 +1309,7 @@ public class PlayoutBuilderTests
|
||||
|
||||
result.Anchor.ScheduleItemsEnumeratorState.Index.Should().Be(1);
|
||||
result.Anchor.MultipleRemaining.Should().Be(1);
|
||||
result.Anchor.NextStartOffset.Should().Be(HoursAfterMidnight(5));
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -1066,6 +1409,7 @@ public class PlayoutBuilderTests
|
||||
|
||||
result.Anchor.ScheduleItemsEnumeratorState.Index.Should().Be(0);
|
||||
result.Anchor.MultipleRemaining.Should().BeNull();
|
||||
result.Anchor.NextStartOffset.Should().Be(HoursAfterMidnight(5));
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -1172,6 +1516,7 @@ public class PlayoutBuilderTests
|
||||
|
||||
result.Anchor.ScheduleItemsEnumeratorState.Index.Should().Be(1);
|
||||
result.Anchor.DurationFinish.Should().Be(HoursAfterMidnight(6).UtcDateTime);
|
||||
result.Anchor.NextStartOffset.Should().Be(HoursAfterMidnight(5));
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -1309,6 +1654,96 @@ public class PlayoutBuilderTests
|
||||
|
||||
result.Anchor.ScheduleItemsEnumeratorState.Index.Should().Be(0);
|
||||
result.Anchor.DurationFinish.Should().BeNull();
|
||||
|
||||
result.Anchor.NextStartOffset.Should().Be(HoursAfterMidnight(6));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Multiple_With_Filler_Should_Keep_Filler_After_End_Of_Playout()
|
||||
{
|
||||
var collectionOne = new Collection
|
||||
{
|
||||
Id = 1,
|
||||
Name = "Duration Items 1",
|
||||
MediaItems = new List<MediaItem>
|
||||
{
|
||||
TestMovie(1, TimeSpan.FromMinutes(61), new DateTime(2020, 1, 1))
|
||||
}
|
||||
};
|
||||
|
||||
var collectionTwo = new Collection
|
||||
{
|
||||
Id = 2,
|
||||
Name = "Filler Items",
|
||||
MediaItems = new List<MediaItem>
|
||||
{
|
||||
TestMovie(2, TimeSpan.FromMinutes(4), new DateTime(2020, 1, 1))
|
||||
}
|
||||
};
|
||||
|
||||
var fakeRepository = new FakeMediaCollectionRepository(
|
||||
Map(
|
||||
(collectionOne.Id, collectionOne.MediaItems.ToList()),
|
||||
(collectionTwo.Id, collectionTwo.MediaItems.ToList())));
|
||||
|
||||
var items = new List<ProgramScheduleItem>
|
||||
{
|
||||
new ProgramScheduleItemMultiple
|
||||
{
|
||||
Id = 1,
|
||||
Index = 1,
|
||||
Collection = collectionOne,
|
||||
CollectionId = collectionOne.Id,
|
||||
StartTime = null,
|
||||
Count = 1,
|
||||
PlaybackOrder = PlaybackOrder.Chronological,
|
||||
PostRollFiller = new FillerPreset
|
||||
{
|
||||
FillerKind = FillerKind.PostRoll,
|
||||
Collection = collectionTwo,
|
||||
CollectionId = collectionTwo.Id,
|
||||
FillerMode = FillerMode.Count,
|
||||
Count = 1
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var playout = new Playout
|
||||
{
|
||||
ProgramSchedule = new ProgramSchedule
|
||||
{
|
||||
Items = items
|
||||
},
|
||||
Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" },
|
||||
ProgramScheduleAnchors = new List<PlayoutProgramScheduleAnchor>(),
|
||||
Items = new List<PlayoutItem>()
|
||||
};
|
||||
|
||||
var configRepo = new Mock<IConfigElementRepository>();
|
||||
var televisionRepo = new FakeTelevisionRepository();
|
||||
var artistRepo = new Mock<IArtistRepository>();
|
||||
var builder = new PlayoutBuilder(
|
||||
configRepo.Object,
|
||||
fakeRepository,
|
||||
televisionRepo,
|
||||
artistRepo.Object,
|
||||
_logger);
|
||||
|
||||
DateTimeOffset start = HoursAfterMidnight(0);
|
||||
DateTimeOffset finish = start + TimeSpan.FromHours(1);
|
||||
|
||||
Playout result = await builder.Build(playout, PlayoutBuildMode.Reset, start, finish);
|
||||
|
||||
result.Items.Count.Should().Be(2);
|
||||
|
||||
result.Items[0].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromMinutes(0));
|
||||
result.Items[0].MediaItemId.Should().Be(1);
|
||||
result.Items[1].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromMinutes(61));
|
||||
result.Items[1].MediaItemId.Should().Be(2);
|
||||
|
||||
result.Anchor.ScheduleItemsEnumeratorState.Index.Should().Be(0);
|
||||
result.Anchor.DurationFinish.Should().BeNull();
|
||||
result.Anchor.NextStartOffset.Should().Be(DateTime.Today.AddMinutes(65));
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -1388,6 +1823,7 @@ public class PlayoutBuilderTests
|
||||
|
||||
result.Anchor.ScheduleItemsEnumeratorState.Index.Should().Be(0);
|
||||
result.Anchor.DurationFinish.Should().BeNull();
|
||||
result.Anchor.NextStartOffset.Should().Be(HoursAfterMidnight(6));
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -1438,6 +1874,8 @@ public class PlayoutBuilderTests
|
||||
|
||||
int seed = result.ProgramScheduleAnchors[0].EnumeratorState.Seed;
|
||||
result.ProgramScheduleAnchors.All(a => a.EnumeratorState.Seed == seed).Should().BeTrue();
|
||||
|
||||
result.Anchor.NextStartOffset.Should().Be(DateTime.Today.AddDays(2));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1590,6 +2028,8 @@ public class PlayoutBuilderTests
|
||||
result.Items[3].MediaItemId.Should().Be(2);
|
||||
result.Items[3].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(18));
|
||||
result.Items[3].FinishOffset.TimeOfDay.Should().Be(TimeSpan.Zero);
|
||||
|
||||
result.Anchor.NextStartOffset.Should().Be(HoursAfterMidnight(48));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1795,7 +2235,7 @@ public class PlayoutBuilderTests
|
||||
DateTimeOffset start2 = HoursAfterMidnight(0);
|
||||
DateTimeOffset finish2 = start2 + TimeSpan.FromHours(6);
|
||||
|
||||
Playout result2 = await builder.Build(playout, PlayoutBuildMode.Continue, start2, finish2);
|
||||
Playout result2 = await builder.Build(result, PlayoutBuildMode.Continue, start2, finish2);
|
||||
|
||||
int secondSeedValue = result2.ProgramScheduleAnchors.Head().EnumeratorState.Seed;
|
||||
|
||||
@@ -1804,6 +2244,57 @@ public class PlayoutBuilderTests
|
||||
result2.ProgramScheduleAnchors.Head().EnumeratorState.Index.Should().Be(0);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ShuffleFlood_Should_MaintainRandomSeed_MultipleDays()
|
||||
{
|
||||
var mediaItems = new List<MediaItem>();
|
||||
for (int i = 1; i <= 25; i++)
|
||||
{
|
||||
mediaItems.Add(TestMovie(i, TimeSpan.FromMinutes(55), DateTime.Today.AddHours(i)));
|
||||
}
|
||||
|
||||
(PlayoutBuilder builder, Playout playout) = TestDataFloodForItems(mediaItems, PlaybackOrder.Shuffle);
|
||||
DateTimeOffset start = HoursAfterMidnight(0).AddSeconds(5);
|
||||
DateTimeOffset finish = start + TimeSpan.FromDays(2);
|
||||
|
||||
Playout result = await builder.Build(playout, PlayoutBuildMode.Reset, start, finish);
|
||||
|
||||
result.Items.Count.Should().Be(53);
|
||||
result.ProgramScheduleAnchors.Count.Should().Be(2);
|
||||
|
||||
result.ProgramScheduleAnchors.All(x => x.AnchorDate is not null).Should().BeTrue();
|
||||
PlayoutProgramScheduleAnchor lastCheckpoint = result.ProgramScheduleAnchors
|
||||
.OrderByDescending(a => a.AnchorDate ?? DateTime.MinValue)
|
||||
.First();
|
||||
lastCheckpoint.EnumeratorState.Seed.Should().BeGreaterThan(0);
|
||||
lastCheckpoint.EnumeratorState.Index.Should().Be(3);
|
||||
|
||||
// we need to mess up the ordering to trigger the problematic behavior
|
||||
// this simulates the way the rows are loaded with EF
|
||||
PlayoutProgramScheduleAnchor oldest = result.ProgramScheduleAnchors.OrderByDescending(a => a.AnchorDate).Last();
|
||||
PlayoutProgramScheduleAnchor newest = result.ProgramScheduleAnchors.OrderByDescending(a => a.AnchorDate).First();
|
||||
|
||||
result.ProgramScheduleAnchors = new List<PlayoutProgramScheduleAnchor>
|
||||
{
|
||||
oldest,
|
||||
newest
|
||||
};
|
||||
|
||||
int firstSeedValue = lastCheckpoint.EnumeratorState.Seed;
|
||||
|
||||
DateTimeOffset start2 = start.AddHours(1);
|
||||
DateTimeOffset finish2 = start2 + TimeSpan.FromDays(2);
|
||||
|
||||
Playout result2 = await builder.Build(result, PlayoutBuildMode.Continue, start2, finish2);
|
||||
|
||||
PlayoutProgramScheduleAnchor continueAnchor =
|
||||
result2.ProgramScheduleAnchors.First(x => x.AnchorDate is null);
|
||||
int secondSeedValue = continueAnchor.EnumeratorState.Seed;
|
||||
|
||||
// the continue anchor should have the same seed as the most recent (last) checkpoint from the first run
|
||||
firstSeedValue.Should().Be(secondSeedValue);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task FloodContent_Should_FloodWithFixedStartTime_FromAnchor()
|
||||
{
|
||||
@@ -1906,6 +2397,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 +2502,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 +2609,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 +2636,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 +2657,8 @@ public class PlayoutBuilderTests
|
||||
MediaItems = mediaItems
|
||||
};
|
||||
|
||||
var configRepo = new Mock<IConfigElementRepository>();
|
||||
Mock<IConfigElementRepository> configRepo = configMock ?? new Mock<IConfigElementRepository>();
|
||||
|
||||
var collectionRepo = new FakeMediaCollectionRepository(Map((mediaCollection.Id, mediaItems)));
|
||||
var televisionRepo = new FakeTelevisionRepository();
|
||||
var artistRepo = new Mock<IArtistRepository>();
|
||||
|
||||
@@ -25,7 +25,8 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
|
||||
StartTime = null,
|
||||
PlayoutDuration = TimeSpan.FromHours(3),
|
||||
TailMode = TailMode.None,
|
||||
PlaybackOrder = PlaybackOrder.Chronological
|
||||
PlaybackOrder = PlaybackOrder.Chronological,
|
||||
CustomTitle = "CustomTitle"
|
||||
};
|
||||
|
||||
var scheduleItemsEnumerator = new OrderedScheduleItemsEnumerator(
|
||||
@@ -49,7 +50,7 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
|
||||
playoutBuilderState.CurrentTime.Should().Be(startState.CurrentTime.AddHours(3));
|
||||
playoutItems.Last().FinishOffset.Should().Be(playoutBuilderState.CurrentTime);
|
||||
|
||||
playoutBuilderState.NextGuideGroup.Should().Be(4);
|
||||
playoutBuilderState.NextGuideGroup.Should().Be(2); // one guide group here because of custom title
|
||||
playoutBuilderState.DurationFinish.IsNone.Should().BeTrue();
|
||||
playoutBuilderState.InFlood.Should().BeFalse();
|
||||
playoutBuilderState.MultipleRemaining.IsNone.Should().BeTrue();
|
||||
@@ -65,18 +66,21 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
|
||||
playoutItems[0].GuideGroup.Should().Be(1);
|
||||
playoutItems[0].FillerKind.Should().Be(FillerKind.None);
|
||||
playoutItems[0].GuideFinish.HasValue.Should().BeFalse();
|
||||
playoutItems[0].CustomTitle.Should().Be("CustomTitle");
|
||||
|
||||
playoutItems[1].MediaItemId.Should().Be(2);
|
||||
playoutItems[1].StartOffset.Should().Be(startState.CurrentTime.AddHours(1));
|
||||
playoutItems[1].GuideGroup.Should().Be(2);
|
||||
playoutItems[1].GuideGroup.Should().Be(1);
|
||||
playoutItems[1].FillerKind.Should().Be(FillerKind.None);
|
||||
playoutItems[1].GuideFinish.HasValue.Should().BeFalse();
|
||||
playoutItems[1].CustomTitle.Should().Be("CustomTitle");
|
||||
|
||||
playoutItems[2].MediaItemId.Should().Be(1);
|
||||
playoutItems[2].StartOffset.Should().Be(startState.CurrentTime.AddHours(2));
|
||||
playoutItems[2].GuideGroup.Should().Be(3);
|
||||
playoutItems[2].GuideGroup.Should().Be(1);
|
||||
playoutItems[2].FillerKind.Should().Be(FillerKind.None);
|
||||
playoutItems[2].GuideFinish.HasValue.Should().BeTrue();
|
||||
playoutItems[2].CustomTitle.Should().Be("CustomTitle");
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -691,4 +695,60 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
|
||||
playoutItems[6].FillerKind.Should().Be(FillerKind.Fallback);
|
||||
playoutItems[6].GuideFinish.HasValue.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_Not_Schedule_At_HardStop()
|
||||
{
|
||||
Collection collectionOne = TwoItemCollection(1, 2, TimeSpan.FromMinutes(55));
|
||||
|
||||
var scheduleItem = new ProgramScheduleItemDuration
|
||||
{
|
||||
Id = 1,
|
||||
Index = 1,
|
||||
Collection = collectionOne,
|
||||
CollectionId = collectionOne.Id,
|
||||
StartTime = TimeSpan.FromHours(6),
|
||||
PlaybackOrder = PlaybackOrder.Chronological,
|
||||
TailFiller = null,
|
||||
FallbackFiller = null,
|
||||
PlayoutDuration = TimeSpan.FromHours(1)
|
||||
};
|
||||
|
||||
var enumerator = new ChronologicalMediaCollectionEnumerator(
|
||||
collectionOne.MediaItems,
|
||||
new CollectionEnumeratorState());
|
||||
|
||||
var sortedScheduleItems = new List<ProgramScheduleItem>
|
||||
{
|
||||
scheduleItem,
|
||||
NextScheduleItem
|
||||
};
|
||||
|
||||
var scheduleItemsEnumerator = new OrderedScheduleItemsEnumerator(
|
||||
sortedScheduleItems,
|
||||
new CollectionEnumeratorState());
|
||||
|
||||
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
|
||||
|
||||
var scheduler = new PlayoutModeSchedulerDuration(new Mock<ILogger>().Object);
|
||||
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule(
|
||||
startState,
|
||||
CollectionEnumerators(scheduleItem, enumerator),
|
||||
scheduleItem,
|
||||
NextScheduleItem,
|
||||
HardStop(scheduleItemsEnumerator));
|
||||
|
||||
playoutItems.Should().BeEmpty();
|
||||
|
||||
playoutBuilderState.CurrentTime.Should().Be(HardStop(scheduleItemsEnumerator));
|
||||
|
||||
playoutBuilderState.NextGuideGroup.Should().Be(1);
|
||||
playoutBuilderState.DurationFinish.IsNone.Should().BeTrue();
|
||||
playoutBuilderState.InFlood.Should().BeFalse();
|
||||
playoutBuilderState.MultipleRemaining.IsNone.Should().BeTrue();
|
||||
playoutBuilderState.InDurationFiller.Should().BeFalse();
|
||||
playoutBuilderState.ScheduleItemsEnumerator.State.Index.Should().Be(0);
|
||||
|
||||
enumerator.State.Index.Should().Be(0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,8 @@ public class PlayoutModeSchedulerFloodTests : SchedulerTestBase
|
||||
StartTime = null,
|
||||
PlaybackOrder = PlaybackOrder.Chronological,
|
||||
TailFiller = null,
|
||||
FallbackFiller = null
|
||||
FallbackFiller = null,
|
||||
CustomTitle = "CustomTitle"
|
||||
};
|
||||
|
||||
var enumerator = new ChronologicalMediaCollectionEnumerator(
|
||||
@@ -55,7 +56,7 @@ public class PlayoutModeSchedulerFloodTests : SchedulerTestBase
|
||||
playoutBuilderState.CurrentTime.Should().Be(startState.CurrentTime.AddHours(3));
|
||||
playoutItems.Last().FinishOffset.Should().Be(playoutBuilderState.CurrentTime);
|
||||
|
||||
playoutBuilderState.NextGuideGroup.Should().Be(4);
|
||||
playoutBuilderState.NextGuideGroup.Should().Be(2); // one guide group here because of custom title
|
||||
playoutBuilderState.DurationFinish.IsNone.Should().BeTrue();
|
||||
playoutBuilderState.InFlood.Should().BeFalse();
|
||||
playoutBuilderState.MultipleRemaining.IsNone.Should().BeTrue();
|
||||
@@ -70,16 +71,19 @@ public class PlayoutModeSchedulerFloodTests : SchedulerTestBase
|
||||
playoutItems[0].StartOffset.Should().Be(startState.CurrentTime);
|
||||
playoutItems[0].GuideGroup.Should().Be(1);
|
||||
playoutItems[0].FillerKind.Should().Be(FillerKind.None);
|
||||
playoutItems[0].CustomTitle.Should().Be("CustomTitle");
|
||||
|
||||
playoutItems[1].MediaItemId.Should().Be(2);
|
||||
playoutItems[1].StartOffset.Should().Be(startState.CurrentTime.AddHours(1));
|
||||
playoutItems[1].GuideGroup.Should().Be(2);
|
||||
playoutItems[1].GuideGroup.Should().Be(1);
|
||||
playoutItems[1].FillerKind.Should().Be(FillerKind.None);
|
||||
playoutItems[1].CustomTitle.Should().Be("CustomTitle");
|
||||
|
||||
playoutItems[2].MediaItemId.Should().Be(1);
|
||||
playoutItems[2].StartOffset.Should().Be(startState.CurrentTime.AddHours(2));
|
||||
playoutItems[2].GuideGroup.Should().Be(3);
|
||||
playoutItems[2].GuideGroup.Should().Be(1);
|
||||
playoutItems[2].FillerKind.Should().Be(FillerKind.None);
|
||||
playoutItems[2].CustomTitle.Should().Be("CustomTitle");
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -827,6 +831,61 @@ public class PlayoutModeSchedulerFloodTests : SchedulerTestBase
|
||||
playoutItems[2].FillerKind.Should().Be(FillerKind.None);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_Not_Schedule_At_HardStop()
|
||||
{
|
||||
Collection collectionOne = TwoItemCollection(1, 2, TimeSpan.FromMinutes(55));
|
||||
|
||||
var scheduleItem = new ProgramScheduleItemFlood
|
||||
{
|
||||
Id = 1,
|
||||
Index = 1,
|
||||
Collection = collectionOne,
|
||||
CollectionId = collectionOne.Id,
|
||||
StartTime = TimeSpan.FromHours(6),
|
||||
PlaybackOrder = PlaybackOrder.Chronological,
|
||||
TailFiller = null,
|
||||
FallbackFiller = null
|
||||
};
|
||||
|
||||
var enumerator = new ChronologicalMediaCollectionEnumerator(
|
||||
collectionOne.MediaItems,
|
||||
new CollectionEnumeratorState());
|
||||
|
||||
var sortedScheduleItems = new List<ProgramScheduleItem>
|
||||
{
|
||||
scheduleItem,
|
||||
NextScheduleItem
|
||||
};
|
||||
|
||||
var scheduleItemsEnumerator = new OrderedScheduleItemsEnumerator(
|
||||
sortedScheduleItems,
|
||||
new CollectionEnumeratorState());
|
||||
|
||||
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
|
||||
|
||||
var scheduler = new PlayoutModeSchedulerFlood(new Mock<ILogger>().Object);
|
||||
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule(
|
||||
startState,
|
||||
CollectionEnumerators(scheduleItem, enumerator),
|
||||
scheduleItem,
|
||||
NextScheduleItem,
|
||||
HardStop(scheduleItemsEnumerator));
|
||||
|
||||
playoutItems.Should().BeEmpty();
|
||||
|
||||
playoutBuilderState.CurrentTime.Should().Be(HardStop(scheduleItemsEnumerator));
|
||||
|
||||
playoutBuilderState.NextGuideGroup.Should().Be(1);
|
||||
playoutBuilderState.DurationFinish.IsNone.Should().BeTrue();
|
||||
playoutBuilderState.InFlood.Should().BeFalse();
|
||||
playoutBuilderState.MultipleRemaining.IsNone.Should().BeTrue();
|
||||
playoutBuilderState.InDurationFiller.Should().BeFalse();
|
||||
playoutBuilderState.ScheduleItemsEnumerator.State.Index.Should().Be(0);
|
||||
|
||||
enumerator.State.Index.Should().Be(0);
|
||||
}
|
||||
|
||||
protected override ProgramScheduleItem NextScheduleItem => new ProgramScheduleItemOne
|
||||
{
|
||||
StartTime = TimeSpan.FromHours(3)
|
||||
|
||||
@@ -27,7 +27,8 @@ public class PlayoutModeSchedulerMultipleTests : SchedulerTestBase
|
||||
PlaybackOrder = PlaybackOrder.Chronological,
|
||||
TailFiller = null,
|
||||
FallbackFiller = null,
|
||||
Count = 3
|
||||
Count = 3,
|
||||
CustomTitle = "CustomTitle"
|
||||
};
|
||||
|
||||
var scheduleItemsEnumerator = new OrderedScheduleItemsEnumerator(
|
||||
@@ -56,7 +57,7 @@ public class PlayoutModeSchedulerMultipleTests : SchedulerTestBase
|
||||
playoutBuilderState.CurrentTime.Should().Be(startState.CurrentTime.AddHours(3));
|
||||
playoutItems.Last().FinishOffset.Should().Be(playoutBuilderState.CurrentTime);
|
||||
|
||||
playoutBuilderState.NextGuideGroup.Should().Be(4);
|
||||
playoutBuilderState.NextGuideGroup.Should().Be(2); // one guide group here because of custom title
|
||||
playoutBuilderState.DurationFinish.IsNone.Should().BeTrue();
|
||||
playoutBuilderState.InFlood.Should().BeFalse();
|
||||
playoutBuilderState.MultipleRemaining.IsNone.Should().BeTrue();
|
||||
@@ -71,16 +72,19 @@ public class PlayoutModeSchedulerMultipleTests : SchedulerTestBase
|
||||
playoutItems[0].StartOffset.Should().Be(startState.CurrentTime);
|
||||
playoutItems[0].GuideGroup.Should().Be(1);
|
||||
playoutItems[0].FillerKind.Should().Be(FillerKind.None);
|
||||
playoutItems[0].CustomTitle.Should().Be("CustomTitle");
|
||||
|
||||
playoutItems[1].MediaItemId.Should().Be(2);
|
||||
playoutItems[1].StartOffset.Should().Be(startState.CurrentTime.AddHours(1));
|
||||
playoutItems[1].GuideGroup.Should().Be(2);
|
||||
playoutItems[1].GuideGroup.Should().Be(1);
|
||||
playoutItems[1].FillerKind.Should().Be(FillerKind.None);
|
||||
playoutItems[1].CustomTitle.Should().Be("CustomTitle");
|
||||
|
||||
playoutItems[2].MediaItemId.Should().Be(1);
|
||||
playoutItems[2].StartOffset.Should().Be(startState.CurrentTime.AddHours(2));
|
||||
playoutItems[2].GuideGroup.Should().Be(3);
|
||||
playoutItems[2].GuideGroup.Should().Be(1);
|
||||
playoutItems[2].FillerKind.Should().Be(FillerKind.None);
|
||||
playoutItems[2].CustomTitle.Should().Be("CustomTitle");
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -660,6 +664,67 @@ public class PlayoutModeSchedulerMultipleTests : SchedulerTestBase
|
||||
playoutItems[2].FillerKind.Should().Be(FillerKind.None);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_Not_Schedule_At_HardStop()
|
||||
{
|
||||
Collection collectionOne = TwoItemCollection(1, 2, TimeSpan.FromMinutes(55));
|
||||
|
||||
var scheduleItem = new ProgramScheduleItemMultiple
|
||||
{
|
||||
Id = 1,
|
||||
Index = 1,
|
||||
Collection = collectionOne,
|
||||
CollectionId = collectionOne.Id,
|
||||
StartTime = TimeSpan.FromHours(6),
|
||||
PlaybackOrder = PlaybackOrder.Chronological,
|
||||
TailFiller = null,
|
||||
FallbackFiller = null,
|
||||
Count = 2
|
||||
};
|
||||
|
||||
var enumerator = new ChronologicalMediaCollectionEnumerator(
|
||||
collectionOne.MediaItems,
|
||||
new CollectionEnumeratorState());
|
||||
|
||||
var sortedScheduleItems = new List<ProgramScheduleItem>
|
||||
{
|
||||
scheduleItem,
|
||||
NextScheduleItem
|
||||
};
|
||||
|
||||
var scheduleItemsEnumerator = new OrderedScheduleItemsEnumerator(
|
||||
sortedScheduleItems,
|
||||
new CollectionEnumeratorState());
|
||||
|
||||
var collectionMediaItems = new Dictionary<CollectionKey, List<MediaItem>>
|
||||
{
|
||||
{ CollectionKey.ForScheduleItem(scheduleItem), collectionOne.MediaItems }
|
||||
}.ToMap();
|
||||
|
||||
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
|
||||
|
||||
var scheduler = new PlayoutModeSchedulerMultiple(collectionMediaItems, new Mock<ILogger>().Object);
|
||||
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule(
|
||||
startState,
|
||||
CollectionEnumerators(scheduleItem, enumerator),
|
||||
scheduleItem,
|
||||
NextScheduleItem,
|
||||
HardStop(scheduleItemsEnumerator));
|
||||
|
||||
playoutItems.Should().BeEmpty();
|
||||
|
||||
playoutBuilderState.CurrentTime.Should().Be(HardStop(scheduleItemsEnumerator));
|
||||
|
||||
playoutBuilderState.NextGuideGroup.Should().Be(1);
|
||||
playoutBuilderState.DurationFinish.IsNone.Should().BeTrue();
|
||||
playoutBuilderState.InFlood.Should().BeFalse();
|
||||
playoutBuilderState.MultipleRemaining.IsNone.Should().BeTrue();
|
||||
playoutBuilderState.InDurationFiller.Should().BeFalse();
|
||||
playoutBuilderState.ScheduleItemsEnumerator.State.Index.Should().Be(0);
|
||||
|
||||
enumerator.State.Index.Should().Be(0);
|
||||
}
|
||||
|
||||
protected override ProgramScheduleItem NextScheduleItem => new ProgramScheduleItemOne
|
||||
{
|
||||
StartTime = TimeSpan.FromHours(3)
|
||||
|
||||
@@ -25,7 +25,8 @@ public class PlayoutModeSchedulerOneTests : SchedulerTestBase
|
||||
StartTime = null,
|
||||
PlaybackOrder = PlaybackOrder.Chronological,
|
||||
TailFiller = null,
|
||||
FallbackFiller = null
|
||||
FallbackFiller = null,
|
||||
CustomTitle = "CustomTitle"
|
||||
};
|
||||
|
||||
var scheduleItemsEnumerator = new OrderedScheduleItemsEnumerator(
|
||||
@@ -64,6 +65,7 @@ public class PlayoutModeSchedulerOneTests : SchedulerTestBase
|
||||
playoutItems[0].StartOffset.Should().Be(startState.CurrentTime);
|
||||
playoutItems[0].GuideGroup.Should().Be(1);
|
||||
playoutItems[0].FillerKind.Should().Be(FillerKind.None);
|
||||
playoutItems[0].CustomTitle.Should().Be("CustomTitle");
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -767,6 +769,61 @@ public class PlayoutModeSchedulerOneTests : SchedulerTestBase
|
||||
playoutItems[3].FillerKind.Should().Be(FillerKind.PostRoll);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_Not_Schedule_At_HardStop()
|
||||
{
|
||||
Collection collectionOne = TwoItemCollection(1, 2, TimeSpan.FromMinutes(55));
|
||||
|
||||
var scheduleItem = new ProgramScheduleItemOne
|
||||
{
|
||||
Id = 1,
|
||||
Index = 1,
|
||||
Collection = collectionOne,
|
||||
CollectionId = collectionOne.Id,
|
||||
StartTime = TimeSpan.FromHours(6),
|
||||
PlaybackOrder = PlaybackOrder.Chronological,
|
||||
TailFiller = null,
|
||||
FallbackFiller = null
|
||||
};
|
||||
|
||||
var enumerator = new ChronologicalMediaCollectionEnumerator(
|
||||
collectionOne.MediaItems,
|
||||
new CollectionEnumeratorState());
|
||||
|
||||
var sortedScheduleItems = new List<ProgramScheduleItem>
|
||||
{
|
||||
scheduleItem,
|
||||
NextScheduleItem
|
||||
};
|
||||
|
||||
var scheduleItemsEnumerator = new OrderedScheduleItemsEnumerator(
|
||||
sortedScheduleItems,
|
||||
new CollectionEnumeratorState());
|
||||
|
||||
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
|
||||
|
||||
var scheduler = new PlayoutModeSchedulerOne(new Mock<ILogger>().Object);
|
||||
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule(
|
||||
startState,
|
||||
CollectionEnumerators(scheduleItem, enumerator),
|
||||
scheduleItem,
|
||||
NextScheduleItem,
|
||||
HardStop(scheduleItemsEnumerator));
|
||||
|
||||
playoutItems.Should().BeEmpty();
|
||||
|
||||
playoutBuilderState.CurrentTime.Should().Be(HardStop(scheduleItemsEnumerator));
|
||||
|
||||
playoutBuilderState.NextGuideGroup.Should().Be(1);
|
||||
playoutBuilderState.DurationFinish.IsNone.Should().BeTrue();
|
||||
playoutBuilderState.InFlood.Should().BeFalse();
|
||||
playoutBuilderState.MultipleRemaining.IsNone.Should().BeTrue();
|
||||
playoutBuilderState.InDurationFiller.Should().BeFalse();
|
||||
playoutBuilderState.ScheduleItemsEnumerator.State.Index.Should().Be(0);
|
||||
|
||||
enumerator.State.Index.Should().Be(0);
|
||||
}
|
||||
|
||||
protected override ProgramScheduleItem NextScheduleItem => new ProgramScheduleItemOne
|
||||
{
|
||||
StartTime = TimeSpan.FromHours(3)
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
using Dapper;
|
||||
using Destructurama;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Data.Repositories;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using LanguageExt.UnsafeValueAccess;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using NUnit.Framework;
|
||||
using Serilog;
|
||||
using Serilog.Events;
|
||||
using Serilog.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Core.Tests.Scheduling;
|
||||
|
||||
[TestFixture]
|
||||
[Explicit]
|
||||
public class ScheduleIntegrationTests
|
||||
{
|
||||
public ScheduleIntegrationTests()
|
||||
{
|
||||
Log.Logger = new LoggerConfiguration()
|
||||
.MinimumLevel.Information()
|
||||
.MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
|
||||
.MinimumLevel.Override("System.Net.Http.HttpClient", LogEventLevel.Warning)
|
||||
.WriteTo.Console()
|
||||
.Destructure.UsingAttributes()
|
||||
.CreateLogger();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Test()
|
||||
{
|
||||
string dbFileName = Path.GetTempFileName() + ".sqlite3";
|
||||
|
||||
IServiceCollection services = new ServiceCollection()
|
||||
.AddLogging();
|
||||
|
||||
var connectionString = $"Data Source={dbFileName};foreign keys=true;";
|
||||
|
||||
services.AddDbContext<TvContext>(
|
||||
options => options.UseSqlite(
|
||||
connectionString,
|
||||
o =>
|
||||
{
|
||||
o.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery);
|
||||
o.MigrationsAssembly("ErsatzTV.Infrastructure");
|
||||
}),
|
||||
ServiceLifetime.Scoped,
|
||||
ServiceLifetime.Singleton);
|
||||
|
||||
services.AddDbContextFactory<TvContext>(
|
||||
options => options.UseSqlite(
|
||||
connectionString,
|
||||
o =>
|
||||
{
|
||||
o.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery);
|
||||
o.MigrationsAssembly("ErsatzTV.Infrastructure");
|
||||
}));
|
||||
|
||||
SqlMapper.AddTypeHandler(new DateTimeOffsetHandler());
|
||||
SqlMapper.AddTypeHandler(new GuidHandler());
|
||||
SqlMapper.AddTypeHandler(new TimeSpanHandler());
|
||||
|
||||
services.AddSingleton((Func<IServiceProvider, ILoggerFactory>)(_ => new SerilogLoggerFactory()));
|
||||
|
||||
ServiceProvider provider = services.BuildServiceProvider();
|
||||
|
||||
IDbContextFactory<TvContext> factory = provider.GetRequiredService<IDbContextFactory<TvContext>>();
|
||||
|
||||
ILogger<ScheduleIntegrationTests> logger = provider.GetRequiredService<ILogger<ScheduleIntegrationTests>>();
|
||||
logger.LogInformation("Database is at {File}", dbFileName);
|
||||
|
||||
await using TvContext dbContext = await factory.CreateDbContextAsync(CancellationToken.None);
|
||||
await dbContext.Database.MigrateAsync(CancellationToken.None);
|
||||
await DbInitializer.Initialize(dbContext, CancellationToken.None);
|
||||
|
||||
var path = new LibraryPath
|
||||
{
|
||||
Path = "Test LibraryPath"
|
||||
};
|
||||
|
||||
var library = new LocalLibrary
|
||||
{
|
||||
MediaKind = LibraryMediaKind.Movies,
|
||||
Paths = new List<LibraryPath> { path },
|
||||
MediaSource = new LocalMediaSource()
|
||||
};
|
||||
|
||||
await dbContext.Libraries.AddAsync(library);
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
var movies = new List<Movie>();
|
||||
for (var i = 1; i < 25; i++)
|
||||
{
|
||||
var movie = new Movie
|
||||
{
|
||||
MediaVersions = new List<MediaVersion>
|
||||
{
|
||||
new() { Duration = TimeSpan.FromMinutes(55) }
|
||||
},
|
||||
MovieMetadata = new List<MovieMetadata>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Title = $"Movie {i}",
|
||||
ReleaseDate = new DateTime(2000, 1, 1).AddDays(i)
|
||||
}
|
||||
},
|
||||
LibraryPath = path,
|
||||
LibraryPathId = path.Id
|
||||
};
|
||||
|
||||
movies.Add(movie);
|
||||
}
|
||||
|
||||
await dbContext.Movies.AddRangeAsync(movies);
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
var collection = new Collection
|
||||
{
|
||||
Name = "Test Collection",
|
||||
MediaItems = movies.Cast<MediaItem>().ToList()
|
||||
};
|
||||
|
||||
await dbContext.Collections.AddAsync(collection);
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
var scheduleItems = new List<ProgramScheduleItem>
|
||||
{
|
||||
new ProgramScheduleItemDuration
|
||||
{
|
||||
Collection = collection,
|
||||
CollectionId = collection.Id,
|
||||
PlayoutDuration = TimeSpan.FromHours(1),
|
||||
TailMode = TailMode.None, // immediately continue
|
||||
PlaybackOrder = PlaybackOrder.Shuffle
|
||||
}
|
||||
};
|
||||
|
||||
int playoutId = await AddTestData(dbContext, scheduleItems);
|
||||
|
||||
DateTimeOffset start = new DateTimeOffset(2022, 7, 26, 8, 0, 5, TimeSpan.FromHours(-5));
|
||||
DateTimeOffset finish = start.AddDays(2);
|
||||
|
||||
var builder = new PlayoutBuilder(
|
||||
new ConfigElementRepository(factory),
|
||||
new MediaCollectionRepository(new Mock<ISearchIndex>().Object, factory),
|
||||
new TelevisionRepository(factory),
|
||||
new ArtistRepository(factory),
|
||||
provider.GetRequiredService<ILogger<PlayoutBuilder>>());
|
||||
|
||||
for (var i = 0; i <= (24 * 4); i++)
|
||||
{
|
||||
await using TvContext context = await factory.CreateDbContextAsync();
|
||||
|
||||
Option<Playout> maybePlayout = await GetPlayout(context, playoutId);
|
||||
Playout playout = maybePlayout.ValueUnsafe();
|
||||
|
||||
await builder.Build(playout, PlayoutBuildMode.Continue, start.AddHours(i), finish.AddHours(i));
|
||||
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<int> AddTestData(TvContext dbContext, List<ProgramScheduleItem> scheduleItems)
|
||||
{
|
||||
var ffmpegProfile = new FFmpegProfile
|
||||
{
|
||||
Name = "Test FFmpeg Profile"
|
||||
};
|
||||
|
||||
await dbContext.FFmpegProfiles.AddAsync(ffmpegProfile);
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
var channel = new Channel(Guid.Parse("00000000-0000-0000-0000-000000000001"))
|
||||
{
|
||||
Name = "Test Channel",
|
||||
FFmpegProfile = ffmpegProfile,
|
||||
FFmpegProfileId = ffmpegProfile.Id
|
||||
};
|
||||
|
||||
await dbContext.Channels.AddAsync(channel);
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
var schedule = new ProgramSchedule
|
||||
{
|
||||
Name = "Test Schedule",
|
||||
Items = scheduleItems
|
||||
};
|
||||
|
||||
await dbContext.ProgramSchedules.AddAsync(schedule);
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
var playout = new Playout
|
||||
{
|
||||
Channel = channel,
|
||||
ChannelId = channel.Id,
|
||||
ProgramSchedule = schedule,
|
||||
ProgramScheduleId = schedule.Id
|
||||
};
|
||||
|
||||
await dbContext.Playouts.AddAsync(playout);
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
return playout.Id;
|
||||
}
|
||||
|
||||
private static async Task<Option<Playout>> GetPlayout(TvContext dbContext, int playoutId)
|
||||
{
|
||||
return await dbContext.Playouts
|
||||
.Include(p => p.Channel)
|
||||
.Include(p => p.Items)
|
||||
.Include(p => p.ProgramScheduleAnchors)
|
||||
.ThenInclude(a => a.MediaItem)
|
||||
.Include(p => p.ProgramSchedule)
|
||||
.ThenInclude(ps => ps.Items)
|
||||
.ThenInclude(psi => psi.Collection)
|
||||
.Include(p => p.ProgramSchedule)
|
||||
.ThenInclude(ps => ps.Items)
|
||||
.ThenInclude(psi => psi.MediaItem)
|
||||
.Include(p => p.ProgramSchedule)
|
||||
.ThenInclude(ps => ps.Items)
|
||||
.ThenInclude(psi => psi.PreRollFiller)
|
||||
.Include(p => p.ProgramSchedule)
|
||||
.ThenInclude(ps => ps.Items)
|
||||
.ThenInclude(psi => psi.MidRollFiller)
|
||||
.Include(p => p.ProgramSchedule)
|
||||
.ThenInclude(ps => ps.Items)
|
||||
.ThenInclude(psi => psi.PostRollFiller)
|
||||
.Include(p => p.ProgramSchedule)
|
||||
.ThenInclude(ps => ps.Items)
|
||||
.ThenInclude(psi => psi.TailFiller)
|
||||
.Include(p => p.ProgramSchedule)
|
||||
.ThenInclude(ps => ps.Items)
|
||||
.ThenInclude(psi => psi.FallbackFiller)
|
||||
.SelectOneAsync(p => p.Id, p => p.Id == playoutId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace ErsatzTV.Core.Api.FFmpegProfiles;
|
||||
|
||||
public record FFmpegFullProfileResponseModel(
|
||||
int Id,
|
||||
string Name,
|
||||
int ThreadCount,
|
||||
int HardwareAcceleration,
|
||||
int VaapiDriver,
|
||||
string VaapiDevice,
|
||||
int ResolutionId,
|
||||
int VideoFormat,
|
||||
int VideoBitrate,
|
||||
int VideoBufferSize,
|
||||
int AudioFormat,
|
||||
int AudioBitrate,
|
||||
int AudioBufferSize,
|
||||
bool NormalizeLoudness,
|
||||
int AudioChannels,
|
||||
int AudioSampleRate,
|
||||
bool NormalizeFramerate,
|
||||
bool? DeinterlaceVideo);
|
||||
@@ -25,4 +25,5 @@ public class Channel
|
||||
public string PreferredAudioLanguageCode { get; set; }
|
||||
public string PreferredSubtitleLanguageCode { get; set; }
|
||||
public ChannelSubtitleMode SubtitleMode { get; set; }
|
||||
public ChannelMusicVideoCreditsMode MusicVideoCreditsMode { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace ErsatzTV.Core.Domain;
|
||||
|
||||
public enum ChannelMusicVideoCreditsMode
|
||||
{
|
||||
None = 0,
|
||||
GenerateSubtitles = 1
|
||||
}
|
||||
@@ -6,6 +6,7 @@ public class ConfigElementKey
|
||||
|
||||
public string Key { get; }
|
||||
|
||||
public static ConfigElementKey MinimumLogLevel => new("log.minimum_level");
|
||||
public static ConfigElementKey FFmpegPath => new("ffmpeg.ffmpeg_path");
|
||||
public static ConfigElementKey FFprobePath => new("ffmpeg.ffprobe_path");
|
||||
public static ConfigElementKey FFmpegDefaultProfileId => new("ffmpeg.default_profile_id");
|
||||
@@ -33,4 +34,5 @@ public class ConfigElementKey
|
||||
public static ConfigElementKey FillerPresetsPageSize => new("pages.filler_presets.page_size");
|
||||
public static ConfigElementKey LibraryRefreshInterval => new("scanner.library_refresh_interval");
|
||||
public static ConfigElementKey PlayoutDaysToBuild => new("playout.days_to_build");
|
||||
public static ConfigElementKey PlayoutSkipMissingItems => new("playout.skip_missing_items");
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ public class FillerPreset
|
||||
public TimeSpan? Duration { get; set; }
|
||||
public int? Count { get; set; }
|
||||
public int? PadToNearestMinute { get; set; }
|
||||
public bool AllowWatermarks { get; set; }
|
||||
public ProgramScheduleItemCollectionType CollectionType { get; set; }
|
||||
public int? CollectionId { get; set; }
|
||||
public Collection Collection { get; set; }
|
||||
|
||||
@@ -4,6 +4,7 @@ public class MusicVideoMetadata : Metadata
|
||||
{
|
||||
public string Album { get; set; }
|
||||
public string Plot { get; set; }
|
||||
public int? Track { get; set; }
|
||||
public int MusicVideoId { get; set; }
|
||||
public MusicVideo MusicVideo { get; set; }
|
||||
public List<MusicVideoArtist> Artists { get; set; }
|
||||
|
||||
@@ -3,5 +3,7 @@
|
||||
public enum SubtitleKind
|
||||
{
|
||||
Embedded = 0,
|
||||
Sidecar = 1
|
||||
Sidecar = 1,
|
||||
|
||||
Generated = 99
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Emby;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Repositories.Caching;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
@@ -10,48 +12,45 @@ public class EmbyCollectionScanner : IEmbyCollectionScanner
|
||||
{
|
||||
private readonly IEmbyApiClient _embyApiClient;
|
||||
private readonly IEmbyCollectionRepository _embyCollectionRepository;
|
||||
private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
|
||||
private readonly ILogger<EmbyCollectionScanner> _logger;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
private readonly ISearchRepository _searchRepository;
|
||||
private readonly ICachingSearchRepository _searchRepository;
|
||||
|
||||
public EmbyCollectionScanner(
|
||||
IEmbyCollectionRepository embyCollectionRepository,
|
||||
IEmbyApiClient embyApiClient,
|
||||
ISearchRepository searchRepository,
|
||||
ICachingSearchRepository searchRepository,
|
||||
ISearchIndex searchIndex,
|
||||
IFallbackMetadataProvider fallbackMetadataProvider,
|
||||
ILogger<EmbyCollectionScanner> logger)
|
||||
{
|
||||
_embyCollectionRepository = embyCollectionRepository;
|
||||
_embyApiClient = embyApiClient;
|
||||
_searchRepository = searchRepository;
|
||||
_searchIndex = searchIndex;
|
||||
_fallbackMetadataProvider = fallbackMetadataProvider;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
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 +74,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 +93,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, _fallbackMetadataProvider, 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -3,6 +3,7 @@ using ErsatzTV.Core.Extensions;
|
||||
using ErsatzTV.Core.Interfaces.Emby;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Repositories.Caching;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
using MediatR;
|
||||
@@ -25,7 +26,8 @@ public class EmbyMovieLibraryScanner :
|
||||
IMediator mediator,
|
||||
IMediaSourceRepository mediaSourceRepository,
|
||||
IEmbyMovieRepository embyMovieRepository,
|
||||
ISearchRepository searchRepository,
|
||||
ICachingSearchRepository searchRepository,
|
||||
IFallbackMetadataProvider fallbackMetadataProvider,
|
||||
IEmbyPathReplacementService pathReplacementService,
|
||||
ILocalFileSystem localFileSystem,
|
||||
ILocalStatisticsProvider localStatisticsProvider,
|
||||
@@ -38,6 +40,7 @@ public class EmbyMovieLibraryScanner :
|
||||
mediator,
|
||||
searchIndex,
|
||||
searchRepository,
|
||||
fallbackMetadataProvider,
|
||||
logger)
|
||||
{
|
||||
_embyApiClient = embyApiClient;
|
||||
@@ -79,7 +82,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(
|
||||
|
||||
@@ -3,6 +3,7 @@ using ErsatzTV.Core.Extensions;
|
||||
using ErsatzTV.Core.Interfaces.Emby;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Repositories.Caching;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
using MediatR;
|
||||
@@ -24,7 +25,8 @@ public class EmbyTelevisionLibraryScanner : MediaServerTelevisionLibraryScanner<
|
||||
IMediaSourceRepository mediaSourceRepository,
|
||||
IEmbyTelevisionRepository televisionRepository,
|
||||
ISearchIndex searchIndex,
|
||||
ISearchRepository searchRepository,
|
||||
ICachingSearchRepository searchRepository,
|
||||
IFallbackMetadataProvider fallbackMetadataProvider,
|
||||
IEmbyPathReplacementService pathReplacementService,
|
||||
ILocalFileSystem localFileSystem,
|
||||
ILocalStatisticsProvider localStatisticsProvider,
|
||||
@@ -37,6 +39,7 @@ public class EmbyTelevisionLibraryScanner : MediaServerTelevisionLibraryScanner<
|
||||
localFileSystem,
|
||||
searchRepository,
|
||||
searchIndex,
|
||||
fallbackMetadataProvider,
|
||||
mediator,
|
||||
logger)
|
||||
{
|
||||
@@ -76,13 +79,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 +101,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(
|
||||
|
||||
@@ -7,16 +7,18 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Bugsnag" Version="3.0.1" />
|
||||
<PackageReference Include="Bugsnag" Version="3.1.0" />
|
||||
<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.2.9" />
|
||||
<PackageReference Include="LanguageExt.Transformers" Version="4.2.9" />
|
||||
<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>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user