Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae13db981d | ||
|
|
b7cc8499a3 | ||
|
|
36147b9e9c | ||
|
|
bf8c821012 | ||
|
|
a0f5d8d5d5 | ||
|
|
f1072b70c7 | ||
|
|
e10b28bc0b | ||
|
|
cd2bb0f2e0 | ||
|
|
e80f687612 | ||
|
|
317ca1967c | ||
|
|
b86f45844c | ||
|
|
353f029452 | ||
|
|
1754e7d5fb | ||
|
|
f96be8f99f | ||
|
|
08ceb53b2b | ||
|
|
3d81f760ee | ||
|
|
4ce87feac1 | ||
|
|
f217ba185b | ||
|
|
e925bd6913 | ||
|
|
3f4c9e063b | ||
|
|
7f361d1ea9 | ||
|
|
35d24ffea6 | ||
|
|
a2d023ee69 | ||
|
|
36f44f14bb | ||
|
|
ccb917d0df | ||
|
|
343a4619a6 | ||
|
|
e167c9318c | ||
|
|
de230f92db | ||
|
|
974020a98f | ||
|
|
da957c9377 | ||
|
|
b72d150775 | ||
|
|
b0b7bd17b3 | ||
|
|
1f2f04f3bd | ||
|
|
5bc90bb245 | ||
|
|
f73a32ec13 | ||
|
|
748ed1cf71 | ||
|
|
f2deaa6f7a | ||
|
|
3698fa5b7d | ||
|
|
dc92cb4ac3 | ||
|
|
69410b1a9b | ||
|
|
4aee03e066 | ||
|
|
e16d6c67f1 | ||
|
|
5d8877975d | ||
|
|
367305d960 | ||
|
|
aa08ad5765 |
@@ -25,6 +25,15 @@ on:
|
||||
required: true
|
||||
gh_token:
|
||||
required: true
|
||||
azure_client_id:
|
||||
required: true
|
||||
azure_tenant_id:
|
||||
required: true
|
||||
azure_subscription_id:
|
||||
required: true
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: write
|
||||
jobs:
|
||||
build_and_upload_mac:
|
||||
name: Mac Build & Upload
|
||||
@@ -246,7 +255,7 @@ jobs:
|
||||
|
||||
package_and_upload_windows:
|
||||
name: Package & Upload Windows
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: windows-latest
|
||||
needs: build_dotnet_windows
|
||||
steps:
|
||||
- name: Download dotnet artifacts
|
||||
@@ -255,6 +264,27 @@ jobs:
|
||||
name: dotnet-windows-build
|
||||
path: dotnet-build
|
||||
|
||||
- name: Azure login
|
||||
uses: azure/login@v2
|
||||
with:
|
||||
client-id: ${{ secrets.azure_client_id }}
|
||||
tenant-id: ${{ secrets.azure_tenant_id }}
|
||||
subscription-id: ${{ secrets.azure_subscription_id }}
|
||||
enable-AzPSSession: true
|
||||
|
||||
- name: Sign dotnet artifacts
|
||||
uses: azure/trusted-signing-action@v0
|
||||
with:
|
||||
endpoint: https://eus.codesigning.azure.net/
|
||||
trusted-signing-account-name: ArtifactSigning
|
||||
certificate-profile-name: ErsatzTV
|
||||
files-folder: ${{ github.workspace }}/dotnet-build
|
||||
files-folder-recurse: true
|
||||
files-folder-filter: ErsatzTV.exe,ErsatzTV.Scanner.exe
|
||||
file-digest: SHA256
|
||||
timestamp-rfc3161: http://timestamp.acs.microsoft.com
|
||||
timestamp-digest: SHA256
|
||||
|
||||
- name: Download rust launcher
|
||||
uses: suisei-cn/actions-download-file@v1.3.0
|
||||
with:
|
||||
@@ -285,7 +315,7 @@ jobs:
|
||||
7z e "ffmpeg/${{ steps.downloadffmpeg.outputs.filename }}" -o"$release_name" '*.exe' -r
|
||||
rm -f "$release_name/ffplay.exe"
|
||||
|
||||
(cd "${release_name}" && zip -r "../${release_name}.zip" .)
|
||||
(cd "${release_name}" && 7z a "../${release_name}.zip" .)
|
||||
|
||||
- name: Delete old release assets
|
||||
uses: mknejp/delete-release-assets@v1
|
||||
|
||||
@@ -46,6 +46,10 @@ jobs:
|
||||
ac_username: ${{ secrets.AC_USERNAME }}
|
||||
ac_password: ${{ secrets.AC_PASSWORD }}
|
||||
gh_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
azure_client_id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
azure_tenant_id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
azure_subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
|
||||
build_images:
|
||||
uses: ersatztv/ersatztv/.github/workflows/docker.yml@main
|
||||
needs: calculate_version
|
||||
|
||||
@@ -52,11 +52,6 @@ jobs:
|
||||
|
||||
- name: Test
|
||||
run: dotnet test --blame-hang-timeout "2m" --no-restore --verbosity normal
|
||||
|
||||
- name: Build Windows
|
||||
run: |
|
||||
cd ErsatzTV-Windows
|
||||
cargo build --release --all-features
|
||||
build_and_test_linux:
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
|
||||
@@ -41,6 +41,9 @@ jobs:
|
||||
ac_username: ${{ secrets.AC_USERNAME }}
|
||||
ac_password: ${{ secrets.AC_PASSWORD }}
|
||||
gh_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
azure_client_id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
azure_tenant_id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
azure_subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
build_images:
|
||||
uses: ersatztv/ersatztv/.github/workflows/docker.yml@main
|
||||
needs: calculate_version
|
||||
|
||||
+84
-8
@@ -5,6 +5,80 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [26.2.0] - 2026-02-02
|
||||
### Added
|
||||
- Channel stream selector: add zero-based culture-specific `day_of_week` to `content_condition`, for example:
|
||||
- en-US can match sunday using `day_of_week = 0`
|
||||
- fr-FR can match sunday using `day_of_week = 6`
|
||||
- As a complete example, to match Saturday from 9pm (inclusive) to 11pm (exclusive), based on content start time
|
||||
- `content_condition: day_of_week = 6 and (time_of_day_seconds >= 75600 and time_of_day_seconds < 82800)`
|
||||
- Add `Pad Mode` to ffmpeg profile. Options are:
|
||||
- `Hardware If Possible` - default/existing behavior when hardware acceleration is properly configured
|
||||
- `Software` - force software padding
|
||||
- This can be used to work around buggy GPU driver behavior where padding is green instead of black
|
||||
- This is most often seen with VAAPI acceleration (radeonsi or i965 drivers)
|
||||
- Add API endpoint to clean artwork cache folder (on demand)
|
||||
- POST `/api/maintenance/clean_artwork`
|
||||
- Add health check to warn about unsupported empty (classic) schedules
|
||||
- Add health check to warn about incompatible ffmpeg due to missing filters
|
||||
- This is directly applicable to homebrew `ffmpeg` on MacOS, which is no longer compatible with ErsatzTV
|
||||
- `ffmpeg@7` or `ffmpeg-full` should be used instead
|
||||
- Add `Marathon Group By` option `Director`
|
||||
- This groups the *first* director on Movies, Episodes, Music Videos and Other Videos
|
||||
- This is supported in classic schedules and sequential schedules
|
||||
- Add FFmpeg Profile options:
|
||||
- `Normalize Audio` (default: true) - normalizes audio streams, or stream copies when disabled
|
||||
- `Normalize Video` (default: true) - normalizes video streams, or stream copies when disabled
|
||||
- `Normalize Colors` (default: true) - normalizes color parameters when enabled
|
||||
- Disabling any of these options may have a significant performance benefit *at the expense of stream stability*
|
||||
- Add chapter `title` to filler expression
|
||||
- This can be used to include or exclude chapters with specific (case-insensitive) titles
|
||||
- E.g. `title == 'here'`, `title != 'not here'`, `title like '%here%'`
|
||||
- Local movie libraries: load fanart from `backdrop` files (created by Jellyfin)
|
||||
|
||||
### Changed
|
||||
- Disable automatic artwork database cleanup
|
||||
- This will be re-enabled at some point in the future (after more testing)
|
||||
- For now, the API should be used to clean as needed
|
||||
- Classic Schedules: make multiple `count` an expression
|
||||
- The following parameters can be used:
|
||||
- `count`: the total number of items in the collection
|
||||
- `random`: a random number between zero and (count - 1)
|
||||
- For example:
|
||||
- `count / 2` will play half of the items in the collection
|
||||
- `random % 4 + 1` will play between 1 and 4 items
|
||||
- `2` (similar to before this change) will play exactly two items
|
||||
|
||||
### Fixed
|
||||
- Use code signing on all Windows executables (`ErsatzTV-Windows.exe`, `ErsatzTV.exe`, `ErsatzTV.Scanner.exe`)
|
||||
- Graphics engine:
|
||||
- Respect `z_index` (draw order) on all graphics element types
|
||||
- Fix bug with `z_index` sorting
|
||||
- Restore default UI font that was erroneously removed in v26.1.1
|
||||
- Classic schedules: fix building playouts when `Fill With Group Mode` schedule items also have graphics elements
|
||||
- Use configured searching log level on startup, instead of the default log level of `Information`
|
||||
- MySql: fix searching for shows and seasons in schedule items editor
|
||||
- Fix 500 errors when serving XMLTV due to concurrent file reads and writes
|
||||
- Fix playback of AC3 audio when targeting stereo output and input layout changes mid-stream
|
||||
- Use other video artwork in XMLTV template
|
||||
- Properly update (add or remove) artwork for all local media libraries when files have changed
|
||||
- Sync Plex library name changes
|
||||
- Sync Plex episode title, plot, year, date added, release date, episode number changes
|
||||
- Sync Jellyfin and Emby library name and type changes
|
||||
- Library type (movies, shows) can only be changed when synchronization is *disabled* for the library in ETV
|
||||
- Fix some sequential and scripted playout build failures when using playlists or marathons
|
||||
- Fix erasing playout items and history so all related data is also erased
|
||||
- This includes rerun history, unscheduled gaps, build status
|
||||
- Fix indexing collections when using Elasticsearch backend
|
||||
|
||||
## [26.1.1] - 2026-01-08
|
||||
### Fixed
|
||||
- Use code signing on Windows launcher (`ErsatzTV-Windows.exe`) to avoid antivirus false positive
|
||||
|
||||
### Changed
|
||||
- Optimize database check for orphaned artwork
|
||||
- Include web resources (CSS, JS) locally instead of relying on CDNs
|
||||
|
||||
## [26.1.0] - 2026-01-06
|
||||
### Added
|
||||
- Graphics Engine:
|
||||
@@ -718,13 +792,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
- `random` will start at a random point in the content
|
||||
- `2` (similar to before this change) will skip the first two items in the content
|
||||
- YAML playout: make `count` an expression
|
||||
- The following parameters can be used:
|
||||
- `count`: the total number of items in the content
|
||||
- `random`: a random number between zero and (count - 1)
|
||||
- For example:
|
||||
- `count / 2` will play half of the items in the content
|
||||
- `random % 4 + 1` will play between 1 and 4 items
|
||||
- `2` (similar to before this change) will play exactly two items
|
||||
- The following parameters can be used:
|
||||
- `count`: the total number of items in the content
|
||||
- `random`: a random number between zero and (count - 1)
|
||||
- For example:
|
||||
- `count / 2` will play half of the items in the content
|
||||
- `random % 4 + 1` will play between 1 and 4 items
|
||||
- `2` (similar to before this change) will play exactly two items
|
||||
- YAML playout: add `disable_watermarks` property to all content instructions
|
||||
- This property defaults to `false` (meaning watermarks are allowed by default)
|
||||
- Setting to `true` will prevent watermarks from ever appearing over the content
|
||||
@@ -3081,7 +3155,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
- Initial release to facilitate testing outside of Docker.
|
||||
|
||||
|
||||
[Unreleased]: https://github.com/ErsatzTV/ErsatzTV/compare/v26.1.0...HEAD
|
||||
[Unreleased]: https://github.com/ErsatzTV/ErsatzTV/compare/v26.2.0...HEAD
|
||||
[26.2.0]: https://github.com/ErsatzTV/ErsatzTV/compare/v26.1.1...v26.2.0
|
||||
[26.1.1]: https://github.com/ErsatzTV/ErsatzTV/compare/v26.1.0...v26.1.1
|
||||
[26.1.0]: https://github.com/ErsatzTV/ErsatzTV/compare/v25.9.0...v26.1.0
|
||||
[25.9.0]: https://github.com/ErsatzTV/ErsatzTV/compare/v25.8.0...v25.9.0
|
||||
[25.8.0]: https://github.com/ErsatzTV/ErsatzTV/compare/v25.7.1...v25.8.0
|
||||
|
||||
@@ -883,6 +883,8 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
metadata.Genres ??= [];
|
||||
metadata.Guids ??= [];
|
||||
|
||||
string artworkPath = GetPrioritizedArtworkPath(metadata);
|
||||
|
||||
var data = new
|
||||
{
|
||||
ProgrammeStart = start,
|
||||
@@ -897,6 +899,8 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
OtherVideoPlot = metadata.Plot,
|
||||
OtherVideoHasYear = metadata.Year.HasValue,
|
||||
OtherVideoYear = metadata.Year,
|
||||
OtherVideoHasArtwork = !string.IsNullOrWhiteSpace(artworkPath),
|
||||
OtherVideoArtworkUrl = artworkPath,
|
||||
OtherVideoGenres = metadata.Genres.Map(g => g.Name).OrderBy(n => n),
|
||||
OtherVideoHasContentRating = !string.IsNullOrWhiteSpace(metadata.ContentRating),
|
||||
OtherVideoContentRating = metadata.ContentRating
|
||||
|
||||
@@ -11,30 +11,18 @@ using Microsoft.IO;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public partial class GetChannelGuideHandler : IRequestHandler<GetChannelGuide, Either<BaseError, ChannelGuide>>
|
||||
public partial class GetChannelGuideHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
RecyclableMemoryStreamManager recyclableMemoryStreamManager,
|
||||
IFileSystem fileSystem,
|
||||
ILocalFileSystem localFileSystem)
|
||||
: IRequestHandler<GetChannelGuide, Either<BaseError, ChannelGuide>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly RecyclableMemoryStreamManager _recyclableMemoryStreamManager;
|
||||
private readonly IFileSystem _fileSystem;
|
||||
|
||||
public GetChannelGuideHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
RecyclableMemoryStreamManager recyclableMemoryStreamManager,
|
||||
IFileSystem fileSystem,
|
||||
ILocalFileSystem localFileSystem)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_recyclableMemoryStreamManager = recyclableMemoryStreamManager;
|
||||
_fileSystem = fileSystem;
|
||||
_localFileSystem = localFileSystem;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, ChannelGuide>> Handle(
|
||||
GetChannelGuide request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
var hiddenChannelNumbers = dbContext.Channels
|
||||
.Where(c => c.ShowInEpg == false)
|
||||
.Select(c => c.Number)
|
||||
@@ -42,13 +30,13 @@ public partial class GetChannelGuideHandler : IRequestHandler<GetChannelGuide, E
|
||||
.Select(n => $"{n}.xml")
|
||||
.ToImmutableHashSet();
|
||||
|
||||
string channelsFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, "channels.xml");
|
||||
if (!_fileSystem.File.Exists(channelsFile))
|
||||
string channelsFile = fileSystem.Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, "channels.xml");
|
||||
if (!fileSystem.File.Exists(channelsFile))
|
||||
{
|
||||
return BaseError.New($"Required file {channelsFile} is missing");
|
||||
}
|
||||
|
||||
long mtime = File.GetLastWriteTime(channelsFile).Ticks;
|
||||
long mtime = fileSystem.File.GetLastWriteTime(channelsFile).Ticks;
|
||||
|
||||
var accessTokenUri = $"?v={mtime}";
|
||||
if (!string.IsNullOrWhiteSpace(request.AccessToken))
|
||||
@@ -56,7 +44,7 @@ public partial class GetChannelGuideHandler : IRequestHandler<GetChannelGuide, E
|
||||
accessTokenUri += $"&access_token={request.AccessToken}";
|
||||
}
|
||||
|
||||
string channelsFragment = await File.ReadAllTextAsync(channelsFile, Encoding.UTF8, cancellationToken);
|
||||
string channelsFragment = await ReadAllTextShared(channelsFile, cancellationToken);
|
||||
|
||||
// TODO: is regex faster?
|
||||
channelsFragment = channelsFragment
|
||||
@@ -65,30 +53,52 @@ public partial class GetChannelGuideHandler : IRequestHandler<GetChannelGuide, E
|
||||
|
||||
var channelDataFragments = new Dictionary<string, string>();
|
||||
|
||||
foreach (string fileName in _localFileSystem.ListFiles(FileSystemLayout.ChannelGuideCacheFolder))
|
||||
foreach (string fileName in localFileSystem.ListFiles(FileSystemLayout.ChannelGuideCacheFolder))
|
||||
{
|
||||
if (fileName.Contains("channels"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (hiddenChannelNumbers.Contains(Path.GetFileName(fileName)))
|
||||
if (hiddenChannelNumbers.Contains(fileSystem.Path.GetFileName(fileName)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string channelDataFragment = await File.ReadAllTextAsync(fileName, Encoding.UTF8, cancellationToken);
|
||||
try
|
||||
{
|
||||
string channelDataFragment = await ReadAllTextShared(fileName, cancellationToken);
|
||||
|
||||
channelDataFragment = channelDataFragment
|
||||
.Replace("{RequestBase}", $"{request.Scheme}://{request.Host}{request.BaseUrl}")
|
||||
.Replace("{AccessTokenUri}", accessTokenUri);
|
||||
channelDataFragment = channelDataFragment
|
||||
.Replace("{RequestBase}", $"{request.Scheme}://{request.Host}{request.BaseUrl}")
|
||||
.Replace("{AccessTokenUri}", accessTokenUri);
|
||||
|
||||
channelDataFragment = EtvTagRegex().Replace(channelDataFragment, string.Empty);
|
||||
channelDataFragment = EtvTagRegex().Replace(channelDataFragment, string.Empty);
|
||||
|
||||
channelDataFragments.Add(Path.GetFileNameWithoutExtension(fileName), channelDataFragment);
|
||||
channelDataFragments.Add(fileSystem.Path.GetFileNameWithoutExtension(fileName), channelDataFragment);
|
||||
}
|
||||
catch (FileNotFoundException)
|
||||
{
|
||||
// ignore this channel fragment
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// ignore this channel fragment
|
||||
}
|
||||
}
|
||||
|
||||
return new ChannelGuide(_recyclableMemoryStreamManager, channelsFragment, channelDataFragments);
|
||||
return new ChannelGuide(recyclableMemoryStreamManager, channelsFragment, channelDataFragments);
|
||||
}
|
||||
|
||||
private async Task<string> ReadAllTextShared(string fileName, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var stream = fileSystem.FileStream.New(
|
||||
fileName,
|
||||
FileMode.Open,
|
||||
FileAccess.Read,
|
||||
FileShare.ReadWrite);
|
||||
using var reader = new StreamReader(stream, Encoding.UTF8, leaveOpen: true);
|
||||
return await reader.ReadToEndAsync(cancellationToken);
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"<etv:[^>]+?>.*?<\/etv:[^>]+?>|<etv:[^>]+?\/>", RegexOptions.Singleline)]
|
||||
|
||||
@@ -14,8 +14,8 @@
|
||||
<PackageReference Include="CliWrap" Version="3.10.0" />
|
||||
<PackageReference Include="Humanizer.Core" Version="3.0.1" />
|
||||
<PackageReference Include="MediatR" Version="[12.5.0]" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.2" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
||||
<PackageReference Include="Serilog.Formatting.Compact.Reader" Version="4.0.0" />
|
||||
<PackageReference Include="WebMarkupMin.Core" Version="2.20.1" />
|
||||
|
||||
@@ -7,6 +7,8 @@ namespace ErsatzTV.Application.FFmpegProfiles;
|
||||
public record CreateFFmpegProfile(
|
||||
string Name,
|
||||
int ThreadCount,
|
||||
bool NormalizeAudio,
|
||||
bool NormalizeVideo,
|
||||
HardwareAccelerationKind HardwareAcceleration,
|
||||
string VaapiDisplay,
|
||||
VaapiDriver VaapiDriver,
|
||||
@@ -14,6 +16,7 @@ public record CreateFFmpegProfile(
|
||||
int? QsvExtraHardwareFrames,
|
||||
int ResolutionId,
|
||||
ScalingBehavior ScalingBehavior,
|
||||
FilterMode PadMode,
|
||||
FFmpegProfileVideoFormat VideoFormat,
|
||||
string VideoProfile,
|
||||
string VideoPreset,
|
||||
@@ -30,4 +33,5 @@ public record CreateFFmpegProfile(
|
||||
int AudioChannels,
|
||||
int AudioSampleRate,
|
||||
bool NormalizeFramerate,
|
||||
bool NormalizeColors,
|
||||
bool DeinterlaceVideo) : IRequest<Either<BaseError, CreateFFmpegProfileResult>>;
|
||||
|
||||
@@ -44,42 +44,63 @@ public class CreateFFmpegProfileHandler :
|
||||
CancellationToken cancellationToken) =>
|
||||
(ValidateName(request), ValidateThreadCount(request),
|
||||
await ResolutionMustExist(dbContext, request, cancellationToken))
|
||||
.Apply((name, threadCount, resolutionId) => new FFmpegProfile
|
||||
.Apply((name, threadCount, resolutionId) =>
|
||||
{
|
||||
Name = name,
|
||||
ThreadCount = threadCount,
|
||||
HardwareAcceleration = request.HardwareAcceleration,
|
||||
VaapiDriver = request.VaapiDriver,
|
||||
VaapiDevice = request.VaapiDevice,
|
||||
QsvExtraHardwareFrames = request.QsvExtraHardwareFrames,
|
||||
ResolutionId = resolutionId,
|
||||
ScalingBehavior = request.ScalingBehavior,
|
||||
VideoFormat = request.VideoFormat,
|
||||
VideoProfile = request.VideoProfile,
|
||||
VideoPreset = request.VideoPreset,
|
||||
AllowBFrames = request.AllowBFrames,
|
||||
var hwAccel = request.NormalizeVideo
|
||||
? request.HardwareAcceleration
|
||||
: HardwareAccelerationKind.None;
|
||||
|
||||
// mpeg2video only supports 8-bit content
|
||||
BitDepth = request.VideoFormat is FFmpegProfileVideoFormat.Mpeg2Video
|
||||
? FFmpegProfileBitDepth.EightBit
|
||||
: request.BitDepth,
|
||||
return new FFmpegProfile
|
||||
{
|
||||
Name = name,
|
||||
ThreadCount = threadCount,
|
||||
|
||||
VideoBitrate = request.VideoBitrate,
|
||||
VideoBufferSize = request.VideoBufferSize,
|
||||
TonemapAlgorithm = request.TonemapAlgorithm,
|
||||
AudioFormat = request.AudioFormat,
|
||||
AudioBitrate = request.AudioBitrate,
|
||||
AudioBufferSize = request.AudioBufferSize,
|
||||
NormalizeAudio = request.NormalizeAudio,
|
||||
NormalizeVideo = request.NormalizeVideo,
|
||||
|
||||
NormalizeLoudnessMode = request.NormalizeLoudnessMode,
|
||||
TargetLoudness = request.NormalizeLoudnessMode is NormalizeLoudnessMode.LoudNorm
|
||||
? request.TargetLoudness
|
||||
: null,
|
||||
HardwareAcceleration = hwAccel,
|
||||
VaapiDriver = request.VaapiDriver,
|
||||
VaapiDevice = request.VaapiDevice,
|
||||
QsvExtraHardwareFrames = request.QsvExtraHardwareFrames,
|
||||
ResolutionId = resolutionId,
|
||||
ScalingBehavior = request.ScalingBehavior,
|
||||
|
||||
AudioChannels = request.AudioChannels,
|
||||
AudioSampleRate = request.AudioSampleRate,
|
||||
NormalizeFramerate = request.NormalizeFramerate,
|
||||
DeinterlaceVideo = request.DeinterlaceVideo
|
||||
// only allow customization with VAAPI accel
|
||||
PadMode = hwAccel switch
|
||||
{
|
||||
HardwareAccelerationKind.None => FilterMode.Software,
|
||||
HardwareAccelerationKind.Vaapi => request.PadMode,
|
||||
_ => FilterMode.HardwareIfPossible
|
||||
},
|
||||
|
||||
VideoFormat = request.NormalizeVideo ? request.VideoFormat : FFmpegProfileVideoFormat.Copy,
|
||||
VideoProfile = request.VideoProfile,
|
||||
VideoPreset = request.VideoPreset,
|
||||
AllowBFrames = request.AllowBFrames,
|
||||
|
||||
// mpeg2video only supports 8-bit content
|
||||
BitDepth = request.VideoFormat is FFmpegProfileVideoFormat.Mpeg2Video
|
||||
? FFmpegProfileBitDepth.EightBit
|
||||
: request.BitDepth,
|
||||
|
||||
VideoBitrate = request.VideoBitrate,
|
||||
VideoBufferSize = request.VideoBufferSize,
|
||||
TonemapAlgorithm = request.TonemapAlgorithm,
|
||||
AudioFormat = request.NormalizeAudio ? request.AudioFormat : FFmpegProfileAudioFormat.Copy,
|
||||
AudioBitrate = request.AudioBitrate,
|
||||
AudioBufferSize = request.AudioBufferSize,
|
||||
|
||||
NormalizeLoudnessMode = request.NormalizeLoudnessMode,
|
||||
TargetLoudness = request.NormalizeLoudnessMode is NormalizeLoudnessMode.LoudNorm
|
||||
? request.TargetLoudness
|
||||
: null,
|
||||
|
||||
AudioChannels = request.AudioChannels,
|
||||
AudioSampleRate = request.AudioSampleRate,
|
||||
NormalizeFramerate = request.NormalizeFramerate,
|
||||
NormalizeColors = request.NormalizeColors,
|
||||
DeinterlaceVideo = request.DeinterlaceVideo
|
||||
};
|
||||
});
|
||||
|
||||
private static Validation<BaseError, string> ValidateName(CreateFFmpegProfile createFFmpegProfile) =>
|
||||
|
||||
@@ -8,6 +8,8 @@ public record UpdateFFmpegProfile(
|
||||
int FFmpegProfileId,
|
||||
string Name,
|
||||
int ThreadCount,
|
||||
bool NormalizeAudio,
|
||||
bool NormalizeVideo,
|
||||
HardwareAccelerationKind HardwareAcceleration,
|
||||
string VaapiDisplay,
|
||||
VaapiDriver VaapiDriver,
|
||||
@@ -15,6 +17,7 @@ public record UpdateFFmpegProfile(
|
||||
int? QsvExtraHardwareFrames,
|
||||
int ResolutionId,
|
||||
ScalingBehavior ScalingBehavior,
|
||||
FilterMode PadMode,
|
||||
FFmpegProfileVideoFormat VideoFormat,
|
||||
string VideoProfile,
|
||||
string VideoPreset,
|
||||
@@ -31,4 +34,5 @@ public record UpdateFFmpegProfile(
|
||||
int AudioChannels,
|
||||
int AudioSampleRate,
|
||||
bool NormalizeFramerate,
|
||||
bool NormalizeColors,
|
||||
bool DeinterlaceVideo) : IRequest<Either<BaseError, UpdateFFmpegProfileResult>>;
|
||||
|
||||
@@ -27,16 +27,23 @@ public class UpdateFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFa
|
||||
UpdateFFmpegProfile update,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var hwAccel = update.NormalizeVideo
|
||||
? update.HardwareAcceleration
|
||||
: HardwareAccelerationKind.None;
|
||||
|
||||
p.Name = update.Name;
|
||||
p.ThreadCount = update.ThreadCount;
|
||||
p.HardwareAcceleration = update.HardwareAcceleration;
|
||||
p.NormalizeAudio = update.NormalizeAudio;
|
||||
p.NormalizeVideo = update.NormalizeVideo;
|
||||
p.HardwareAcceleration = hwAccel;
|
||||
p.VaapiDisplay = update.VaapiDisplay;
|
||||
p.VaapiDriver = update.VaapiDriver;
|
||||
p.VaapiDevice = update.VaapiDevice;
|
||||
p.QsvExtraHardwareFrames = update.QsvExtraHardwareFrames;
|
||||
p.ResolutionId = update.ResolutionId;
|
||||
p.ScalingBehavior = update.ScalingBehavior;
|
||||
p.VideoFormat = update.VideoFormat;
|
||||
p.PadMode = update.PadMode;
|
||||
p.VideoFormat = update.NormalizeVideo ? update.VideoFormat : FFmpegProfileVideoFormat.Copy;
|
||||
p.VideoProfile = update.VideoProfile;
|
||||
p.VideoPreset = update.VideoPreset;
|
||||
p.AllowBFrames = update.AllowBFrames;
|
||||
@@ -53,10 +60,20 @@ public class UpdateFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFa
|
||||
p.VideoFormat = FFmpegProfileVideoFormat.Hevc;
|
||||
}
|
||||
|
||||
// only allow customization with VAAPI accel
|
||||
if (p.HardwareAcceleration is HardwareAccelerationKind.None)
|
||||
{
|
||||
p.PadMode = FilterMode.Software;
|
||||
}
|
||||
else if (p.HardwareAcceleration is not HardwareAccelerationKind.Vaapi)
|
||||
{
|
||||
p.PadMode = FilterMode.HardwareIfPossible;
|
||||
}
|
||||
|
||||
p.VideoBitrate = update.VideoBitrate;
|
||||
p.VideoBufferSize = update.VideoBufferSize;
|
||||
p.TonemapAlgorithm = update.TonemapAlgorithm;
|
||||
p.AudioFormat = update.AudioFormat;
|
||||
p.AudioFormat = update.NormalizeAudio ? update.AudioFormat : FFmpegProfileAudioFormat.Copy;
|
||||
p.AudioBitrate = update.AudioBitrate;
|
||||
p.AudioBufferSize = update.AudioBufferSize;
|
||||
|
||||
@@ -68,6 +85,7 @@ public class UpdateFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFa
|
||||
p.AudioChannels = update.AudioChannels;
|
||||
p.AudioSampleRate = update.AudioSampleRate;
|
||||
p.NormalizeFramerate = update.NormalizeFramerate;
|
||||
p.NormalizeColors = update.NormalizeColors;
|
||||
p.DeinterlaceVideo = update.DeinterlaceVideo;
|
||||
|
||||
// don't save invalid preset
|
||||
|
||||
@@ -8,6 +8,8 @@ public record FFmpegProfileViewModel(
|
||||
int Id,
|
||||
string Name,
|
||||
int ThreadCount,
|
||||
bool NormalizeAudio,
|
||||
bool NormalizeVideo,
|
||||
HardwareAccelerationKind HardwareAcceleration,
|
||||
string VaapiDisplay,
|
||||
VaapiDriver VaapiDriver,
|
||||
@@ -15,6 +17,7 @@ public record FFmpegProfileViewModel(
|
||||
int? QsvExtraHardwareFrames,
|
||||
ResolutionViewModel Resolution,
|
||||
ScalingBehavior ScalingBehavior,
|
||||
FilterMode PadMode,
|
||||
FFmpegProfileVideoFormat VideoFormat,
|
||||
string VideoProfile,
|
||||
string VideoPreset,
|
||||
@@ -31,4 +34,5 @@ public record FFmpegProfileViewModel(
|
||||
int AudioChannels,
|
||||
int AudioSampleRate,
|
||||
bool NormalizeFramerate,
|
||||
bool NormalizeColors,
|
||||
bool DeinterlaceVideo);
|
||||
|
||||
@@ -10,6 +10,8 @@ internal static class Mapper
|
||||
profile.Id,
|
||||
profile.Name,
|
||||
profile.ThreadCount,
|
||||
profile.NormalizeAudio,
|
||||
profile.NormalizeVideo,
|
||||
profile.HardwareAcceleration,
|
||||
profile.VaapiDisplay ?? "drm",
|
||||
profile.VaapiDriver,
|
||||
@@ -17,6 +19,7 @@ internal static class Mapper
|
||||
profile.QsvExtraHardwareFrames,
|
||||
Resolutions.Mapper.ProjectToViewModel(profile.Resolution),
|
||||
profile.ScalingBehavior,
|
||||
profile.PadMode,
|
||||
profile.VideoFormat,
|
||||
profile.VideoProfile,
|
||||
profile.VideoPreset ?? string.Empty,
|
||||
@@ -33,6 +36,7 @@ internal static class Mapper
|
||||
profile.AudioChannels,
|
||||
profile.AudioSampleRate,
|
||||
profile.NormalizeFramerate,
|
||||
profile.NormalizeColors,
|
||||
profile.DeinterlaceVideo == true);
|
||||
|
||||
internal static FFmpegProfileResponseModel ProjectToResponseModel(FFmpegProfile ffmpegProfile) =>
|
||||
|
||||
@@ -1,18 +1,174 @@
|
||||
using ErsatzTV.Core;
|
||||
using System.Globalization;
|
||||
using System.IO.Abstractions;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Images;
|
||||
using Humanizer;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Application.Maintenance;
|
||||
|
||||
public class DeleteOrphanedArtworkHandler : IRequestHandler<DeleteOrphanedArtwork, Either<BaseError, Unit>>
|
||||
public class DeleteOrphanedArtworkHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IArtworkRepository artworkRepository,
|
||||
IFileSystem fileSystem,
|
||||
ILogger<DeleteOrphanedArtworkHandler> logger)
|
||||
: IRequestHandler<DeleteOrphanedArtwork, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IArtworkRepository _artworkRepository;
|
||||
public async Task<Either<BaseError, Unit>> Handle(
|
||||
DeleteOrphanedArtwork request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await CleanUpDatabase();
|
||||
await CleanUpFileSystem(cancellationToken);
|
||||
|
||||
public DeleteOrphanedArtworkHandler(IArtworkRepository artworkRepository) =>
|
||||
_artworkRepository = artworkRepository;
|
||||
return Unit.Default;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return BaseError.New(e.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<Either<BaseError, Unit>>
|
||||
Handle(DeleteOrphanedArtwork request, CancellationToken cancellationToken) =>
|
||||
_artworkRepository.GetOrphanedArtwork()
|
||||
.Bind(_artworkRepository.Delete)
|
||||
.Map(_ => Right<BaseError, Unit>(Unit.Default));
|
||||
private async Task CleanUpDatabase()
|
||||
{
|
||||
List<int> ids = await artworkRepository.GetOrphanedArtworkIds();
|
||||
if (ids.Count > 0)
|
||||
{
|
||||
await artworkRepository.Delete(ids);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CleanUpFileSystem(CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
System.Collections.Generic.HashSet<string> validFiles = [];
|
||||
|
||||
List<string> watermarks = await dbContext.ChannelWatermarks
|
||||
.TagWithCallSite()
|
||||
.AsNoTracking()
|
||||
.Select(c => c.Image)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (string watermark in watermarks.Where(w => !string.IsNullOrWhiteSpace(w)))
|
||||
{
|
||||
validFiles.Add(watermark);
|
||||
}
|
||||
|
||||
var lastId = 0;
|
||||
while (true)
|
||||
{
|
||||
List<MinimalArtwork> result = await dbContext.Artwork
|
||||
.TagWithCallSite()
|
||||
.AsNoTracking()
|
||||
.Where(a => a.Id > lastId)
|
||||
.OrderBy(a => a.Id)
|
||||
.Take(1000)
|
||||
.Select(a => new MinimalArtwork(a.Id, a.Path, a.BlurHash43, a.BlurHash54, a.BlurHash64))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (result.Count == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
foreach (MinimalArtwork artwork in result)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(artwork.Path) && !artwork.Path.Contains('/'))
|
||||
{
|
||||
validFiles.Add(artwork.Path);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(artwork.BlurHash43))
|
||||
{
|
||||
validFiles.Add(ImageCache.GetBlurHashFileName(artwork.BlurHash43));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(artwork.BlurHash54))
|
||||
{
|
||||
validFiles.Add(ImageCache.GetBlurHashFileName(artwork.BlurHash54));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(artwork.BlurHash64))
|
||||
{
|
||||
validFiles.Add(ImageCache.GetBlurHashFileName(artwork.BlurHash64));
|
||||
}
|
||||
}
|
||||
|
||||
lastId = result.Last().Id;
|
||||
}
|
||||
|
||||
logger.LogDebug("Loaded {Count} artwork hashes (valid file names)", validFiles.Count);
|
||||
|
||||
var deleted = 0;
|
||||
long bytes = 0;
|
||||
foreach (string file in fileSystem.Directory.EnumerateFiles(
|
||||
FileSystemLayout.ArtworkCacheFolder,
|
||||
"*.*",
|
||||
SearchOption.AllDirectories))
|
||||
{
|
||||
string fileName = fileSystem.Path.GetFileName(file);
|
||||
if (!validFiles.Contains(fileName))
|
||||
{
|
||||
try
|
||||
{
|
||||
bytes += fileSystem.FileInfo.New(file).Length;
|
||||
|
||||
fileSystem.File.Delete(file);
|
||||
deleted++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Could not delete artwork file {File}", file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.LogDebug(
|
||||
"Deleted {Count} unused artwork cache files totaling {Size}",
|
||||
deleted,
|
||||
bytes.Bytes().Humanize(CultureInfo.CurrentCulture));
|
||||
|
||||
DeleteEmptySubfolders(FileSystemLayout.ArtworkCacheFolder);
|
||||
}
|
||||
|
||||
private void DeleteEmptySubfolders(string path)
|
||||
{
|
||||
if (!fileSystem.Directory.Exists(path))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (string sub in fileSystem.Directory.GetDirectories(path))
|
||||
{
|
||||
DeleteEmptySubfolders(sub);
|
||||
}
|
||||
|
||||
if (!fileSystem.Directory.EnumerateFileSystemEntries(path).Any())
|
||||
{
|
||||
try
|
||||
{
|
||||
// don't delete artwork cache folder or its direct children
|
||||
if (path != FileSystemLayout.ArtworkCacheFolder)
|
||||
{
|
||||
var parent = fileSystem.Directory.GetParent(path);
|
||||
if (parent?.FullName != FileSystemLayout.ArtworkCacheFolder)
|
||||
{
|
||||
fileSystem.Directory.Delete(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Could not delete empty cache folder {Folder}", path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record MinimalArtwork(int Id, string Path, string BlurHash43, string BlurHash54, string BlurHash64);
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ public record AddProgramScheduleItem(
|
||||
int? MarathonBatchSize,
|
||||
FillWithGroupMode FillWithGroupMode,
|
||||
MultipleMode MultipleMode,
|
||||
int? MultipleCount,
|
||||
string MultipleCount,
|
||||
TimeSpan? PlayoutDuration,
|
||||
TailMode TailMode,
|
||||
int? DiscardToFillAttempts,
|
||||
|
||||
@@ -24,7 +24,7 @@ public interface IProgramScheduleItemRequest
|
||||
int? MarathonBatchSize { get; }
|
||||
FillWithGroupMode FillWithGroupMode { get; }
|
||||
MultipleMode MultipleMode { get; }
|
||||
int? MultipleCount { get; }
|
||||
string MultipleCount { get; }
|
||||
TimeSpan? PlayoutDuration { get; }
|
||||
TailMode TailMode { get; }
|
||||
int? DiscardToFillAttempts { get; }
|
||||
|
||||
@@ -79,10 +79,10 @@ public abstract class ProgramScheduleItemCommandBase
|
||||
"[MultipleMode] cannot be [PlaylistItemSize] when collection is not a playlist");
|
||||
}
|
||||
|
||||
if (item.MultipleMode is MultipleMode.Count && item.MultipleCount.GetValueOrDefault() < 1)
|
||||
if (item.MultipleMode is MultipleMode.Count && string.IsNullOrWhiteSpace(item.MultipleCount))
|
||||
{
|
||||
return BaseError.New(
|
||||
"[MultipleCount] must be greater than 0 for playout mode 'multiple / count'");
|
||||
"[MultipleCount] must be valid for playout mode 'multiple / count'");
|
||||
}
|
||||
|
||||
break;
|
||||
@@ -298,7 +298,7 @@ public abstract class ProgramScheduleItemCommandBase
|
||||
MarathonBatchSize = item.MarathonBatchSize,
|
||||
FillWithGroupMode = item.FillWithGroupMode,
|
||||
MultipleMode = item.MultipleMode,
|
||||
Count = item.MultipleMode is MultipleMode.Count ? item.MultipleCount.GetValueOrDefault() : 0,
|
||||
Count = item.MultipleMode is MultipleMode.Count ? item.MultipleCount ?? "0" : "0",
|
||||
CustomTitle = item.CustomTitle,
|
||||
GuideMode = item.GuideMode,
|
||||
PreRollFillerId = item.PreRollFillerId,
|
||||
|
||||
@@ -26,7 +26,7 @@ public record ReplaceProgramScheduleItem(
|
||||
int? MarathonBatchSize,
|
||||
FillWithGroupMode FillWithGroupMode,
|
||||
MultipleMode MultipleMode,
|
||||
int? MultipleCount,
|
||||
string MultipleCount,
|
||||
TimeSpan? PlayoutDuration,
|
||||
TailMode TailMode,
|
||||
int? DiscardToFillAttempts,
|
||||
|
||||
@@ -32,7 +32,7 @@ public record ProgramScheduleItemMultipleViewModel : ProgramScheduleItemViewMode
|
||||
int? marathonBatchSize,
|
||||
FillWithGroupMode fillWithGroupMode,
|
||||
MultipleMode multipleMode,
|
||||
int count,
|
||||
string count,
|
||||
string customTitle,
|
||||
GuideMode guideMode,
|
||||
FillerPresetViewModel preRollFiller,
|
||||
@@ -87,5 +87,5 @@ public record ProgramScheduleItemMultipleViewModel : ProgramScheduleItemViewMode
|
||||
|
||||
public MultipleMode MultipleMode { get; set; }
|
||||
|
||||
public int Count { get; }
|
||||
public string Count { get; }
|
||||
}
|
||||
|
||||
@@ -21,27 +21,43 @@ public class ErasePlayoutHistoryHandler(IDbContextFactory<TvContext> dbContextFa
|
||||
|
||||
foreach (Playout playout in maybePlayout)
|
||||
{
|
||||
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
|
||||
|
||||
int nextSeed = new Random().Next();
|
||||
playout.Seed = nextSeed;
|
||||
|
||||
// this deletes the owned PlayoutAnchor
|
||||
playout.Anchor = null;
|
||||
|
||||
playout.OnDemandCheckpoint = null;
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await dbContext.Database.ExecuteSqlAsync(
|
||||
$"DELETE FROM PlayoutItem WHERE PlayoutId = {playout.Id}",
|
||||
cancellationToken);
|
||||
await dbContext.PlayoutItems
|
||||
.Where(pi => pi.PlayoutId == playout.Id)
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
|
||||
await dbContext.Database.ExecuteSqlAsync(
|
||||
$"DELETE FROM PlayoutHistory WHERE PlayoutId = {playout.Id}",
|
||||
cancellationToken);
|
||||
await dbContext.PlayoutHistory
|
||||
.Where(ph => ph.PlayoutId == playout.Id)
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
|
||||
await dbContext.Database.ExecuteSqlAsync(
|
||||
$"DELETE FROM PlayoutAnchor WHERE PlayoutId = {playout.Id}",
|
||||
cancellationToken);
|
||||
await dbContext.PlayoutProgramScheduleItemAnchors
|
||||
.Where(a => a.PlayoutId == playout.Id)
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
|
||||
await dbContext.Database.ExecuteSqlAsync(
|
||||
$"DELETE FROM PlayoutProgramScheduleAnchor WHERE PlayoutId = {playout.Id}",
|
||||
cancellationToken);
|
||||
await dbContext.RerunHistory
|
||||
.Where(rh => rh.PlayoutId == playout.Id)
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
|
||||
await dbContext.PlayoutGaps
|
||||
.Where(pg => pg.PlayoutId == playout.Id)
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
|
||||
await dbContext.PlayoutBuildStatus
|
||||
.Where(pb => pb.PlayoutId == playout.Id)
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Scheduling;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Scheduling;
|
||||
@@ -14,39 +12,49 @@ public class ErasePlayoutItemsHandler(IDbContextFactory<TvContext> dbContextFact
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
Option<Playout> maybePlayout = await dbContext.Playouts
|
||||
.Include(p => p.Items)
|
||||
.Include(p => p.PlayoutHistory)
|
||||
.TagWithCallSite()
|
||||
.AsNoTracking()
|
||||
.Filter(p => p.ScheduleKind == PlayoutScheduleKind.Block ||
|
||||
p.ScheduleKind == PlayoutScheduleKind.Sequential ||
|
||||
p.ScheduleKind == PlayoutScheduleKind.Scripted)
|
||||
.SelectOneAsync(p => p.Id, p => p.Id == request.PlayoutId, cancellationToken);
|
||||
.SingleOrDefaultAsync(p => p.Id == request.PlayoutId, cancellationToken);
|
||||
|
||||
foreach (Playout playout in maybePlayout)
|
||||
{
|
||||
// find the earliest item that finishes after "now"
|
||||
Option<PlayoutItem> maybeFirstItem = playout.Items
|
||||
.Filter(i => i.FinishOffset > DateTimeOffset.Now)
|
||||
.OrderBy(i => i.StartOffset)
|
||||
.HeadOrNone();
|
||||
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
|
||||
|
||||
// delete all history starting with that item
|
||||
// importantly, do NOT delete earlier history
|
||||
foreach (PlayoutItem item in maybeFirstItem)
|
||||
// find the earliest item that finishes after "now"
|
||||
Option<PlayoutItem> maybeFirstItem = await dbContext.PlayoutItems
|
||||
.TagWithCallSite()
|
||||
.AsNoTracking()
|
||||
.Where(pi => pi.PlayoutId == playout.Id)
|
||||
.Where(pi => pi.Finish > DateTime.UtcNow)
|
||||
.OrderBy(i => i.Start)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
foreach (PlayoutItem firstItem in maybeFirstItem)
|
||||
{
|
||||
var toRemove = playout.PlayoutHistory.Filter(h => h.When >= item.Start).ToList();
|
||||
foreach (PlayoutHistory history in toRemove)
|
||||
{
|
||||
playout.PlayoutHistory.Remove(history);
|
||||
}
|
||||
// delete all history starting with that item
|
||||
// importantly, do NOT delete earlier history
|
||||
await dbContext.PlayoutHistory
|
||||
.Where(ph => ph.PlayoutId == playout.Id)
|
||||
.Where(ph => ph.When >= firstItem.Start)
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
}
|
||||
|
||||
// save history changes
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
await dbContext.PlayoutItems
|
||||
.Where(pi => pi.PlayoutId == playout.Id)
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
|
||||
// delete all playout items
|
||||
await dbContext.Database.ExecuteSqlAsync(
|
||||
$"DELETE FROM PlayoutItem WHERE PlayoutId = {playout.Id}",
|
||||
cancellationToken);
|
||||
await dbContext.PlayoutGaps
|
||||
.Where(pg => pg.PlayoutId == playout.Id)
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
|
||||
await dbContext.PlayoutBuildStatus
|
||||
.Where(pb => pb.PlayoutId == playout.Id)
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +1,32 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using Bugsnag;
|
||||
using ErsatzTV.Application.MediaItems;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Search;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Search;
|
||||
|
||||
public class SearchMoviesHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<SearchMovies, List<NamedMediaItemViewModel>>
|
||||
public class SearchMoviesHandler(
|
||||
IClient client,
|
||||
ISearchIndex searchIndex,
|
||||
IDbContextFactory<TvContext> dbContextFactory)
|
||||
: SearchUsingSearchIndexHandler(client, searchIndex), IRequestHandler<SearchMovies, List<NamedMediaItemViewModel>>
|
||||
{
|
||||
public async Task<List<NamedMediaItemViewModel>> Handle(SearchMovies request, CancellationToken cancellationToken)
|
||||
{
|
||||
ImmutableHashSet<int> ids = await Search(LuceneSearchIndex.MovieType, request.Query, cancellationToken);
|
||||
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
return await dbContext.MovieMetadata
|
||||
.TagWithCallSite()
|
||||
.AsNoTracking()
|
||||
.Where(m => EF.Functions.Like(m.Title + " " + m.Year, $"%{request.Query}%"))
|
||||
.OrderBy(m => m.Title)
|
||||
.ThenBy(m => m.Year)
|
||||
.Take(10)
|
||||
.Where(mm => ids.Contains(mm.MovieId))
|
||||
.OrderBy(mm => mm.Title)
|
||||
.ThenBy(mm => mm.Year)
|
||||
.ToListAsync(cancellationToken)
|
||||
.Map(list => list.Map(ToNamedMediaItem).ToList());
|
||||
}
|
||||
|
||||
@@ -1,30 +1,45 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using Bugsnag;
|
||||
using ErsatzTV.Application.MediaItems;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Search;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Search;
|
||||
|
||||
public class SearchTelevisionSeasonsHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<SearchTelevisionSeasons, List<NamedMediaItemViewModel>>
|
||||
public class SearchTelevisionSeasonsHandler(
|
||||
IClient client,
|
||||
ISearchIndex searchIndex,
|
||||
IDbContextFactory<TvContext> dbContextFactory)
|
||||
: SearchUsingSearchIndexHandler(client, searchIndex),
|
||||
IRequestHandler<SearchTelevisionSeasons, List<NamedMediaItemViewModel>>
|
||||
{
|
||||
public async Task<List<NamedMediaItemViewModel>> Handle(
|
||||
SearchTelevisionSeasons request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ImmutableHashSet<int> ids = await Search(LuceneSearchIndex.SeasonType, request.Query, cancellationToken);
|
||||
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
return await (from season in dbContext.Set<Season>()
|
||||
join seasonMetadata in dbContext.Set<SeasonMetadata>()
|
||||
on season.Id equals seasonMetadata.SeasonId
|
||||
join showMetadata in dbContext.Set<ShowMetadata>()
|
||||
on season.ShowId equals showMetadata.ShowId
|
||||
where EF.Functions.Like(showMetadata.Title + " " + seasonMetadata.Title, $"%{request.Query}%")
|
||||
orderby showMetadata.Title, season.SeasonNumber
|
||||
select new TelevisionSeason(season.Id, showMetadata.Title, showMetadata.Year, season.SeasonNumber))
|
||||
.Take(20)
|
||||
return await dbContext.SeasonMetadata
|
||||
.TagWithCallSite()
|
||||
.AsNoTracking()
|
||||
.Include(s => s.Season)
|
||||
.ThenInclude(s => s.Show)
|
||||
.ThenInclude(s => s.ShowMetadata)
|
||||
.Where(sm => ids.Contains(sm.SeasonId))
|
||||
.ToListAsync(cancellationToken)
|
||||
.Map(list => list.Map(ToNamedMediaItem).ToList());
|
||||
.Map(list => list.Map(sm => new TelevisionSeason(
|
||||
sm.SeasonId,
|
||||
sm.Season.Show.ShowMetadata.HeadOrNone().Match(s => s.Title, string.Empty),
|
||||
sm.Year,
|
||||
sm.Season.SeasonNumber))
|
||||
.OrderBy(s => s.Title)
|
||||
.ThenBy(s => s.SeasonNumber)
|
||||
.Map(ToNamedMediaItem)
|
||||
.ToList());
|
||||
}
|
||||
|
||||
private static NamedMediaItemViewModel ToNamedMediaItem(TelevisionSeason season) =>
|
||||
|
||||
@@ -1,25 +1,35 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using Bugsnag;
|
||||
using ErsatzTV.Application.MediaItems;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Search;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Search;
|
||||
|
||||
public class SearchTelevisionShowsHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<SearchTelevisionShows, List<NamedMediaItemViewModel>>
|
||||
public class SearchTelevisionShowsHandler(
|
||||
IClient client,
|
||||
ISearchIndex searchIndex,
|
||||
IDbContextFactory<TvContext> dbContextFactory)
|
||||
: SearchUsingSearchIndexHandler(client, searchIndex),
|
||||
IRequestHandler<SearchTelevisionShows, List<NamedMediaItemViewModel>>
|
||||
{
|
||||
public async Task<List<NamedMediaItemViewModel>> Handle(
|
||||
SearchTelevisionShows request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ImmutableHashSet<int> ids = await Search(LuceneSearchIndex.ShowType, request.Query, cancellationToken);
|
||||
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
return await dbContext.ShowMetadata
|
||||
.TagWithCallSite()
|
||||
.AsNoTracking()
|
||||
.Where(s => EF.Functions.Like(s.Title + " " + s.Year, $"%{request.Query}%"))
|
||||
.OrderBy(s => s.Title)
|
||||
.ThenBy(s => s.Year)
|
||||
.Take(10)
|
||||
.Where(sm => ids.Contains(sm.ShowId))
|
||||
.OrderBy(sm => sm.Title)
|
||||
.ThenBy(sm => sm.Year)
|
||||
.ToListAsync(cancellationToken)
|
||||
.Map(list => list.Map(ToNamedMediaItem).ToList());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
using System.Collections.Immutable;
|
||||
using Bugsnag;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Infrastructure.Search;
|
||||
|
||||
namespace ErsatzTV.Application.Search;
|
||||
|
||||
public abstract class SearchUsingSearchIndexHandler(IClient client, ISearchIndex searchIndex)
|
||||
{
|
||||
private const int PageSize = 10;
|
||||
|
||||
protected async Task<ImmutableHashSet<int>> Search(string type, string query, CancellationToken cancellationToken)
|
||||
{
|
||||
var searchResult = await searchIndex.Search(
|
||||
client,
|
||||
$"type:{type} AND *{query.Replace(" ", @"\ ")}*",
|
||||
string.Empty,
|
||||
0,
|
||||
PageSize,
|
||||
[LuceneSearchIndex.TitleAndYearSearchField],
|
||||
cancellationToken);
|
||||
|
||||
return searchResult.Items.Select(i => i.Id).ToImmutableHashSet();
|
||||
}
|
||||
}
|
||||
@@ -10,20 +10,20 @@
|
||||
<PackageReference Include="Bugsnag" Version="4.1.0" />
|
||||
<PackageReference Include="CliWrap" Version="3.10.0" />
|
||||
<PackageReference Include="LanguageExt.Core" Version="4.4.9" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="10.0.2" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
|
||||
<PackageReference Include="NSubstitute" Version="5.3.0" />
|
||||
<PackageReference Include="NUnit" Version="4.4.0" />
|
||||
<PackageReference Include="NUnit3TestAdapter" Version="6.0.1" />
|
||||
<PackageReference Include="NUnit3TestAdapter" Version="6.1.0" />
|
||||
<PackageReference Include="Serilog" Version="4.3.0" />
|
||||
<PackageReference Include="Serilog.Extensions.Logging" Version="10.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.Debug" Version="3.0.0" />
|
||||
<PackageReference Include="Shouldly" Version="4.3.0" />
|
||||
<PackageReference Include="Testably.Abstractions.Testing" Version="5.0.1" />
|
||||
<PackageReference Include="Testably.Abstractions.Testing" Version="5.0.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -7,6 +7,7 @@ using NUnit.Framework;
|
||||
using Serilog;
|
||||
using Shouldly;
|
||||
using Testably.Abstractions.Testing;
|
||||
using TimeZoneConverter;
|
||||
|
||||
namespace ErsatzTV.Core.Tests.FFmpeg;
|
||||
|
||||
@@ -569,6 +570,289 @@ public class CustomStreamSelectorTests
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Content_Condition_Fail()
|
||||
{
|
||||
const string YAML =
|
||||
"""
|
||||
---
|
||||
items:
|
||||
- audio_language: ["ja"]
|
||||
subtitle_language: ["eng"]
|
||||
content_condition: "day_of_week = 1"
|
||||
|
||||
- audio_language: ["eng"]
|
||||
disable_subtitles: true
|
||||
""";
|
||||
|
||||
var fileSystem = new MockFileSystem();
|
||||
fileSystem.Initialize()
|
||||
.WithFile(TestFileName).Which(f => f.HasStringContent(YAML));
|
||||
var streamSelector = new CustomStreamSelector(fileSystem, _logger);
|
||||
|
||||
var tz = TZConvert.GetTimeZoneInfo("America/Chicago");
|
||||
var start = new DateTime(2026, 1, 11, 0, 0, 0, DateTimeKind.Unspecified); // sunday
|
||||
var dto = new DateTimeOffset(start, tz.GetUtcOffset(start));
|
||||
|
||||
StreamSelectorResult result = await streamSelector.SelectStreams(_channel, dto, _audioVersion, _subtitles);
|
||||
|
||||
result.AudioStream.IsSome.ShouldBeTrue();
|
||||
|
||||
foreach (MediaStream audioStream in result.AudioStream)
|
||||
{
|
||||
audioStream.Index.ShouldBe(1);
|
||||
audioStream.Language.ShouldBe("eng");
|
||||
}
|
||||
|
||||
result.Subtitle.IsSome.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Content_Condition_Match()
|
||||
{
|
||||
const string YAML =
|
||||
"""
|
||||
---
|
||||
items:
|
||||
- audio_language: ["ja"]
|
||||
subtitle_language: ["eng"]
|
||||
content_condition: "day_of_week = 0"
|
||||
|
||||
- audio_language: ["eng"]
|
||||
disable_subtitles: true
|
||||
""";
|
||||
|
||||
var fileSystem = new MockFileSystem();
|
||||
fileSystem.Initialize()
|
||||
.WithFile(TestFileName).Which(f => f.HasStringContent(YAML));
|
||||
var streamSelector = new CustomStreamSelector(fileSystem, _logger);
|
||||
|
||||
var tz = TZConvert.GetTimeZoneInfo("America/Chicago");
|
||||
var start = new DateTime(2026, 1, 11, 0, 0, 0, DateTimeKind.Unspecified); // sunday
|
||||
var dto = new DateTimeOffset(start, tz.GetUtcOffset(start));
|
||||
|
||||
StreamSelectorResult result = await streamSelector.SelectStreams(_channel, dto, _audioVersion, _subtitles);
|
||||
|
||||
result.AudioStream.IsSome.ShouldBeTrue();
|
||||
|
||||
foreach (MediaStream audioStream in result.AudioStream)
|
||||
{
|
||||
audioStream.Index.ShouldBe(0);
|
||||
audioStream.Language.ShouldBe("ja");
|
||||
}
|
||||
|
||||
result.Subtitle.IsSome.ShouldBeTrue();
|
||||
|
||||
foreach (Subtitle subtitle in result.Subtitle)
|
||||
{
|
||||
subtitle.Id.ShouldBe(2);
|
||||
subtitle.Language.ShouldBe("eng");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Time_Of_Day_Content_Condition_Fail_Before()
|
||||
{
|
||||
// saturday from 9pm-11pm
|
||||
const string YAML =
|
||||
"""
|
||||
---
|
||||
items:
|
||||
- audio_language: ["ja"]
|
||||
subtitle_language: ["eng"]
|
||||
content_condition: "day_of_week = 6 and (time_of_day_seconds >= 75600 and time_of_day_seconds < 82800)"
|
||||
|
||||
- audio_language: ["eng"]
|
||||
disable_subtitles: true
|
||||
""";
|
||||
|
||||
var fileSystem = new MockFileSystem();
|
||||
fileSystem.Initialize()
|
||||
.WithFile(TestFileName).Which(f => f.HasStringContent(YAML));
|
||||
var streamSelector = new CustomStreamSelector(fileSystem, _logger);
|
||||
|
||||
var tz = TZConvert.GetTimeZoneInfo("America/Chicago");
|
||||
var start = new DateTime(2026, 1, 10, 20, 59, 59, DateTimeKind.Unspecified); // saturday at 8:59:59pm
|
||||
var dto = new DateTimeOffset(start, tz.GetUtcOffset(start));
|
||||
|
||||
StreamSelectorResult result = await streamSelector.SelectStreams(_channel, dto, _audioVersion, _subtitles);
|
||||
|
||||
result.AudioStream.IsSome.ShouldBeTrue();
|
||||
|
||||
foreach (MediaStream audioStream in result.AudioStream)
|
||||
{
|
||||
audioStream.Index.ShouldBe(1);
|
||||
audioStream.Language.ShouldBe("eng");
|
||||
}
|
||||
|
||||
result.Subtitle.IsSome.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Time_Of_Day_Content_Condition_Fail_After()
|
||||
{
|
||||
// saturday from 9pm-11pm
|
||||
const string YAML =
|
||||
"""
|
||||
---
|
||||
items:
|
||||
- audio_language: ["ja"]
|
||||
subtitle_language: ["eng"]
|
||||
content_condition: "day_of_week = 6 and (time_of_day_seconds >= 75600 and time_of_day_seconds < 82800)"
|
||||
|
||||
- audio_language: ["eng"]
|
||||
disable_subtitles: true
|
||||
""";
|
||||
|
||||
var fileSystem = new MockFileSystem();
|
||||
fileSystem.Initialize()
|
||||
.WithFile(TestFileName).Which(f => f.HasStringContent(YAML));
|
||||
var streamSelector = new CustomStreamSelector(fileSystem, _logger);
|
||||
|
||||
var tz = TZConvert.GetTimeZoneInfo("America/Chicago");
|
||||
var start = new DateTime(2026, 1, 10, 23, 0, 0, DateTimeKind.Unspecified); // saturday at 11:00pm
|
||||
var dto = new DateTimeOffset(start, tz.GetUtcOffset(start));
|
||||
|
||||
StreamSelectorResult result = await streamSelector.SelectStreams(_channel, dto, _audioVersion, _subtitles);
|
||||
|
||||
result.AudioStream.IsSome.ShouldBeTrue();
|
||||
|
||||
foreach (MediaStream audioStream in result.AudioStream)
|
||||
{
|
||||
audioStream.Index.ShouldBe(1);
|
||||
audioStream.Language.ShouldBe("eng");
|
||||
}
|
||||
|
||||
result.Subtitle.IsSome.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Time_Of_Day_Content_Condition_Fail_Wrong_Day()
|
||||
{
|
||||
// saturday from 9pm-11pm
|
||||
const string YAML =
|
||||
"""
|
||||
---
|
||||
items:
|
||||
- audio_language: ["ja"]
|
||||
subtitle_language: ["eng"]
|
||||
content_condition: "day_of_week = 6 and (time_of_day_seconds >= 75600 and time_of_day_seconds < 82800)"
|
||||
|
||||
- audio_language: ["eng"]
|
||||
disable_subtitles: true
|
||||
""";
|
||||
|
||||
var fileSystem = new MockFileSystem();
|
||||
fileSystem.Initialize()
|
||||
.WithFile(TestFileName).Which(f => f.HasStringContent(YAML));
|
||||
var streamSelector = new CustomStreamSelector(fileSystem, _logger);
|
||||
|
||||
var tz = TZConvert.GetTimeZoneInfo("America/Chicago");
|
||||
var start = new DateTime(2026, 1, 11, 22, 0, 0, DateTimeKind.Unspecified); // sunday at 10:00pm
|
||||
var dto = new DateTimeOffset(start, tz.GetUtcOffset(start));
|
||||
|
||||
StreamSelectorResult result = await streamSelector.SelectStreams(_channel, dto, _audioVersion, _subtitles);
|
||||
|
||||
result.AudioStream.IsSome.ShouldBeTrue();
|
||||
|
||||
foreach (MediaStream audioStream in result.AudioStream)
|
||||
{
|
||||
audioStream.Index.ShouldBe(1);
|
||||
audioStream.Language.ShouldBe("eng");
|
||||
}
|
||||
|
||||
result.Subtitle.IsSome.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Time_Of_Day_Content_Condition_Match()
|
||||
{
|
||||
// saturday from 9pm-11pm
|
||||
const string YAML =
|
||||
"""
|
||||
---
|
||||
items:
|
||||
- audio_language: ["ja"]
|
||||
subtitle_language: ["eng"]
|
||||
content_condition: "day_of_week = 6 and (time_of_day_seconds >= 75600 and time_of_day_seconds < 82800)"
|
||||
|
||||
- audio_language: ["eng"]
|
||||
disable_subtitles: true
|
||||
""";
|
||||
|
||||
var fileSystem = new MockFileSystem();
|
||||
fileSystem.Initialize()
|
||||
.WithFile(TestFileName).Which(f => f.HasStringContent(YAML));
|
||||
var streamSelector = new CustomStreamSelector(fileSystem, _logger);
|
||||
|
||||
var tz = TZConvert.GetTimeZoneInfo("America/Chicago");
|
||||
var start = new DateTime(2026, 1, 10, 22, 0, 0, DateTimeKind.Unspecified); // saturday at 10:00pm
|
||||
var dto = new DateTimeOffset(start, tz.GetUtcOffset(start));
|
||||
|
||||
StreamSelectorResult result = await streamSelector.SelectStreams(_channel, dto, _audioVersion, _subtitles);
|
||||
|
||||
result.AudioStream.IsSome.ShouldBeTrue();
|
||||
|
||||
foreach (MediaStream audioStream in result.AudioStream)
|
||||
{
|
||||
audioStream.Index.ShouldBe(0);
|
||||
audioStream.Language.ShouldBe("ja");
|
||||
}
|
||||
|
||||
result.Subtitle.IsSome.ShouldBeTrue();
|
||||
|
||||
foreach (Subtitle subtitle in result.Subtitle)
|
||||
{
|
||||
subtitle.Id.ShouldBe(2);
|
||||
subtitle.Language.ShouldBe("eng");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
[SetCulture("fr-FR")]
|
||||
public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Time_Of_Day_Content_Condition_Match_France()
|
||||
{
|
||||
// saturday from 9pm-11pm
|
||||
const string YAML =
|
||||
"""
|
||||
---
|
||||
items:
|
||||
- audio_language: ["ja"]
|
||||
subtitle_language: ["eng"]
|
||||
content_condition: "day_of_week = 5 and (time_of_day_seconds >= 75600 and time_of_day_seconds < 82800)"
|
||||
|
||||
- audio_language: ["eng"]
|
||||
disable_subtitles: true
|
||||
""";
|
||||
|
||||
var fileSystem = new MockFileSystem();
|
||||
fileSystem.Initialize()
|
||||
.WithFile(TestFileName).Which(f => f.HasStringContent(YAML));
|
||||
var streamSelector = new CustomStreamSelector(fileSystem, _logger);
|
||||
|
||||
var tz = TZConvert.GetTimeZoneInfo("America/Chicago");
|
||||
var start = new DateTime(2026, 1, 10, 22, 0, 0, DateTimeKind.Unspecified); // saturday at 10:00pm
|
||||
var dto = new DateTimeOffset(start, tz.GetUtcOffset(start));
|
||||
|
||||
StreamSelectorResult result = await streamSelector.SelectStreams(_channel, dto, _audioVersion, _subtitles);
|
||||
|
||||
result.AudioStream.IsSome.ShouldBeTrue();
|
||||
|
||||
foreach (MediaStream audioStream in result.AudioStream)
|
||||
{
|
||||
audioStream.Index.ShouldBe(0);
|
||||
audioStream.Language.ShouldBe("ja");
|
||||
}
|
||||
|
||||
result.Subtitle.IsSome.ShouldBeTrue();
|
||||
|
||||
foreach (Subtitle subtitle in result.Subtitle)
|
||||
{
|
||||
subtitle.Id.ShouldBe(2);
|
||||
subtitle.Language.ShouldBe("eng");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Ignore_Blocked_Audio_Title()
|
||||
{
|
||||
|
||||
@@ -8,6 +8,7 @@ using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Serilog;
|
||||
using Shouldly;
|
||||
using Testably.Abstractions.Testing;
|
||||
|
||||
namespace ErsatzTV.Core.Tests.FFmpeg;
|
||||
|
||||
@@ -65,8 +66,18 @@ public class WatermarkSelectorTests
|
||||
|
||||
var loggerFactory = new LoggerFactory().AddSerilog(Log.Logger);
|
||||
|
||||
// watermarks should always exist; effectively ignoring filesystem checks for now
|
||||
var mockFileSystem = new MockFileSystem();
|
||||
mockFileSystem.Initialize()
|
||||
.WithFile("/tmp/watermark");
|
||||
|
||||
var fakeImageCache = Substitute.For<IImageCache>();
|
||||
fakeImageCache.GetPathForImage(Arg.Any<string>(), Arg.Is(ArtworkKind.Watermark), Arg.Any<Option<int>>())
|
||||
.Returns(_ => "/tmp/watermark");
|
||||
|
||||
WatermarkSelector = new WatermarkSelector(
|
||||
Substitute.For<IImageCache>(),
|
||||
mockFileSystem,
|
||||
fakeImageCache,
|
||||
new DecoSelector(loggerFactory.CreateLogger<DecoSelector>()),
|
||||
loggerFactory.CreateLogger<WatermarkSelector>());
|
||||
|
||||
|
||||
@@ -76,6 +76,8 @@ public class FakeMediaCollectionRepository : IMediaCollectionRepository
|
||||
public Task<bool> IsCustomPlaybackOrder(int collectionId) => false.AsTask();
|
||||
public Task<Option<string>> GetNameFromKey(CollectionKey emptyCollection, CancellationToken cancellationToken) => Option<string>.None.AsTask();
|
||||
|
||||
public List<CollectionWithItems> GroupIntoFakeCollections(List<MediaItem> items, string fakeKey = null) =>
|
||||
throw new NotSupportedException();
|
||||
public List<CollectionWithItems> GroupIntoFakeCollections(List<MediaItem> items, string fakeKey = null)
|
||||
{
|
||||
return [new CollectionWithItems(1, 0, fakeKey, items, true, PlaybackOrder.Shuffle, false)];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -666,7 +666,7 @@ public class ContinuePlayoutTests : PlayoutBuilderTestBase
|
||||
Collection = collectionOne,
|
||||
CollectionId = collectionOne.Id,
|
||||
StartTime = null,
|
||||
Count = 3,
|
||||
Count = "3",
|
||||
PlaybackOrder = PlaybackOrder.Chronological
|
||||
},
|
||||
new ProgramScheduleItemMultiple
|
||||
@@ -676,7 +676,7 @@ public class ContinuePlayoutTests : PlayoutBuilderTestBase
|
||||
Collection = collectionTwo,
|
||||
CollectionId = collectionTwo.Id,
|
||||
StartTime = null,
|
||||
Count = 3,
|
||||
Count = "3",
|
||||
PlaybackOrder = PlaybackOrder.Chronological
|
||||
}
|
||||
};
|
||||
|
||||
@@ -15,6 +15,103 @@ namespace ErsatzTV.Core.Tests.Scheduling.ClassicScheduling;
|
||||
[TestFixture]
|
||||
public class NewPlayoutTests : PlayoutBuilderTestBase
|
||||
{
|
||||
[Test]
|
||||
public async Task FillWithGroupMode_Should_Not_Fail()
|
||||
{
|
||||
var collection = new Collection
|
||||
{
|
||||
Id = 1,
|
||||
Name = "Multiple Items",
|
||||
MediaItems =
|
||||
[
|
||||
TestMovie(1, TimeSpan.FromHours(1), new DateTime(2020, 1, 1)),
|
||||
TestMovie(2, TimeSpan.FromHours(1), new DateTime(2020, 2, 1))
|
||||
]
|
||||
};
|
||||
|
||||
var fakeRepository = new FakeMediaCollectionRepository(
|
||||
Map((collection.Id, collection.MediaItems.ToList())));
|
||||
|
||||
var items = new List<ProgramScheduleItem>
|
||||
{
|
||||
new ProgramScheduleItemMultiple
|
||||
{
|
||||
Id = 1,
|
||||
Index = 1,
|
||||
//Collection = collection,
|
||||
CollectionId = collection.Id,
|
||||
StartTime = null,
|
||||
PlaybackOrder = PlaybackOrder.Chronological,
|
||||
MultipleMode = MultipleMode.Count,
|
||||
Count = "2",
|
||||
FillWithGroupMode = FillWithGroupMode.FillWithShuffledGroups,
|
||||
ProgramScheduleItemGraphicsElements = []
|
||||
}
|
||||
};
|
||||
|
||||
// having a graphics element reference the schedule item triggers the bug
|
||||
items[0].ProgramScheduleItemGraphicsElements.Add(new ProgramScheduleItemGraphicsElement
|
||||
{
|
||||
GraphicsElementId = 1,
|
||||
ProgramScheduleItem = items[0],
|
||||
ProgramScheduleItemId = items[0].Id
|
||||
});
|
||||
|
||||
var playout = new Playout
|
||||
{
|
||||
ProgramSchedule = new ProgramSchedule
|
||||
{
|
||||
Items = items
|
||||
},
|
||||
Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" },
|
||||
ProgramScheduleAnchors = [],
|
||||
Items = [],
|
||||
ProgramScheduleAlternates = [],
|
||||
FillGroupIndices = []
|
||||
};
|
||||
|
||||
var referenceData =
|
||||
new PlayoutReferenceData(
|
||||
playout.Channel,
|
||||
Option<Deco>.None,
|
||||
[],
|
||||
[],
|
||||
playout.ProgramSchedule,
|
||||
[],
|
||||
[],
|
||||
TimeSpan.Zero);
|
||||
|
||||
IConfigElementRepository configRepo = Substitute.For<IConfigElementRepository>();
|
||||
var televisionRepo = new FakeTelevisionRepository();
|
||||
IArtistRepository artistRepo = Substitute.For<IArtistRepository>();
|
||||
IMultiEpisodeShuffleCollectionEnumeratorFactory factory =
|
||||
Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>();
|
||||
IRerunHelper rerunHelper = Substitute.For<IRerunHelper>();
|
||||
var builder = new PlayoutBuilder(
|
||||
configRepo,
|
||||
fakeRepository,
|
||||
televisionRepo,
|
||||
artistRepo,
|
||||
factory,
|
||||
new MockFileSystem(),
|
||||
rerunHelper,
|
||||
Logger);
|
||||
|
||||
DateTimeOffset start = HoursAfterMidnight(0);
|
||||
DateTimeOffset finish = start + TimeSpan.FromHours(6);
|
||||
|
||||
Either<BaseError, PlayoutBuildResult> buildResult = await builder.Build(
|
||||
playout,
|
||||
referenceData,
|
||||
PlayoutBuildResult.Empty,
|
||||
PlayoutBuildMode.Reset,
|
||||
start,
|
||||
finish,
|
||||
CancellationToken);
|
||||
|
||||
buildResult.IsRight.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task OnlyZeroDurationItem_Should_Abort()
|
||||
{
|
||||
@@ -852,7 +949,7 @@ public class NewPlayoutTests : PlayoutBuilderTestBase
|
||||
Collection = fixedCollection,
|
||||
CollectionId = fixedCollection.Id,
|
||||
StartTime = TimeSpan.FromHours(3),
|
||||
Count = 2,
|
||||
Count = "2",
|
||||
PlaybackOrder = PlaybackOrder.Chronological
|
||||
}
|
||||
};
|
||||
@@ -981,7 +1078,7 @@ public class NewPlayoutTests : PlayoutBuilderTestBase
|
||||
Collection = fixedCollection,
|
||||
CollectionId = fixedCollection.Id,
|
||||
StartTime = TimeSpan.FromHours(3),
|
||||
Count = 2,
|
||||
Count = "2",
|
||||
PlaybackOrder = PlaybackOrder.Chronological
|
||||
}
|
||||
};
|
||||
@@ -1361,7 +1458,7 @@ public class NewPlayoutTests : PlayoutBuilderTestBase
|
||||
Collection = multipleCollection,
|
||||
CollectionId = multipleCollection.Id,
|
||||
StartTime = null,
|
||||
Count = 2,
|
||||
Count = "2",
|
||||
PlaybackOrder = PlaybackOrder.Chronological
|
||||
},
|
||||
new ProgramScheduleItemDuration
|
||||
@@ -1494,7 +1591,7 @@ public class NewPlayoutTests : PlayoutBuilderTestBase
|
||||
Collection = collectionOne,
|
||||
CollectionId = collectionOne.Id,
|
||||
StartTime = null,
|
||||
Count = 0,
|
||||
Count = "0",
|
||||
MultipleMode = MultipleMode.CollectionSize,
|
||||
PlaybackOrder = PlaybackOrder.Chronological
|
||||
},
|
||||
@@ -1505,7 +1602,7 @@ public class NewPlayoutTests : PlayoutBuilderTestBase
|
||||
Collection = collectionTwo,
|
||||
CollectionId = collectionTwo.Id,
|
||||
StartTime = null,
|
||||
Count = 0,
|
||||
Count = "0",
|
||||
MultipleMode = MultipleMode.CollectionSize,
|
||||
PlaybackOrder = PlaybackOrder.Chronological
|
||||
}
|
||||
@@ -1777,7 +1874,7 @@ public class NewPlayoutTests : PlayoutBuilderTestBase
|
||||
Collection = collectionOne,
|
||||
CollectionId = collectionOne.Id,
|
||||
StartTime = null,
|
||||
Count = 1,
|
||||
Count = "1",
|
||||
PlaybackOrder = PlaybackOrder.Chronological,
|
||||
PostRollFiller = new FillerPreset
|
||||
{
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
using ErsatzTV.Core.Interfaces.Scheduling;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Core.Tests.Scheduling;
|
||||
|
||||
[TestFixture]
|
||||
public class CountExpressionTests
|
||||
{
|
||||
[Test]
|
||||
[TestCase("2", 2)]
|
||||
[TestCase("count", 10)]
|
||||
[TestCase("count / 2", 5)]
|
||||
[TestCase("count * 2", 20)]
|
||||
[TestCase("count + 1", 11)]
|
||||
[TestCase("count - 1", 9)]
|
||||
[TestCase("random % 4 + 1", 3)]
|
||||
[TestCase("invalid", 0)]
|
||||
[TestCase("count / 0", 0)]
|
||||
public void Should_Evaluate_Expression(string expression, int expected)
|
||||
{
|
||||
var enumerator = Substitute.For<IMediaCollectionEnumerator>();
|
||||
enumerator.Count.Returns(10);
|
||||
|
||||
var random = Substitute.For<Random>();
|
||||
random.Next().Returns(2);
|
||||
|
||||
int result = CountExpression.Evaluate(expression, enumerator, random, CancellationToken.None);
|
||||
|
||||
result.ShouldBe(expected);
|
||||
}
|
||||
}
|
||||
@@ -75,4 +75,106 @@ public class FillerExpressionTests
|
||||
result[1].EndTime.ShouldBe(TimeSpan.FromMinutes(20));
|
||||
result[2].EndTime.ShouldBe(TimeSpan.FromMinutes(30));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Match_Case_Insensitive_Titles_Expression()
|
||||
{
|
||||
// 30 min content
|
||||
var playoutItem = new PlayoutItem { Start = DateTimeOffset.Now.UtcDateTime };
|
||||
playoutItem.Finish = playoutItem.Start + TimeSpan.FromMinutes(30);
|
||||
|
||||
// chapters every 5 min
|
||||
var chapters = new List<MediaChapter>
|
||||
{
|
||||
new() { ChapterId = 1, StartTime = TimeSpan.Zero, EndTime = TimeSpan.FromMinutes(5), Title = "Not Here" },
|
||||
new() { ChapterId = 2, StartTime = TimeSpan.FromMinutes(5), EndTime = TimeSpan.FromMinutes(10), Title = "Here" },
|
||||
new() { ChapterId = 3, StartTime = TimeSpan.FromMinutes(10), EndTime = TimeSpan.FromMinutes(15), Title = "Not Here" },
|
||||
new() { ChapterId = 4, StartTime = TimeSpan.FromMinutes(15), EndTime = TimeSpan.FromMinutes(20), Title = "Here" },
|
||||
new() { ChapterId = 5, StartTime = TimeSpan.FromMinutes(20), EndTime = TimeSpan.FromMinutes(25), Title = "Not Here" },
|
||||
new() { ChapterId = 6, StartTime = TimeSpan.FromMinutes(25), EndTime = TimeSpan.FromMinutes(30), Title = "Here" }
|
||||
};
|
||||
|
||||
var fillerPreset = new FillerPreset
|
||||
{
|
||||
FillerKind = FillerKind.MidRoll,
|
||||
Expression =
|
||||
"title == 'here'"
|
||||
};
|
||||
|
||||
List<MediaChapter> result = FillerExpression.FilterChapters(fillerPreset.Expression, chapters, playoutItem);
|
||||
|
||||
result.Count.ShouldBe(3);
|
||||
result[0].EndTime.ShouldBe(TimeSpan.FromMinutes(10));
|
||||
result[1].EndTime.ShouldBe(TimeSpan.FromMinutes(20));
|
||||
result[2].EndTime.ShouldBe(TimeSpan.FromMinutes(30));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Exclude_Case_Insensitive_Titles_Expression()
|
||||
{
|
||||
// 30 min content
|
||||
var playoutItem = new PlayoutItem { Start = DateTimeOffset.Now.UtcDateTime };
|
||||
playoutItem.Finish = playoutItem.Start + TimeSpan.FromMinutes(30);
|
||||
|
||||
// chapters every 5 min
|
||||
var chapters = new List<MediaChapter>
|
||||
{
|
||||
new() { ChapterId = 1, StartTime = TimeSpan.Zero, EndTime = TimeSpan.FromMinutes(5), Title = "Not Here" },
|
||||
new() { ChapterId = 2, StartTime = TimeSpan.FromMinutes(5), EndTime = TimeSpan.FromMinutes(10), Title = "Here" },
|
||||
new() { ChapterId = 3, StartTime = TimeSpan.FromMinutes(10), EndTime = TimeSpan.FromMinutes(15), Title = "Not Here" },
|
||||
new() { ChapterId = 4, StartTime = TimeSpan.FromMinutes(15), EndTime = TimeSpan.FromMinutes(20), Title = "Here" },
|
||||
new() { ChapterId = 5, StartTime = TimeSpan.FromMinutes(20), EndTime = TimeSpan.FromMinutes(25), Title = "Not Here" },
|
||||
new() { ChapterId = 6, StartTime = TimeSpan.FromMinutes(25), EndTime = TimeSpan.FromMinutes(30), Title = "Here" }
|
||||
};
|
||||
|
||||
var fillerPreset = new FillerPreset
|
||||
{
|
||||
FillerKind = FillerKind.MidRoll,
|
||||
Expression =
|
||||
"title != 'not here'"
|
||||
};
|
||||
|
||||
List<MediaChapter> result = FillerExpression.FilterChapters(fillerPreset.Expression, chapters, playoutItem);
|
||||
|
||||
result.Count.ShouldBe(3);
|
||||
result[0].EndTime.ShouldBe(TimeSpan.FromMinutes(10));
|
||||
result[1].EndTime.ShouldBe(TimeSpan.FromMinutes(20));
|
||||
result[2].EndTime.ShouldBe(TimeSpan.FromMinutes(30));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Include_Partial_Case_Insensitive_Titles_Expression()
|
||||
{
|
||||
// 30 min content
|
||||
var playoutItem = new PlayoutItem { Start = DateTimeOffset.Now.UtcDateTime };
|
||||
playoutItem.Finish = playoutItem.Start + TimeSpan.FromMinutes(30);
|
||||
|
||||
// chapters every 5 min
|
||||
var chapters = new List<MediaChapter>
|
||||
{
|
||||
new() { ChapterId = 1, StartTime = TimeSpan.Zero, EndTime = TimeSpan.FromMinutes(5), Title = "Not Here" },
|
||||
new() { ChapterId = 2, StartTime = TimeSpan.FromMinutes(5), EndTime = TimeSpan.FromMinutes(10), Title = "Here" },
|
||||
new() { ChapterId = 3, StartTime = TimeSpan.FromMinutes(10), EndTime = TimeSpan.FromMinutes(15), Title = "Not Here" },
|
||||
new() { ChapterId = 4, StartTime = TimeSpan.FromMinutes(15), EndTime = TimeSpan.FromMinutes(20), Title = "Here" },
|
||||
new() { ChapterId = 5, StartTime = TimeSpan.FromMinutes(20), EndTime = TimeSpan.FromMinutes(25), Title = "Not Here" },
|
||||
new() { ChapterId = 6, StartTime = TimeSpan.FromMinutes(25), EndTime = TimeSpan.FromMinutes(30), Title = "Here" }
|
||||
};
|
||||
|
||||
var fillerPreset = new FillerPreset
|
||||
{
|
||||
FillerKind = FillerKind.MidRoll,
|
||||
Expression =
|
||||
"title like \"%here%\""
|
||||
};
|
||||
|
||||
List<MediaChapter> result = FillerExpression.FilterChapters(fillerPreset.Expression, chapters, playoutItem);
|
||||
|
||||
result.Count.ShouldBe(6);
|
||||
result[0].EndTime.ShouldBe(TimeSpan.FromMinutes(5));
|
||||
result[1].EndTime.ShouldBe(TimeSpan.FromMinutes(10));
|
||||
result[2].EndTime.ShouldBe(TimeSpan.FromMinutes(15));
|
||||
result[3].EndTime.ShouldBe(TimeSpan.FromMinutes(20));
|
||||
result[4].EndTime.ShouldBe(TimeSpan.FromMinutes(25));
|
||||
result[5].EndTime.ShouldBe(TimeSpan.FromMinutes(30));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -796,6 +796,7 @@ public class PlayoutModeSchedulerBaseTests : SchedulerTestBase
|
||||
ProgramScheduleItem scheduleItem,
|
||||
ProgramScheduleItem nextScheduleItem,
|
||||
DateTimeOffset hardStop,
|
||||
Random random,
|
||||
CancellationToken cancellationToken) =>
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
@@ -13,9 +13,14 @@ namespace ErsatzTV.Core.Tests.Scheduling;
|
||||
public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
|
||||
{
|
||||
[SetUp]
|
||||
public void SetUp() => _cancellationToken = new CancellationTokenSource(TimeSpan.FromSeconds(10)).Token;
|
||||
public void SetUp()
|
||||
{
|
||||
_cancellationToken = new CancellationTokenSource(TimeSpan.FromSeconds(10)).Token;
|
||||
_random = new Random();
|
||||
}
|
||||
|
||||
private CancellationToken _cancellationToken;
|
||||
private Random _random;
|
||||
private readonly ILogger<PlayoutModeSchedulerDuration> _logger;
|
||||
|
||||
public PlayoutModeSchedulerDurationTests()
|
||||
@@ -66,6 +71,7 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
|
||||
scheduleItem,
|
||||
NextScheduleItem,
|
||||
HardStop(scheduleItemsEnumerator),
|
||||
_random,
|
||||
_cancellationToken);
|
||||
|
||||
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(3));
|
||||
@@ -139,6 +145,7 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
|
||||
scheduleItem,
|
||||
NextScheduleItem,
|
||||
HardStop(scheduleItemsEnumerator),
|
||||
_random,
|
||||
_cancellationToken);
|
||||
|
||||
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(3));
|
||||
@@ -211,6 +218,7 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
|
||||
scheduleItem,
|
||||
NextScheduleItem,
|
||||
HardStop(scheduleItemsEnumerator),
|
||||
_random,
|
||||
_cancellationToken);
|
||||
|
||||
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.Add(new TimeSpan(2, 45, 0)));
|
||||
@@ -280,6 +288,7 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
|
||||
scheduleItem,
|
||||
NextScheduleItem,
|
||||
HardStop(scheduleItemsEnumerator),
|
||||
_random,
|
||||
_cancellationToken);
|
||||
|
||||
// duration block should end after exact duration, with gap
|
||||
@@ -363,6 +372,7 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
|
||||
scheduleItem,
|
||||
NextScheduleItem,
|
||||
HardStop(scheduleItemsEnumerator),
|
||||
_random,
|
||||
_cancellationToken);
|
||||
|
||||
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(3));
|
||||
@@ -450,6 +460,7 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
|
||||
scheduleItem,
|
||||
NextScheduleItem,
|
||||
HardStop(scheduleItemsEnumerator),
|
||||
_random,
|
||||
_cancellationToken);
|
||||
|
||||
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(3));
|
||||
@@ -549,6 +560,7 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
|
||||
scheduleItem,
|
||||
NextScheduleItem,
|
||||
HardStop(scheduleItemsEnumerator),
|
||||
_random,
|
||||
_cancellationToken);
|
||||
|
||||
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(3));
|
||||
@@ -665,6 +677,7 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
|
||||
scheduleItem,
|
||||
NextScheduleItem,
|
||||
HardStop(scheduleItemsEnumerator),
|
||||
_random,
|
||||
_cancellationToken);
|
||||
|
||||
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(3));
|
||||
@@ -816,6 +829,7 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
|
||||
scheduleItem,
|
||||
NextScheduleItem,
|
||||
HardStop(scheduleItemsEnumerator),
|
||||
_random,
|
||||
_cancellationToken);
|
||||
|
||||
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddMinutes(30));
|
||||
@@ -880,6 +894,7 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
|
||||
scheduleItem,
|
||||
NextScheduleItem,
|
||||
HardStop(scheduleItemsEnumerator),
|
||||
_random,
|
||||
_cancellationToken);
|
||||
|
||||
playoutItems.ShouldBeEmpty();
|
||||
|
||||
@@ -12,9 +12,14 @@ namespace ErsatzTV.Core.Tests.Scheduling;
|
||||
public class PlayoutModeSchedulerFloodTests : SchedulerTestBase
|
||||
{
|
||||
[SetUp]
|
||||
public void SetUp() => _cancellationToken = new CancellationTokenSource(TimeSpan.FromSeconds(10)).Token;
|
||||
public void SetUp()
|
||||
{
|
||||
_cancellationToken = new CancellationTokenSource(TimeSpan.FromSeconds(10)).Token;
|
||||
_random = new Random();
|
||||
}
|
||||
|
||||
private CancellationToken _cancellationToken;
|
||||
private Random _random;
|
||||
|
||||
[Test]
|
||||
public void Should_Fill_Exactly_To_Next_Schedule_Item()
|
||||
@@ -57,6 +62,7 @@ public class PlayoutModeSchedulerFloodTests : SchedulerTestBase
|
||||
scheduleItem,
|
||||
NextScheduleItem,
|
||||
HardStop(scheduleItemsEnumerator),
|
||||
_random,
|
||||
_cancellationToken);
|
||||
|
||||
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(3));
|
||||
@@ -132,6 +138,7 @@ public class PlayoutModeSchedulerFloodTests : SchedulerTestBase
|
||||
scheduleItem,
|
||||
scheduleItem,
|
||||
HardStop(scheduleItemsEnumerator),
|
||||
_random,
|
||||
_cancellationToken);
|
||||
|
||||
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(6));
|
||||
@@ -229,6 +236,7 @@ public class PlayoutModeSchedulerFloodTests : SchedulerTestBase
|
||||
scheduleItem,
|
||||
NextScheduleItem,
|
||||
HardStop(scheduleItemsEnumerator),
|
||||
_random,
|
||||
_cancellationToken);
|
||||
|
||||
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(3));
|
||||
@@ -314,6 +322,7 @@ public class PlayoutModeSchedulerFloodTests : SchedulerTestBase
|
||||
scheduleItem,
|
||||
NextScheduleItem,
|
||||
HardStop(scheduleItemsEnumerator),
|
||||
_random,
|
||||
_cancellationToken);
|
||||
|
||||
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(3));
|
||||
@@ -402,6 +411,7 @@ public class PlayoutModeSchedulerFloodTests : SchedulerTestBase
|
||||
scheduleItem,
|
||||
NextScheduleItem,
|
||||
HardStop(scheduleItemsEnumerator),
|
||||
_random,
|
||||
_cancellationToken);
|
||||
|
||||
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.Add(new TimeSpan(2, 45, 0)));
|
||||
@@ -484,6 +494,7 @@ public class PlayoutModeSchedulerFloodTests : SchedulerTestBase
|
||||
scheduleItem,
|
||||
NextScheduleItem,
|
||||
HardStop(scheduleItemsEnumerator),
|
||||
_random,
|
||||
_cancellationToken);
|
||||
|
||||
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(3));
|
||||
@@ -582,6 +593,7 @@ public class PlayoutModeSchedulerFloodTests : SchedulerTestBase
|
||||
scheduleItem,
|
||||
NextScheduleItem,
|
||||
HardStop(scheduleItemsEnumerator),
|
||||
_random,
|
||||
_cancellationToken);
|
||||
|
||||
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(3));
|
||||
@@ -670,6 +682,7 @@ public class PlayoutModeSchedulerFloodTests : SchedulerTestBase
|
||||
scheduleItem,
|
||||
NextScheduleItem,
|
||||
HardStop(scheduleItemsEnumerator),
|
||||
_random,
|
||||
_cancellationToken);
|
||||
|
||||
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.Add(new TimeSpan(2, 57, 0)));
|
||||
@@ -784,6 +797,7 @@ public class PlayoutModeSchedulerFloodTests : SchedulerTestBase
|
||||
scheduleItem,
|
||||
NextScheduleItem,
|
||||
HardStop(scheduleItemsEnumerator),
|
||||
_random,
|
||||
_cancellationToken);
|
||||
|
||||
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(3));
|
||||
@@ -896,6 +910,7 @@ public class PlayoutModeSchedulerFloodTests : SchedulerTestBase
|
||||
scheduleItem,
|
||||
NextScheduleItem,
|
||||
hardStop,
|
||||
_random,
|
||||
_cancellationToken);
|
||||
|
||||
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(2));
|
||||
@@ -1002,6 +1017,7 @@ public class PlayoutModeSchedulerFloodTests : SchedulerTestBase
|
||||
scheduleItem,
|
||||
NextScheduleItem,
|
||||
hardStop,
|
||||
_random,
|
||||
_cancellationToken);
|
||||
|
||||
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(2));
|
||||
@@ -1116,6 +1132,7 @@ public class PlayoutModeSchedulerFloodTests : SchedulerTestBase
|
||||
scheduleItem,
|
||||
NextScheduleItem,
|
||||
HardStop(scheduleItemsEnumerator),
|
||||
_random,
|
||||
_cancellationToken);
|
||||
|
||||
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(3));
|
||||
@@ -1190,6 +1207,7 @@ public class PlayoutModeSchedulerFloodTests : SchedulerTestBase
|
||||
scheduleItem,
|
||||
NextScheduleItem,
|
||||
HardStop(scheduleItemsEnumerator),
|
||||
_random,
|
||||
_cancellationToken);
|
||||
|
||||
playoutItems.ShouldBeEmpty();
|
||||
|
||||
@@ -12,9 +12,14 @@ namespace ErsatzTV.Core.Tests.Scheduling;
|
||||
public class PlayoutModeSchedulerMultipleTests : SchedulerTestBase
|
||||
{
|
||||
[SetUp]
|
||||
public void SetUp() => _cancellationToken = new CancellationTokenSource(TimeSpan.FromSeconds(10)).Token;
|
||||
public void SetUp()
|
||||
{
|
||||
_cancellationToken = new CancellationTokenSource(TimeSpan.FromSeconds(10)).Token;
|
||||
_random = new Random();
|
||||
}
|
||||
|
||||
private CancellationToken _cancellationToken;
|
||||
private Random _random;
|
||||
|
||||
[Test]
|
||||
public void Should_Respect_Fixed_Start_Time()
|
||||
@@ -32,7 +37,7 @@ public class PlayoutModeSchedulerMultipleTests : SchedulerTestBase
|
||||
PlaybackOrder = PlaybackOrder.Chronological,
|
||||
TailFiller = null,
|
||||
FallbackFiller = null,
|
||||
Count = 0,
|
||||
Count = "0",
|
||||
MultipleMode = MultipleMode.CollectionSize,
|
||||
CustomTitle = "CustomTitle"
|
||||
};
|
||||
@@ -59,6 +64,7 @@ public class PlayoutModeSchedulerMultipleTests : SchedulerTestBase
|
||||
scheduleItem,
|
||||
NextScheduleItem,
|
||||
HardStop(scheduleItemsEnumerator),
|
||||
_random,
|
||||
_cancellationToken);
|
||||
|
||||
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(3));
|
||||
@@ -134,7 +140,7 @@ public class PlayoutModeSchedulerMultipleTests : SchedulerTestBase
|
||||
PlaybackOrder = PlaybackOrder.Chronological,
|
||||
TailFiller = null,
|
||||
FallbackFiller = null,
|
||||
Count = 0,
|
||||
Count = "0",
|
||||
MultipleMode = MultipleMode.MultiEpisodeGroupSize,
|
||||
CustomTitle = "CustomTitle"
|
||||
};
|
||||
@@ -161,6 +167,7 @@ public class PlayoutModeSchedulerMultipleTests : SchedulerTestBase
|
||||
scheduleItem,
|
||||
NextScheduleItem,
|
||||
HardStop(scheduleItemsEnumerator),
|
||||
_random,
|
||||
_cancellationToken);
|
||||
|
||||
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(3));
|
||||
@@ -206,7 +213,7 @@ public class PlayoutModeSchedulerMultipleTests : SchedulerTestBase
|
||||
PlaybackOrder = PlaybackOrder.Chronological,
|
||||
TailFiller = null,
|
||||
FallbackFiller = null,
|
||||
Count = 3,
|
||||
Count = "3",
|
||||
CustomTitle = "CustomTitle"
|
||||
};
|
||||
|
||||
@@ -232,6 +239,7 @@ public class PlayoutModeSchedulerMultipleTests : SchedulerTestBase
|
||||
scheduleItem,
|
||||
NextScheduleItem,
|
||||
HardStop(scheduleItemsEnumerator),
|
||||
_random,
|
||||
_cancellationToken);
|
||||
|
||||
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(3));
|
||||
@@ -282,7 +290,7 @@ public class PlayoutModeSchedulerMultipleTests : SchedulerTestBase
|
||||
PlaybackOrder = PlaybackOrder.Chronological,
|
||||
TailFiller = null,
|
||||
FallbackFiller = null,
|
||||
Count = 3
|
||||
Count = "3"
|
||||
};
|
||||
|
||||
var scheduleItemsEnumerator = new OrderedScheduleItemsEnumerator(
|
||||
@@ -307,6 +315,7 @@ public class PlayoutModeSchedulerMultipleTests : SchedulerTestBase
|
||||
scheduleItem,
|
||||
NextScheduleItem,
|
||||
HardStop(scheduleItemsEnumerator),
|
||||
_random,
|
||||
_cancellationToken);
|
||||
|
||||
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.Add(new TimeSpan(2, 45, 0)));
|
||||
@@ -360,7 +369,7 @@ public class PlayoutModeSchedulerMultipleTests : SchedulerTestBase
|
||||
CollectionId = collectionTwo.Id
|
||||
},
|
||||
FallbackFiller = null,
|
||||
Count = 3
|
||||
Count = "3"
|
||||
};
|
||||
|
||||
var scheduleItemsEnumerator = new OrderedScheduleItemsEnumerator(
|
||||
@@ -390,6 +399,7 @@ public class PlayoutModeSchedulerMultipleTests : SchedulerTestBase
|
||||
scheduleItem,
|
||||
NextScheduleItem,
|
||||
HardStop(scheduleItemsEnumerator),
|
||||
_random,
|
||||
_cancellationToken);
|
||||
|
||||
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(3));
|
||||
@@ -459,7 +469,7 @@ public class PlayoutModeSchedulerMultipleTests : SchedulerTestBase
|
||||
Collection = collectionTwo,
|
||||
CollectionId = collectionTwo.Id
|
||||
},
|
||||
Count = 3
|
||||
Count = "3"
|
||||
};
|
||||
|
||||
var scheduleItemsEnumerator = new OrderedScheduleItemsEnumerator(
|
||||
@@ -489,6 +499,7 @@ public class PlayoutModeSchedulerMultipleTests : SchedulerTestBase
|
||||
scheduleItem,
|
||||
NextScheduleItem,
|
||||
HardStop(scheduleItemsEnumerator),
|
||||
_random,
|
||||
_cancellationToken);
|
||||
|
||||
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(3));
|
||||
@@ -548,7 +559,7 @@ public class PlayoutModeSchedulerMultipleTests : SchedulerTestBase
|
||||
CollectionId = collectionTwo.Id
|
||||
},
|
||||
FallbackFiller = null,
|
||||
Count = 3
|
||||
Count = "3"
|
||||
};
|
||||
|
||||
var scheduleItemsEnumerator = new OrderedScheduleItemsEnumerator(
|
||||
@@ -578,6 +589,7 @@ public class PlayoutModeSchedulerMultipleTests : SchedulerTestBase
|
||||
scheduleItem,
|
||||
NextScheduleItem,
|
||||
HardStop(scheduleItemsEnumerator),
|
||||
_random,
|
||||
_cancellationToken);
|
||||
|
||||
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.Add(new TimeSpan(2, 57, 0)));
|
||||
@@ -653,7 +665,7 @@ public class PlayoutModeSchedulerMultipleTests : SchedulerTestBase
|
||||
Collection = collectionThree,
|
||||
CollectionId = collectionThree.Id
|
||||
},
|
||||
Count = 3
|
||||
Count = "3"
|
||||
};
|
||||
|
||||
var scheduleItemsEnumerator = new OrderedScheduleItemsEnumerator(
|
||||
@@ -694,6 +706,7 @@ public class PlayoutModeSchedulerMultipleTests : SchedulerTestBase
|
||||
scheduleItem,
|
||||
NextScheduleItem,
|
||||
HardStop(scheduleItemsEnumerator),
|
||||
_random,
|
||||
_cancellationToken);
|
||||
|
||||
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(3));
|
||||
@@ -775,7 +788,7 @@ public class PlayoutModeSchedulerMultipleTests : SchedulerTestBase
|
||||
Collection = collectionThree,
|
||||
CollectionId = collectionThree.Id
|
||||
},
|
||||
Count = 3
|
||||
Count = "3"
|
||||
};
|
||||
|
||||
var scheduleItemsEnumerator = new OrderedScheduleItemsEnumerator(
|
||||
@@ -816,6 +829,7 @@ public class PlayoutModeSchedulerMultipleTests : SchedulerTestBase
|
||||
scheduleItem,
|
||||
NextScheduleItem,
|
||||
HardStop(scheduleItemsEnumerator),
|
||||
_random,
|
||||
_cancellationToken);
|
||||
|
||||
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(3));
|
||||
@@ -865,7 +879,7 @@ public class PlayoutModeSchedulerMultipleTests : SchedulerTestBase
|
||||
PlaybackOrder = PlaybackOrder.Chronological,
|
||||
TailFiller = null,
|
||||
FallbackFiller = null,
|
||||
Count = 2
|
||||
Count = "2"
|
||||
};
|
||||
|
||||
var enumerator = new ChronologicalMediaCollectionEnumerator(
|
||||
@@ -896,6 +910,7 @@ public class PlayoutModeSchedulerMultipleTests : SchedulerTestBase
|
||||
scheduleItem,
|
||||
NextScheduleItem,
|
||||
HardStop(scheduleItemsEnumerator),
|
||||
_random,
|
||||
_cancellationToken);
|
||||
|
||||
playoutItems.ShouldBeEmpty();
|
||||
|
||||
@@ -12,9 +12,14 @@ namespace ErsatzTV.Core.Tests.Scheduling;
|
||||
public class PlayoutModeSchedulerOneTests : SchedulerTestBase
|
||||
{
|
||||
[SetUp]
|
||||
public void SetUp() => _cancellationToken = new CancellationTokenSource(TimeSpan.FromSeconds(10)).Token;
|
||||
public void SetUp()
|
||||
{
|
||||
_cancellationToken = new CancellationTokenSource(TimeSpan.FromSeconds(10)).Token;
|
||||
_random = new Random();
|
||||
}
|
||||
|
||||
private CancellationToken _cancellationToken;
|
||||
private Random _random;
|
||||
|
||||
[Test]
|
||||
public void Should_Have_Gap_With_No_Tail_No_Fallback()
|
||||
@@ -51,6 +56,7 @@ public class PlayoutModeSchedulerOneTests : SchedulerTestBase
|
||||
scheduleItem,
|
||||
NextScheduleItem,
|
||||
HardStop(scheduleItemsEnumerator),
|
||||
_random,
|
||||
_cancellationToken);
|
||||
|
||||
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(1));
|
||||
@@ -134,6 +140,7 @@ public class PlayoutModeSchedulerOneTests : SchedulerTestBase
|
||||
scheduleItem,
|
||||
NextScheduleItem,
|
||||
HardStop(scheduleItemsEnumerator),
|
||||
_random,
|
||||
_cancellationToken);
|
||||
|
||||
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(1));
|
||||
@@ -202,6 +209,7 @@ public class PlayoutModeSchedulerOneTests : SchedulerTestBase
|
||||
scheduleItem,
|
||||
NextScheduleItem,
|
||||
HardStop(scheduleItemsEnumerator),
|
||||
_random,
|
||||
_cancellationToken);
|
||||
|
||||
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(3));
|
||||
@@ -284,6 +292,7 @@ public class PlayoutModeSchedulerOneTests : SchedulerTestBase
|
||||
scheduleItem,
|
||||
NextScheduleItem,
|
||||
HardStop(scheduleItemsEnumerator),
|
||||
_random,
|
||||
_cancellationToken);
|
||||
|
||||
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(3));
|
||||
@@ -356,6 +365,7 @@ public class PlayoutModeSchedulerOneTests : SchedulerTestBase
|
||||
scheduleItem,
|
||||
NextScheduleItem,
|
||||
HardStop(scheduleItemsEnumerator),
|
||||
_random,
|
||||
_cancellationToken);
|
||||
|
||||
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.Add(new TimeSpan(2, 57, 0)));
|
||||
@@ -454,6 +464,7 @@ public class PlayoutModeSchedulerOneTests : SchedulerTestBase
|
||||
scheduleItem,
|
||||
NextScheduleItem,
|
||||
HardStop(scheduleItemsEnumerator),
|
||||
_random,
|
||||
_cancellationToken);
|
||||
|
||||
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(3));
|
||||
@@ -558,6 +569,7 @@ public class PlayoutModeSchedulerOneTests : SchedulerTestBase
|
||||
scheduleItem,
|
||||
NextScheduleItem,
|
||||
HardStop(scheduleItemsEnumerator),
|
||||
_random,
|
||||
_cancellationToken);
|
||||
|
||||
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(3));
|
||||
@@ -644,6 +656,7 @@ public class PlayoutModeSchedulerOneTests : SchedulerTestBase
|
||||
scheduleItem,
|
||||
NextScheduleItem,
|
||||
HardStop(scheduleItemsEnumerator),
|
||||
_random,
|
||||
_cancellationToken);
|
||||
|
||||
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(3));
|
||||
@@ -744,6 +757,7 @@ public class PlayoutModeSchedulerOneTests : SchedulerTestBase
|
||||
scheduleItem,
|
||||
NextScheduleItem,
|
||||
HardStop(scheduleItemsEnumerator),
|
||||
_random,
|
||||
_cancellationToken);
|
||||
|
||||
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(3));
|
||||
@@ -823,6 +837,7 @@ public class PlayoutModeSchedulerOneTests : SchedulerTestBase
|
||||
scheduleItem,
|
||||
NextScheduleItem,
|
||||
HardStop(scheduleItemsEnumerator),
|
||||
_random,
|
||||
_cancellationToken);
|
||||
|
||||
playoutItems.ShouldBeEmpty();
|
||||
|
||||
@@ -7,6 +7,8 @@ public record FFmpegProfile
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; }
|
||||
public int ThreadCount { get; set; }
|
||||
public bool NormalizeAudio { get; set; }
|
||||
public bool NormalizeVideo { get; set; }
|
||||
public HardwareAccelerationKind HardwareAcceleration { get; set; }
|
||||
public string VaapiDisplay { get; set; }
|
||||
public VaapiDriver VaapiDriver { get; set; }
|
||||
@@ -15,6 +17,7 @@ public record FFmpegProfile
|
||||
public int ResolutionId { get; set; }
|
||||
public Resolution Resolution { get; set; }
|
||||
public ScalingBehavior ScalingBehavior { get; set; }
|
||||
public FilterMode PadMode { get; set; }
|
||||
public FFmpegProfileVideoFormat VideoFormat { get; set; }
|
||||
public string VideoProfile { get; set; }
|
||||
public string VideoPreset { get; set; }
|
||||
@@ -31,6 +34,7 @@ public record FFmpegProfile
|
||||
public int AudioChannels { get; set; }
|
||||
public int AudioSampleRate { get; set; }
|
||||
public bool NormalizeFramerate { get; set; }
|
||||
public bool NormalizeColors { get; set; }
|
||||
public bool? DeinterlaceVideo { get; set; }
|
||||
|
||||
public static FFmpegProfile New(string name, Resolution resolution) =>
|
||||
@@ -40,6 +44,8 @@ public record FFmpegProfile
|
||||
ThreadCount = 0,
|
||||
ResolutionId = resolution.Id,
|
||||
Resolution = resolution,
|
||||
ScalingBehavior = ScalingBehavior.ScaleAndPad,
|
||||
PadMode = FilterMode.Software,
|
||||
VideoFormat = FFmpegProfileVideoFormat.H264,
|
||||
VideoProfile = "high",
|
||||
VideoPreset = ErsatzTV.FFmpeg.Preset.VideoPreset.Unset,
|
||||
@@ -56,6 +62,9 @@ public record FFmpegProfile
|
||||
DeinterlaceVideo = true,
|
||||
NormalizeFramerate = false,
|
||||
HardwareAcceleration = HardwareAccelerationKind.None,
|
||||
QsvExtraHardwareFrames = 64
|
||||
QsvExtraHardwareFrames = 64,
|
||||
NormalizeAudio = true,
|
||||
NormalizeVideo = true,
|
||||
NormalizeColors = true
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace ErsatzTV.Core.Domain;
|
||||
|
||||
public enum FilterMode
|
||||
{
|
||||
HardwareIfPossible = 0,
|
||||
Software = 1
|
||||
}
|
||||
@@ -6,5 +6,6 @@ public enum MarathonGroupBy
|
||||
Show = 1,
|
||||
Season = 2,
|
||||
Artist = 3,
|
||||
Album = 4
|
||||
Album = 4,
|
||||
Director = 5
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ public class Artwork
|
||||
public ArtworkKind ArtworkKind { get; set; }
|
||||
public DateTime DateAdded { get; set; }
|
||||
public DateTime DateUpdated { get; set; }
|
||||
public bool? IsMetadataOrphan { get; set; }
|
||||
|
||||
public bool IsExternalUrl() => IsExternalUrl(Path);
|
||||
|
||||
|
||||
@@ -4,5 +4,5 @@ public class ProgramScheduleItemMultiple : ProgramScheduleItem
|
||||
{
|
||||
public MultipleMode MultipleMode { get; set; }
|
||||
|
||||
public int Count { get; set; }
|
||||
public string Count { get; set; }
|
||||
}
|
||||
|
||||
@@ -11,16 +11,16 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Bugsnag" Version="4.1.0" />
|
||||
<PackageReference Include="Destructurama.Attributed" Version="5.1.0" />
|
||||
<PackageReference Include="Destructurama.Attributed" Version="5.2.0" />
|
||||
<PackageReference Include="Flurl" Version="4.0.0" />
|
||||
<PackageReference Include="Humanizer.Core" Version="3.0.1" />
|
||||
<PackageReference Include="LanguageExt.Core" Version="4.4.9" />
|
||||
<PackageReference Include="LanguageExt.Transformers" Version="4.4.8" />
|
||||
<PackageReference Include="MediatR" Version="[12.5.0]" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.2" />
|
||||
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="3.0.1" />
|
||||
<PackageReference Include="NCalcSync" Version="5.11.0" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
||||
@@ -28,7 +28,7 @@
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
|
||||
<PackageReference Include="SkiaSharp" Version="3.119.1" />
|
||||
<PackageReference Include="SkiaSharp.NativeAssets.Linux.NoDependencies" Version="3.119.1" />
|
||||
<PackageReference Include="System.CommandLine" Version="2.0.1" />
|
||||
<PackageReference Include="System.CommandLine" Version="2.0.2" />
|
||||
<PackageReference Include="Testably.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="TimeSpanParserUtil" Version="1.2.0" />
|
||||
<PackageReference Include="YamlDotNet" Version="16.3.0" />
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace ErsatzTV.Core.Extensions;
|
||||
|
||||
public static class ProgramScheduleItemExtensions
|
||||
{
|
||||
public static ProgramScheduleItem DeepCopy(this ProgramScheduleItem item)
|
||||
{
|
||||
if (item == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var settings = new JsonSerializerSettings
|
||||
{
|
||||
// program schedule item => graphics element => (same) program schedule item should be ignored
|
||||
ReferenceLoopHandling = ReferenceLoopHandling.Ignore,
|
||||
NullValueHandling = NullValueHandling.Ignore
|
||||
};
|
||||
|
||||
string json = JsonConvert.SerializeObject(item, settings);
|
||||
return (ProgramScheduleItem)JsonConvert.DeserializeObject(json, item.GetType(), settings);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Globalization;
|
||||
using System.IO.Abstractions;
|
||||
using System.IO.Enumeration;
|
||||
using ErsatzTV.Core.Domain;
|
||||
@@ -315,7 +316,8 @@ public class CustomStreamSelector(IFileSystem fileSystem, ILogger<CustomStreamSe
|
||||
{
|
||||
"channel_number" => channel.Number,
|
||||
"channel_name" => channel.Name,
|
||||
"time_of_day_seconds" => contentStartTime.LocalDateTime.TimeOfDay.TotalSeconds,
|
||||
"time_of_day_seconds" => contentStartTime.TimeOfDay.TotalSeconds,
|
||||
"day_of_week" => GetLocalizedDayOfWeekIndex(contentStartTime),
|
||||
_ => e.Result
|
||||
};
|
||||
};
|
||||
@@ -341,4 +343,11 @@ public class CustomStreamSelector(IFileSystem fileSystem, ILogger<CustomStreamSe
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static int GetLocalizedDayOfWeekIndex(DateTimeOffset date)
|
||||
{
|
||||
DayOfWeek first = CultureInfo.CurrentCulture.DateTimeFormat.FirstDayOfWeek;
|
||||
DayOfWeek current = date.DayOfWeek;
|
||||
return ((int)current - (int)first + 7) % 7;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -509,15 +509,19 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
scaledSize,
|
||||
paddedSize,
|
||||
cropSize,
|
||||
false,
|
||||
channel.FFmpegProfile.PadMode is FilterMode.HardwareIfPossible
|
||||
? FFmpegFilterMode.HardwareIfPossible
|
||||
: FFmpegFilterMode.Software,
|
||||
IsAnamorphic: false,
|
||||
playbackSettings.FrameRate,
|
||||
playbackSettings.VideoBitrate,
|
||||
playbackSettings.VideoBufferSize,
|
||||
playbackSettings.VideoTrackTimeScale,
|
||||
playbackSettings.NormalizeColors,
|
||||
playbackSettings.Deinterlace);
|
||||
|
||||
// only use graphics engine when we have elements
|
||||
if (graphicsElementContexts.Count > 0 || graphicsElements.Count > 0)
|
||||
// only use graphics engine when we have elements, and are normalizing video
|
||||
if (videoFormat != VideoFormat.Copy && (graphicsElementContexts.Count > 0 || graphicsElements.Count > 0))
|
||||
{
|
||||
FrameSize targetSize = await desiredState.CroppedSize.IfNoneAsync(desiredState.ScaledSize);
|
||||
|
||||
@@ -711,11 +715,15 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
new FrameSize(desiredResolution.Width, desiredResolution.Height),
|
||||
new FrameSize(desiredResolution.Width, desiredResolution.Height),
|
||||
Option<FrameSize>.None,
|
||||
false,
|
||||
channel.FFmpegProfile.PadMode is FilterMode.HardwareIfPossible
|
||||
? FFmpegFilterMode.HardwareIfPossible
|
||||
: FFmpegFilterMode.Software,
|
||||
IsAnamorphic: false,
|
||||
playbackSettings.FrameRate,
|
||||
playbackSettings.VideoBitrate,
|
||||
playbackSettings.VideoBufferSize,
|
||||
playbackSettings.VideoTrackTimeScale,
|
||||
playbackSettings.NormalizeColors,
|
||||
playbackSettings.Deinterlace);
|
||||
|
||||
OutputFormatKind outputFormat = OutputFormatKind.MpegTs;
|
||||
|
||||
@@ -31,4 +31,5 @@ public class FFmpegPlaybackSettings
|
||||
public NormalizeLoudnessMode NormalizeLoudnessMode { get; set; }
|
||||
public Option<double> TargetLoudness { get; set; }
|
||||
public Option<FrameRate> FrameRate { get; set; }
|
||||
public bool NormalizeColors { get; set; }
|
||||
}
|
||||
|
||||
@@ -118,6 +118,11 @@ public static class FFmpegPlaybackSettingsCalculator
|
||||
result.FrameRate = targetFramerate;
|
||||
}
|
||||
|
||||
if (ffmpegProfile.NormalizeColors)
|
||||
{
|
||||
result.NormalizeColors = true;
|
||||
}
|
||||
|
||||
result.VideoTrackTimeScale = 90000;
|
||||
|
||||
foreach (MediaStream stream in videoStream.Where(s => !s.AttachedPic))
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.IO.Abstractions;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.Domain.Scheduling;
|
||||
@@ -8,7 +9,11 @@ using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Core.FFmpeg;
|
||||
|
||||
public class WatermarkSelector(IImageCache imageCache, IDecoSelector decoSelector, ILogger<WatermarkSelector> logger)
|
||||
public class WatermarkSelector(
|
||||
IFileSystem fileSystem,
|
||||
IImageCache imageCache,
|
||||
IDecoSelector decoSelector,
|
||||
ILogger<WatermarkSelector> logger)
|
||||
: IWatermarkSelector
|
||||
{
|
||||
public List<WatermarkOptions> SelectWatermarks(
|
||||
@@ -170,10 +175,18 @@ public class WatermarkSelector(IImageCache imageCache, IDecoSelector decoSelecto
|
||||
{
|
||||
// used for song progress overlay
|
||||
case ChannelWatermarkImageSource.Resource:
|
||||
return new WatermarkOptions(
|
||||
watermark,
|
||||
Path.Combine(FileSystemLayout.ResourcesCacheFolder, watermark.Image),
|
||||
Option<int>.None);
|
||||
string resourcePath = fileSystem.Path.Combine(
|
||||
FileSystemLayout.ResourcesCacheFolder,
|
||||
watermark.Image);
|
||||
if (fileSystem.File.Exists(resourcePath))
|
||||
{
|
||||
return new WatermarkOptions(watermark, resourcePath, Option<int>.None);
|
||||
}
|
||||
|
||||
logger.LogWarning(
|
||||
"Watermark resource no longer exists at {Path} and will be ignored",
|
||||
resourcePath);
|
||||
return None;
|
||||
case ChannelWatermarkImageSource.Custom:
|
||||
// bad form validation makes this possible
|
||||
if (string.IsNullOrWhiteSpace(watermark.Image))
|
||||
@@ -190,10 +203,16 @@ public class WatermarkSelector(IImageCache imageCache, IDecoSelector decoSelecto
|
||||
watermark.Image,
|
||||
ArtworkKind.Watermark,
|
||||
Option<int>.None);
|
||||
return new WatermarkOptions(
|
||||
watermark,
|
||||
customPath,
|
||||
None);
|
||||
|
||||
if (fileSystem.File.Exists(customPath))
|
||||
{
|
||||
return new WatermarkOptions(watermark, customPath, None);
|
||||
}
|
||||
|
||||
logger.LogWarning(
|
||||
"Custom watermark no longer exists at {Path} and will be ignored",
|
||||
customPath);
|
||||
return None;
|
||||
case ChannelWatermarkImageSource.ChannelLogo:
|
||||
logger.LogDebug("Watermark will come from playout item (channel logo)");
|
||||
|
||||
@@ -207,7 +226,15 @@ public class WatermarkSelector(IImageCache imageCache, IDecoSelector decoSelecto
|
||||
: imageCache.GetPathForImage(logoArtwork.Path, ArtworkKind.Logo, Option<int>.None);
|
||||
}
|
||||
|
||||
return new WatermarkOptions(watermark, channelPath, None);
|
||||
if (fileSystem.File.Exists(channelPath))
|
||||
{
|
||||
return new WatermarkOptions(watermark, channelPath, None);
|
||||
}
|
||||
|
||||
logger.LogWarning(
|
||||
"Channel logo no longer exists at {Path} and will be ignored",
|
||||
channelPath);
|
||||
return None;
|
||||
default:
|
||||
throw new NotSupportedException("Unsupported watermark image source");
|
||||
}
|
||||
@@ -225,10 +252,16 @@ public class WatermarkSelector(IImageCache imageCache, IDecoSelector decoSelecto
|
||||
channel.Watermark.Image,
|
||||
ArtworkKind.Watermark,
|
||||
Option<int>.None);
|
||||
return new WatermarkOptions(
|
||||
channel.Watermark,
|
||||
customPath,
|
||||
None);
|
||||
|
||||
if (fileSystem.File.Exists(customPath))
|
||||
{
|
||||
return new WatermarkOptions(channel.Watermark, customPath, None);
|
||||
}
|
||||
|
||||
logger.LogWarning(
|
||||
"Custom watermark no longer exists at {Path} and will be ignored",
|
||||
customPath);
|
||||
return None;
|
||||
case ChannelWatermarkImageSource.ChannelLogo:
|
||||
logger.LogDebug("Watermark will come from channel (channel logo)");
|
||||
|
||||
@@ -242,7 +275,15 @@ public class WatermarkSelector(IImageCache imageCache, IDecoSelector decoSelecto
|
||||
: imageCache.GetPathForImage(logoArtwork.Path, ArtworkKind.Logo, Option<int>.None);
|
||||
}
|
||||
|
||||
return new WatermarkOptions(channel.Watermark, channelPath, None);
|
||||
if (fileSystem.File.Exists(channelPath))
|
||||
{
|
||||
return new WatermarkOptions(channel.Watermark, channelPath, None);
|
||||
}
|
||||
|
||||
logger.LogWarning(
|
||||
"Channel logo no longer exists at {Path} and will be ignored",
|
||||
channelPath);
|
||||
return None;
|
||||
default:
|
||||
throw new NotSupportedException("Unsupported watermark image source");
|
||||
}
|
||||
@@ -260,10 +301,16 @@ public class WatermarkSelector(IImageCache imageCache, IDecoSelector decoSelecto
|
||||
watermark.Image,
|
||||
ArtworkKind.Watermark,
|
||||
Option<int>.None);
|
||||
return new WatermarkOptions(
|
||||
watermark,
|
||||
customPath,
|
||||
None);
|
||||
|
||||
if (fileSystem.File.Exists(customPath))
|
||||
{
|
||||
return new WatermarkOptions(watermark, customPath, None);
|
||||
}
|
||||
|
||||
logger.LogWarning(
|
||||
"Custom watermark no longer exists at {Path} and will be ignored",
|
||||
customPath);
|
||||
return None;
|
||||
case ChannelWatermarkImageSource.ChannelLogo:
|
||||
logger.LogDebug("Watermark will come from global (channel logo)");
|
||||
|
||||
@@ -277,7 +324,15 @@ public class WatermarkSelector(IImageCache imageCache, IDecoSelector decoSelecto
|
||||
: imageCache.GetPathForImage(logoArtwork.Path, ArtworkKind.Logo, Option<int>.None);
|
||||
}
|
||||
|
||||
return new WatermarkOptions(watermark, channelPath, None);
|
||||
if (fileSystem.File.Exists(channelPath))
|
||||
{
|
||||
return new WatermarkOptions(watermark, channelPath, None);
|
||||
}
|
||||
|
||||
logger.LogWarning(
|
||||
"Channel logo no longer exists at {Path} and will be ignored",
|
||||
channelPath);
|
||||
return None;
|
||||
default:
|
||||
throw new NotSupportedException("Unsupported watermark image source");
|
||||
}
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
using YamlDotNet.Serialization;
|
||||
|
||||
namespace ErsatzTV.Core.Graphics;
|
||||
|
||||
public class BaseGraphicsElement
|
||||
{
|
||||
public string Name { get; set; }
|
||||
|
||||
[YamlIgnore]
|
||||
public string SourceFileName { get; set; }
|
||||
|
||||
public string DebugName() => string.IsNullOrEmpty(Name) ? SourceFileName : Name;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
namespace ErsatzTV.Core.Health.Checks;
|
||||
|
||||
public interface IEmptyScheduleHealthCheck : IHealthCheck
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
namespace ErsatzTV.Core.Health.Checks;
|
||||
|
||||
public interface IFFmpegCapabilitiesHealthCheck : IHealthCheck
|
||||
{
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Repositories;
|
||||
namespace ErsatzTV.Core.Interfaces.Repositories;
|
||||
|
||||
public interface IArtworkRepository
|
||||
{
|
||||
Task<List<Artwork>> GetOrphanedArtwork();
|
||||
Task<Unit> Delete(List<Artwork> artwork);
|
||||
Task<List<int>> GetOrphanedArtworkIds();
|
||||
Task<Unit> Delete(List<int> artworkIds);
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ public interface IMetadataRepository
|
||||
Task<bool> UpdateStatistics(MediaItem mediaItem, MediaVersion incoming, bool updateVersion = true);
|
||||
Task<Unit> UpdateArtworkPath(Artwork artwork);
|
||||
Task<Unit> AddArtwork(Domain.Metadata metadata, Artwork artwork);
|
||||
Task<Unit> RemoveArtwork(Domain.Metadata metadata, ArtworkKind artworkKind);
|
||||
Task<bool> RemoveArtworkWithKind(Domain.Metadata metadata, ArtworkKind artworkKind);
|
||||
|
||||
Task<bool> CloneArtwork(
|
||||
Domain.Metadata metadata,
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Repositories;
|
||||
|
||||
public interface IPlexMetadataRepository
|
||||
{
|
||||
Task<Unit> RemoveArtwork(Domain.Metadata metadata, ArtworkKind artworkKind);
|
||||
}
|
||||
@@ -11,5 +11,6 @@ public interface IPlayoutModeScheduler<in T> where T : ProgramScheduleItem
|
||||
T scheduleItem,
|
||||
ProgramScheduleItem nextScheduleItem,
|
||||
DateTimeOffset hardStop,
|
||||
Random random,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -45,5 +45,14 @@ public interface ISearchIndex : IDisposable
|
||||
int limit,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<SearchResult> Search(
|
||||
IClient client,
|
||||
string query,
|
||||
string smartCollectionName,
|
||||
int skip,
|
||||
int limit,
|
||||
List<string> defaultFields,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
void Commit();
|
||||
}
|
||||
|
||||
@@ -44,10 +44,11 @@ public partial class FallbackMetadataProvider(IClient client) : IFallbackMetadat
|
||||
{
|
||||
MetadataKind = MetadataKind.Fallback,
|
||||
Title = fileName ?? showFolder,
|
||||
Genres = new List<Genre>(),
|
||||
Tags = new List<Tag>(),
|
||||
Studios = new List<Studio>(),
|
||||
Actors = new List<Actor>()
|
||||
Genres = [],
|
||||
Tags = [],
|
||||
Studios = [],
|
||||
Actors = [],
|
||||
Artwork = []
|
||||
};
|
||||
return GetTelevisionShowMetadata(fileName, metadata);
|
||||
}
|
||||
@@ -56,7 +57,11 @@ public partial class FallbackMetadataProvider(IClient client) : IFallbackMetadat
|
||||
{
|
||||
string fileName = Path.GetFileName(artistFolder);
|
||||
return new ArtistMetadata
|
||||
{ MetadataKind = MetadataKind.Fallback, Title = fileName ?? artistFolder };
|
||||
{
|
||||
MetadataKind = MetadataKind.Fallback,
|
||||
Title = fileName ?? artistFolder,
|
||||
Artwork = []
|
||||
};
|
||||
}
|
||||
|
||||
public List<EpisodeMetadata> GetFallbackMetadata(Episode episode)
|
||||
@@ -69,18 +74,18 @@ public partial class FallbackMetadataProvider(IClient client) : IFallbackMetadat
|
||||
Title = Path.GetFileNameWithoutExtension(path) ?? path,
|
||||
DateAdded = DateTime.UtcNow,
|
||||
EpisodeNumber = 0,
|
||||
Actors = new List<Actor>(),
|
||||
Artwork = new List<Artwork>(),
|
||||
Directors = new List<Director>(),
|
||||
Genres = new List<Genre>(),
|
||||
Guids = new List<MetadataGuid>(),
|
||||
Studios = new List<Studio>(),
|
||||
Tags = new List<Tag>(),
|
||||
Writers = new List<Writer>()
|
||||
Actors = [],
|
||||
Artwork = [],
|
||||
Directors = [],
|
||||
Genres = [],
|
||||
Guids = [],
|
||||
Studios = [],
|
||||
Tags = [],
|
||||
Writers = []
|
||||
};
|
||||
return fileName != null
|
||||
? GetEpisodeMetadata(fileName, baseMetadata)
|
||||
: new List<EpisodeMetadata> { baseMetadata };
|
||||
: [baseMetadata];
|
||||
}
|
||||
|
||||
public MovieMetadata GetFallbackMetadata(Movie movie)
|
||||
@@ -91,13 +96,14 @@ public partial class FallbackMetadataProvider(IClient client) : IFallbackMetadat
|
||||
{
|
||||
MetadataKind = MetadataKind.Fallback,
|
||||
Title = Path.GetFileNameWithoutExtension(path) ?? path,
|
||||
Genres = new List<Genre>(),
|
||||
Tags = new List<Tag>(),
|
||||
Studios = new List<Studio>(),
|
||||
Actors = new List<Actor>(),
|
||||
Directors = new List<Director>(),
|
||||
Writers = new List<Writer>(),
|
||||
Guids = new List<MetadataGuid>()
|
||||
Artwork = [],
|
||||
Genres = [],
|
||||
Tags = [],
|
||||
Studios = [],
|
||||
Actors = [],
|
||||
Directors = [],
|
||||
Writers = [],
|
||||
Guids = []
|
||||
};
|
||||
|
||||
return fileName != null ? GetMovieMetadata(fileName, metadata) : metadata;
|
||||
@@ -110,7 +116,8 @@ public partial class FallbackMetadataProvider(IClient client) : IFallbackMetadat
|
||||
var metadata = new MusicVideoMetadata
|
||||
{
|
||||
MetadataKind = MetadataKind.Fallback,
|
||||
Title = fileName ?? path
|
||||
Title = fileName ?? path,
|
||||
Artwork = []
|
||||
};
|
||||
|
||||
return GetMusicVideoMetadata(fileName, metadata);
|
||||
@@ -125,13 +132,14 @@ public partial class FallbackMetadataProvider(IClient client) : IFallbackMetadat
|
||||
MetadataKind = MetadataKind.Fallback,
|
||||
Title = fileName ?? path,
|
||||
OtherVideo = otherVideo,
|
||||
Genres = new List<Genre>(),
|
||||
Tags = new List<Tag>(),
|
||||
Studios = new List<Studio>(),
|
||||
Actors = new List<Actor>(),
|
||||
Directors = new List<Director>(),
|
||||
Writers = new List<Writer>(),
|
||||
Guids = new List<MetadataGuid>()
|
||||
Artwork = [],
|
||||
Genres = [],
|
||||
Tags = [],
|
||||
Studios = [],
|
||||
Actors = [],
|
||||
Directors = [],
|
||||
Writers = [],
|
||||
Guids = []
|
||||
};
|
||||
|
||||
return GetOtherVideoMetadata(path, metadata);
|
||||
@@ -145,7 +153,8 @@ public partial class FallbackMetadataProvider(IClient client) : IFallbackMetadat
|
||||
{
|
||||
MetadataKind = MetadataKind.Fallback,
|
||||
Title = fileName ?? path,
|
||||
Song = song
|
||||
Song = song,
|
||||
Artwork = []
|
||||
};
|
||||
|
||||
return GetSongMetadata(path, metadata);
|
||||
@@ -160,6 +169,7 @@ public partial class FallbackMetadataProvider(IClient client) : IFallbackMetadat
|
||||
MetadataKind = MetadataKind.Fallback,
|
||||
Title = fileName ?? path,
|
||||
Image = image,
|
||||
Artwork = [],
|
||||
Genres = [],
|
||||
Tags = [],
|
||||
Studios = [],
|
||||
@@ -179,6 +189,7 @@ public partial class FallbackMetadataProvider(IClient client) : IFallbackMetadat
|
||||
MetadataKind = MetadataKind.Fallback,
|
||||
Title = fileName ?? path,
|
||||
RemoteStream = remoteStream,
|
||||
Artwork = [],
|
||||
Genres = [],
|
||||
Tags = [],
|
||||
Studios = [],
|
||||
@@ -232,14 +243,14 @@ public partial class FallbackMetadataProvider(IClient client) : IFallbackMetadat
|
||||
DateAdded = baseMetadata.DateAdded,
|
||||
DateUpdated = baseMetadata.DateAdded,
|
||||
Title = baseMetadata.Title,
|
||||
Actors = new List<Actor>(),
|
||||
Artwork = new List<Artwork>(),
|
||||
Directors = new List<Director>(),
|
||||
Genres = new List<Genre>(),
|
||||
Guids = new List<MetadataGuid>(),
|
||||
Studios = new List<Studio>(),
|
||||
Tags = new List<Tag>(),
|
||||
Writers = new List<Writer>()
|
||||
Actors = [],
|
||||
Artwork = [],
|
||||
Directors = [],
|
||||
Genres = [],
|
||||
Guids = [],
|
||||
Studios = [],
|
||||
Tags = [],
|
||||
Writers = []
|
||||
};
|
||||
|
||||
result.Add(metadata);
|
||||
@@ -290,6 +301,7 @@ public partial class FallbackMetadataProvider(IClient client) : IFallbackMetadat
|
||||
? match.Groups[2].Value.Trim()
|
||||
: Path.GetFileNameWithoutExtension(fileName);
|
||||
metadata.Artists = [];
|
||||
metadata.Artwork = [];
|
||||
metadata.Genres = [];
|
||||
metadata.Tags = [];
|
||||
metadata.Studios = [];
|
||||
@@ -326,11 +338,11 @@ public partial class FallbackMetadataProvider(IClient client) : IFallbackMetadat
|
||||
.Map(t => new Tag { Name = t })
|
||||
.ToList();
|
||||
|
||||
metadata.Artwork = new List<Artwork>();
|
||||
metadata.Actors = new List<Actor>();
|
||||
metadata.Genres = new List<Genre>();
|
||||
metadata.Actors = [];
|
||||
metadata.Artwork = [];
|
||||
metadata.Genres = [];
|
||||
metadata.Tags = tags;
|
||||
metadata.Studios = new List<Studio>();
|
||||
metadata.Studios = [];
|
||||
metadata.DateUpdated = DateTime.UtcNow;
|
||||
metadata.OriginalTitle = Path.GetRelativePath(libraryPath, path);
|
||||
|
||||
@@ -364,8 +376,8 @@ public partial class FallbackMetadataProvider(IClient client) : IFallbackMetadat
|
||||
.Map(t => new Tag { Name = t })
|
||||
.ToList();
|
||||
|
||||
metadata.Artwork = [];
|
||||
metadata.Actors = [];
|
||||
metadata.Artwork = [];
|
||||
metadata.Genres = [];
|
||||
metadata.Tags = tags;
|
||||
metadata.Studios = [];
|
||||
@@ -402,8 +414,8 @@ public partial class FallbackMetadataProvider(IClient client) : IFallbackMetadat
|
||||
.Map(t => new Tag { Name = t })
|
||||
.ToList();
|
||||
|
||||
metadata.Artwork = [];
|
||||
metadata.Actors = [];
|
||||
metadata.Artwork = [];
|
||||
metadata.Genres = [];
|
||||
metadata.Tags = tags;
|
||||
metadata.Studios = [];
|
||||
@@ -440,11 +452,11 @@ public partial class FallbackMetadataProvider(IClient client) : IFallbackMetadat
|
||||
.Map(t => new Tag { Name = t })
|
||||
.ToList();
|
||||
|
||||
metadata.Artwork = new List<Artwork>();
|
||||
metadata.Actors = new List<Actor>();
|
||||
metadata.Genres = new List<Genre>();
|
||||
metadata.Actors = [];
|
||||
metadata.Artwork = [];
|
||||
metadata.Genres = [];
|
||||
metadata.Tags = tags;
|
||||
metadata.Studios = new List<Studio>();
|
||||
metadata.Studios = [];
|
||||
metadata.DateUpdated = DateTime.UtcNow;
|
||||
metadata.OriginalTitle = Path.GetRelativePath(libraryPath, path);
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
using ErsatzTV.Core.Interfaces.Scheduling;
|
||||
using NCalc;
|
||||
|
||||
namespace ErsatzTV.Core.Scheduling;
|
||||
|
||||
public static class CountExpression
|
||||
{
|
||||
public static int Evaluate(
|
||||
string countExpression,
|
||||
IMediaCollectionEnumerator enumerator,
|
||||
Random random,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
int enumeratorCount = enumerator is PlaylistEnumerator playlistEnumerator
|
||||
? playlistEnumerator.CountForRandom
|
||||
: enumerator.Count;
|
||||
var expression = new Expression(countExpression);
|
||||
expression.EvaluateParameter += (name, e) =>
|
||||
{
|
||||
e.Result = name switch
|
||||
{
|
||||
"count" => enumeratorCount,
|
||||
"random" => enumeratorCount > 0 ? random.Next() % enumeratorCount : 0,
|
||||
_ => e.Result
|
||||
};
|
||||
};
|
||||
|
||||
object expressionResult = 0;
|
||||
try
|
||||
{
|
||||
expressionResult = expression.Evaluate(cancellationToken);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
|
||||
return expressionResult switch
|
||||
{
|
||||
double d when double.IsInfinity(d) || double.IsNaN(d) => 0,
|
||||
double doubleResult => (int)Math.Floor(doubleResult),
|
||||
int intResult => intResult,
|
||||
long longResult => (int)longResult,
|
||||
_ => 0
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,10 @@ public class MarathonHelper(IMediaCollectionRepository mediaCollectionRepository
|
||||
groups.AddRange(mediaItems.GroupBy(MediaItemKeyByAlbum));
|
||||
itemPlaybackOrder = marathonShuffleItems ? PlaybackOrder.Shuffle : PlaybackOrder.Chronological;
|
||||
break;
|
||||
case MarathonGroupBy.Director:
|
||||
groups.AddRange(mediaItems.GroupBy(MediaItemKeyByDirector));
|
||||
itemPlaybackOrder = marathonShuffleItems ? PlaybackOrder.Shuffle : PlaybackOrder.Chronological;
|
||||
break;
|
||||
default:
|
||||
return Option<PlaylistEnumerator>.None;
|
||||
}
|
||||
@@ -111,6 +115,11 @@ public class MarathonHelper(IMediaCollectionRepository mediaCollectionRepository
|
||||
{
|
||||
groups.AddRange(allMediaItems.GroupBy(MediaItemKeyByAlbum));
|
||||
}
|
||||
// group by (first) director
|
||||
else if (string.Equals(groupBy, "director", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
groups.AddRange(allMediaItems.GroupBy(MediaItemKeyByDirector));
|
||||
}
|
||||
|
||||
Dictionary<PlaylistItem, List<MediaItem>> itemMap = [];
|
||||
|
||||
@@ -189,6 +198,39 @@ public class MarathonHelper(IMediaCollectionRepository mediaCollectionRepository
|
||||
_ => new GroupKey(CollectionType.Collection, 0, null, null, null)
|
||||
};
|
||||
|
||||
private static GroupKey MediaItemKeyByDirector(MediaItem mediaItem) =>
|
||||
mediaItem switch
|
||||
{
|
||||
Movie m => new GroupKey(
|
||||
CollectionType.Collection,
|
||||
m.MovieMetadata.HeadOrNone().Map(mm => FirstDirectorHashCode(mm.Directors)).IfNone(0),
|
||||
null,
|
||||
null,
|
||||
null),
|
||||
Episode e => new GroupKey(
|
||||
CollectionType.Collection,
|
||||
e.EpisodeMetadata.HeadOrNone().Map(em => FirstDirectorHashCode(em.Directors)).IfNone(0),
|
||||
null,
|
||||
null,
|
||||
null),
|
||||
MusicVideo mv => new GroupKey(
|
||||
CollectionType.Collection,
|
||||
mv.MusicVideoMetadata.HeadOrNone().Map(mvm => FirstDirectorHashCode(mvm.Directors)).IfNone(0),
|
||||
null,
|
||||
null,
|
||||
null),
|
||||
OtherVideo ov => new GroupKey(
|
||||
CollectionType.Collection,
|
||||
ov.OtherVideoMetadata.HeadOrNone().Map(ovm => FirstDirectorHashCode(ovm.Directors)).IfNone(0),
|
||||
null,
|
||||
null,
|
||||
null),
|
||||
_ => new GroupKey(CollectionType.Collection, 0, null, null, null)
|
||||
};
|
||||
|
||||
private static int FirstDirectorHashCode(List<Director> directors) =>
|
||||
directors.HeadOrNone().Select(director => director.Name.GetStableHashCode()).FirstOrDefault();
|
||||
|
||||
private static PlaylistItem GroupToPlaylistItem(
|
||||
int index,
|
||||
bool playAllItems,
|
||||
|
||||
@@ -444,9 +444,13 @@ public class SchedulingEngine(
|
||||
return false;
|
||||
}
|
||||
|
||||
int enumeratorCount = enumeratorDetails.Enumerator is PlaylistEnumerator playlistEnumerator
|
||||
? playlistEnumerator.CountForFiller
|
||||
: enumeratorDetails.Enumerator.Count;
|
||||
|
||||
return AddCountInternal(
|
||||
enumeratorDetails,
|
||||
enumeratorDetails.Enumerator.Count,
|
||||
enumeratorCount,
|
||||
fillerKind,
|
||||
customTitle,
|
||||
disableWatermarks);
|
||||
|
||||
@@ -15,7 +15,7 @@ public static class FillerExpression
|
||||
return effectiveChapters;
|
||||
}
|
||||
|
||||
var chapterPoints = effectiveChapters.Map(c => c.EndTime).SkipLast().ToList();
|
||||
var candidateChapters = effectiveChapters.SkipLast().ToList();
|
||||
|
||||
var newChapters = new List<MediaChapter>();
|
||||
|
||||
@@ -26,10 +26,11 @@ public static class FillerExpression
|
||||
double contentDuration = (playoutItem.FinishOffset - playoutItem.StartOffset).TotalSeconds;
|
||||
var matches = 0;
|
||||
|
||||
for (var index = 0; index < chapterPoints.Count; index++)
|
||||
for (var index = 0; index < candidateChapters.Count; index++)
|
||||
{
|
||||
TimeSpan chapterPoint = chapterPoints[index];
|
||||
var expression = new Expression(fillerExpression);
|
||||
MediaChapter chapter = candidateChapters[index];
|
||||
TimeSpan chapterPoint = chapter.EndTime;
|
||||
var expression = new Expression(fillerExpression, ExpressionOptions.CaseInsensitiveStringComparer);
|
||||
int chapterNum = index + 1;
|
||||
double sinceLastFiller = chapterPoint.TotalSeconds - lastFiller;
|
||||
int matchedPoints = matches;
|
||||
@@ -45,6 +46,7 @@ public static class FillerExpression
|
||||
"matched_points" => matchedPoints,
|
||||
"point" => chapterPoint.TotalSeconds,
|
||||
"num" => chapterNum,
|
||||
"title" => chapter.Title ?? string.Empty,
|
||||
_ => e.Result
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System.IO.Abstractions;
|
||||
using System.Reflection;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.Errors;
|
||||
@@ -673,13 +672,6 @@ public class PlayoutBuilder : IPlayoutBuilder
|
||||
.Filter(c => c.ShowId > 0 || c.ArtistId > 0 || !string.IsNullOrWhiteSpace(c.Key))
|
||||
.ToList();
|
||||
List<ProgramScheduleItem> fakeScheduleItems = [];
|
||||
|
||||
// this will be used to clone a schedule item
|
||||
MethodInfo generic = typeof(JsonConvert).GetMethods()
|
||||
.FirstOrDefault(x => x.Name.Equals("DeserializeObject", StringComparison.OrdinalIgnoreCase) &&
|
||||
x.IsGenericMethod &&
|
||||
x.GetParameters().Length == 1)?.MakeGenericMethod(scheduleItem.GetType());
|
||||
|
||||
foreach (CollectionWithItems fakeCollection in fakeCollections)
|
||||
{
|
||||
CollectionKey key = (fakeCollection.ShowId, fakeCollection.ArtistId, fakeCollection.Key) switch
|
||||
@@ -709,8 +701,7 @@ public class PlayoutBuilder : IPlayoutBuilder
|
||||
continue;
|
||||
}
|
||||
|
||||
string serialized = JsonConvert.SerializeObject(scheduleItem);
|
||||
var copyScheduleItem = generic.Invoke(this, [serialized]) as ProgramScheduleItem;
|
||||
var copyScheduleItem = scheduleItem.DeepCopy();
|
||||
copyScheduleItem.CollectionType = key.CollectionType;
|
||||
copyScheduleItem.MediaItemId = key.MediaItemId;
|
||||
copyScheduleItem.FakeCollectionKey = key.FakeCollectionKey;
|
||||
@@ -857,6 +848,7 @@ public class PlayoutBuilder : IPlayoutBuilder
|
||||
multiple,
|
||||
nextScheduleItem,
|
||||
playoutFinish,
|
||||
random,
|
||||
cancellationToken),
|
||||
ProgramScheduleItemDuration duration => schedulerDuration.Schedule(
|
||||
playoutBuilderState,
|
||||
@@ -864,6 +856,7 @@ public class PlayoutBuilder : IPlayoutBuilder
|
||||
duration,
|
||||
nextScheduleItem,
|
||||
playoutFinish,
|
||||
random,
|
||||
cancellationToken),
|
||||
ProgramScheduleItemFlood flood => schedulerFlood.Schedule(
|
||||
playoutBuilderState,
|
||||
@@ -871,6 +864,7 @@ public class PlayoutBuilder : IPlayoutBuilder
|
||||
flood,
|
||||
nextScheduleItem,
|
||||
playoutFinish,
|
||||
random,
|
||||
cancellationToken),
|
||||
ProgramScheduleItemOne one => schedulerOne.Schedule(
|
||||
playoutBuilderState,
|
||||
@@ -878,6 +872,7 @@ public class PlayoutBuilder : IPlayoutBuilder
|
||||
one,
|
||||
nextScheduleItem,
|
||||
playoutFinish,
|
||||
random,
|
||||
cancellationToken),
|
||||
_ => throw new NotSupportedException(nameof(scheduleItem))
|
||||
};
|
||||
|
||||
@@ -23,6 +23,7 @@ public abstract class PlayoutModeSchedulerBase<T>(ILogger logger) : IPlayoutMode
|
||||
T scheduleItem,
|
||||
ProgramScheduleItem nextScheduleItem,
|
||||
DateTimeOffset hardStop,
|
||||
Random random,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
public static DateTimeOffset GetFillerStartTimeAfter(
|
||||
|
||||
@@ -16,6 +16,7 @@ public class PlayoutModeSchedulerDuration(ILogger logger)
|
||||
ProgramScheduleItemDuration scheduleItem,
|
||||
ProgramScheduleItem nextScheduleItem,
|
||||
DateTimeOffset hardStop,
|
||||
Random random,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var warnings = new PlayoutBuildWarnings();
|
||||
|
||||
@@ -15,6 +15,7 @@ public class PlayoutModeSchedulerFlood(ILogger logger) : PlayoutModeSchedulerBas
|
||||
ProgramScheduleItemFlood scheduleItem,
|
||||
ProgramScheduleItem nextScheduleItem,
|
||||
DateTimeOffset hardStop,
|
||||
Random random,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var warnings = new PlayoutBuildWarnings();
|
||||
|
||||
@@ -16,6 +16,7 @@ public class PlayoutModeSchedulerMultiple(Map<CollectionKey, int> collectionItem
|
||||
ProgramScheduleItemMultiple scheduleItem,
|
||||
ProgramScheduleItem nextScheduleItem,
|
||||
DateTimeOffset hardStop,
|
||||
Random random,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var warnings = new PlayoutBuildWarnings();
|
||||
@@ -28,15 +29,16 @@ public class PlayoutModeSchedulerMultiple(Map<CollectionKey, int> collectionItem
|
||||
return new PlayoutSchedulerResult(playoutBuilderState, playoutItems, warnings);
|
||||
}
|
||||
|
||||
IMediaCollectionEnumerator contentEnumerator =
|
||||
collectionEnumerators[CollectionKey.ForScheduleItem(scheduleItem)];
|
||||
|
||||
PlayoutBuilderState nextState = playoutBuilderState with
|
||||
{
|
||||
CurrentTime = firstStart,
|
||||
MultipleRemaining = playoutBuilderState.MultipleRemaining.IfNone(scheduleItem.Count)
|
||||
MultipleRemaining = playoutBuilderState.MultipleRemaining.IfNone(
|
||||
CountExpression.Evaluate(scheduleItem.Count, contentEnumerator, random, cancellationToken))
|
||||
};
|
||||
|
||||
IMediaCollectionEnumerator contentEnumerator =
|
||||
collectionEnumerators[CollectionKey.ForScheduleItem(scheduleItem)];
|
||||
|
||||
if (nextState.MultipleRemaining == 0)
|
||||
{
|
||||
switch (scheduleItem.MultipleMode)
|
||||
|
||||
@@ -14,6 +14,7 @@ public class PlayoutModeSchedulerOne(ILogger logger) : PlayoutModeSchedulerBase<
|
||||
ProgramScheduleItemOne scheduleItem,
|
||||
ProgramScheduleItem nextScheduleItem,
|
||||
DateTimeOffset hardStop,
|
||||
Random random,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var warnings = new PlayoutBuildWarnings();
|
||||
|
||||
@@ -31,7 +31,11 @@ public class YamlPlayoutAllHandler(EnumeratorCache enumeratorCache) : YamlPlayou
|
||||
|
||||
foreach (IMediaCollectionEnumerator enumerator in maybeEnumerator)
|
||||
{
|
||||
for (var i = 0; i < enumerator.Count; i++)
|
||||
int enumeratorCount = enumerator is PlaylistEnumerator playlistEnumerator
|
||||
? playlistEnumerator.CountForFiller
|
||||
: enumerator.Count;
|
||||
|
||||
for (var i = 0; i < enumeratorCount; i++)
|
||||
{
|
||||
foreach (string preRollSequence in context.GetPreRollSequence())
|
||||
{
|
||||
|
||||
@@ -5,7 +5,6 @@ using ErsatzTV.Core.Extensions;
|
||||
using ErsatzTV.Core.Interfaces.Scheduling;
|
||||
using ErsatzTV.Core.Scheduling.YamlScheduling.Models;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NCalc;
|
||||
|
||||
namespace ErsatzTV.Core.Scheduling.YamlScheduling.Handlers;
|
||||
|
||||
@@ -34,27 +33,7 @@ public class YamlPlayoutCountHandler(EnumeratorCache enumeratorCache) : YamlPlay
|
||||
{
|
||||
int seed = context.Playout.Seed + context.InstructionIndex + context.CurrentTime.DayOfYear;
|
||||
var random = new Random(seed);
|
||||
int enumeratorCount = enumerator is PlaylistEnumerator playlistEnumerator
|
||||
? playlistEnumerator.CountForRandom
|
||||
: enumerator.Count;
|
||||
var expression = new Expression(count.Count);
|
||||
expression.EvaluateParameter += (name, e) =>
|
||||
{
|
||||
e.Result = name switch
|
||||
{
|
||||
"count" => enumeratorCount,
|
||||
"random" => enumeratorCount > 0 ? random.Next() % enumeratorCount : 0,
|
||||
_ => e.Result
|
||||
};
|
||||
};
|
||||
|
||||
object expressionResult = expression.Evaluate(cancellationToken);
|
||||
int countValue = expressionResult switch
|
||||
{
|
||||
double doubleResult => (int)Math.Floor(doubleResult),
|
||||
int intResult => intResult,
|
||||
_ => 0
|
||||
};
|
||||
int countValue = CountExpression.Evaluate(count.Count, enumerator, random, cancellationToken);
|
||||
|
||||
for (var i = 0; i < countValue; i++)
|
||||
{
|
||||
|
||||
@@ -32,7 +32,7 @@ public class YamlPlayoutSkipItemsHandler(EnumeratorCache enumeratorCache) : IYam
|
||||
int seed = context.Playout.Seed + context.InstructionIndex + context.CurrentTime.DayOfYear;
|
||||
var random = new Random(seed);
|
||||
int enumeratorCount = enumerator is PlaylistEnumerator playlistEnumerator
|
||||
? playlistEnumerator.CountForRandom
|
||||
? playlistEnumerator.CountForFiller
|
||||
: enumerator.Count;
|
||||
var expression = new Expression(skipItems.SkipItems);
|
||||
expression.EvaluateParameter += (name, e) =>
|
||||
@@ -53,7 +53,7 @@ public class YamlPlayoutSkipItemsHandler(EnumeratorCache enumeratorCache) : IYam
|
||||
_ => 0
|
||||
};
|
||||
|
||||
skipCount %= enumerator.Count;
|
||||
skipCount %= enumeratorCount;
|
||||
|
||||
if (skipCount < 0)
|
||||
{
|
||||
|
||||
@@ -40,7 +40,12 @@ public class YamlPlayoutSkipToItemHandler(EnumeratorCache enumeratorCache) : IYa
|
||||
foreach (IMediaCollectionEnumerator enumerator in maybeEnumerator)
|
||||
{
|
||||
var done = false;
|
||||
for (var index = 0; index < enumerator.Count; index++)
|
||||
|
||||
int enumeratorCount = enumerator is PlaylistEnumerator playlistEnumerator
|
||||
? playlistEnumerator.CountForFiller
|
||||
: enumerator.Count;
|
||||
|
||||
for (var index = 0; index < enumeratorCount; index++)
|
||||
{
|
||||
if (done)
|
||||
{
|
||||
|
||||
@@ -8,12 +8,12 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyModel" Version="8.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyModel" Version="[8.0.2]" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.2" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
|
||||
<PackageReference Include="NSubstitute" Version="5.3.0" />
|
||||
<PackageReference Include="NUnit" Version="4.4.0" />
|
||||
<PackageReference Include="NUnit3TestAdapter" Version="6.0.1" />
|
||||
<PackageReference Include="NUnit3TestAdapter" Version="6.1.0" />
|
||||
<PackageReference Include="Shouldly" Version="4.3.0" />
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
|
||||
@@ -67,11 +67,13 @@ public class PipelineBuilderBaseTests
|
||||
new FrameSize(1920, 1080),
|
||||
new FrameSize(1920, 1080),
|
||||
Option<FrameSize>.None,
|
||||
FFmpegFilterMode.HardwareIfPossible,
|
||||
false,
|
||||
Option<FrameRate>.None,
|
||||
2000,
|
||||
4000,
|
||||
90_000,
|
||||
false,
|
||||
false);
|
||||
|
||||
var ffmpegState = new FFmpegState(
|
||||
@@ -168,11 +170,13 @@ public class PipelineBuilderBaseTests
|
||||
new FrameSize(1920, 1080),
|
||||
new FrameSize(1920, 1080),
|
||||
Option<FrameSize>.None,
|
||||
FFmpegFilterMode.HardwareIfPossible,
|
||||
false,
|
||||
Option<FrameRate>.None,
|
||||
2000,
|
||||
4000,
|
||||
90_000,
|
||||
false,
|
||||
false);
|
||||
|
||||
var ffmpegState = new FFmpegState(
|
||||
@@ -327,11 +331,13 @@ public class PipelineBuilderBaseTests
|
||||
new FrameSize(1920, 1080),
|
||||
new FrameSize(1920, 1080),
|
||||
Option<FrameSize>.None,
|
||||
FFmpegFilterMode.HardwareIfPossible,
|
||||
false,
|
||||
Option<FrameRate>.None,
|
||||
2000,
|
||||
4000,
|
||||
90_000,
|
||||
false,
|
||||
false);
|
||||
|
||||
var ffmpegState = new FFmpegState(
|
||||
@@ -384,7 +390,7 @@ public class PipelineBuilderBaseTests
|
||||
|
||||
// 0.4.0 reference: "-nostdin -threads 1 -hide_banner -loglevel error -nostats -fflags +genpts+discardcorrupt+igndts -re -ss 00:14:33.6195516 -i /tmp/whatever.mkv -map 0:0 -map 0:a -c:v copy -flags cgop -sc_threshold 0 -c:a copy -movflags +faststart -metadata service_provider="ErsatzTV" -metadata service_name="ErsatzTV" -t 00:06:39.6934484 -f mpegts -mpegts_flags +initial_discontinuity pipe:1"
|
||||
command.ShouldBe(
|
||||
"-nostdin -hide_banner -nostats -loglevel error -fflags +genpts+discardcorrupt+igndts -readrate 1.0 -i /tmp/whatever.mkv -map 0:0 -map 0:1 -muxdelay 0 -muxpreload 0 -movflags +faststart+frag_keyframe+separate_moof+omit_tfhd_offset+empty_moov+delay_moov -flags cgop -sc_threshold 0 -c:v copy -c:a copy -f mp4 pipe:1");
|
||||
"-nostdin -hide_banner -nostats -loglevel error -fflags +genpts+discardcorrupt+igndts -readrate 1.0 -i /tmp/whatever.mkv -map 0:0 -map 0:1 -muxdelay 0 -muxpreload 0 -movflags +faststart+frag_keyframe+separate_moof+omit_tfhd_offset+empty_moov+delay_moov -c:v copy -c:a copy -f mp4 pipe:1");
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -421,11 +427,13 @@ public class PipelineBuilderBaseTests
|
||||
new FrameSize(1920, 1080),
|
||||
new FrameSize(1920, 1080),
|
||||
Option<FrameSize>.None,
|
||||
FFmpegFilterMode.HardwareIfPossible,
|
||||
false,
|
||||
Option<FrameRate>.None,
|
||||
2000,
|
||||
4000,
|
||||
90_000,
|
||||
false,
|
||||
false);
|
||||
|
||||
var ffmpegState = new FFmpegState(
|
||||
@@ -476,7 +484,7 @@ public class PipelineBuilderBaseTests
|
||||
string command = PrintCommand(videoInputFile, audioInputFile, None, None, None, result);
|
||||
|
||||
command.ShouldBe(
|
||||
"-nostdin -hide_banner -nostats -loglevel error -fflags +genpts+discardcorrupt+igndts -readrate 1.0 -i /tmp/whatever.mkv -map 0:0 -map 0:a -muxdelay 0 -muxpreload 0 -movflags +faststart+frag_keyframe+separate_moof+omit_tfhd_offset+empty_moov+delay_moov -flags cgop -sc_threshold 0 -c:v copy -c:a copy -f mp4 pipe:1");
|
||||
"-nostdin -hide_banner -nostats -loglevel error -fflags +genpts+discardcorrupt+igndts -readrate 1.0 -i /tmp/whatever.mkv -map 0:0 -map 0:a -muxdelay 0 -muxpreload 0 -movflags +faststart+frag_keyframe+separate_moof+omit_tfhd_offset+empty_moov+delay_moov -c:v copy -c:a copy -f mp4 pipe:1");
|
||||
}
|
||||
|
||||
[Test]
|
||||
|
||||
@@ -4,7 +4,28 @@ public record FFmpegKnownFilter
|
||||
{
|
||||
public static readonly FFmpegKnownFilter ScaleNpp = new("scale_npp");
|
||||
public static readonly FFmpegKnownFilter TonemapOpenCL = new("tonemap_opencl");
|
||||
public static readonly FFmpegKnownFilter Libplacebo = new("libplacebo");
|
||||
//public static readonly FFmpegKnownFilter Libplacebo = new("libplacebo");
|
||||
public static readonly FFmpegKnownFilter AudioPad = new("apad");
|
||||
public static readonly FFmpegKnownFilter AudioResample = new("aresample");
|
||||
public static readonly FFmpegKnownFilter Color = new("color");
|
||||
public static readonly FFmpegKnownFilter ColorChannelMixer = new("colorchannelmixer");
|
||||
public static readonly FFmpegKnownFilter Colorspace = new("colorspace");
|
||||
public static readonly FFmpegKnownFilter Crop = new("crop");
|
||||
public static readonly FFmpegKnownFilter Fade = new("fade");
|
||||
public static readonly FFmpegKnownFilter Format = new("format");
|
||||
public static readonly FFmpegKnownFilter Fps = new("fps");
|
||||
public static readonly FFmpegKnownFilter FrameRate = new("framerate");
|
||||
public static readonly FFmpegKnownFilter Loop = new("loop");
|
||||
public static readonly FFmpegKnownFilter NormalizeLoudness = new("loudnorm");
|
||||
public static readonly FFmpegKnownFilter Overlay = new("overlay");
|
||||
public static readonly FFmpegKnownFilter Pad = new("pad");
|
||||
public static readonly FFmpegKnownFilter Realtime = new("realtime");
|
||||
public static readonly FFmpegKnownFilter SetPts = new("setpts");
|
||||
public static readonly FFmpegKnownFilter Scale = new("scale");
|
||||
public static readonly FFmpegKnownFilter Subtitles = new("subtitles");
|
||||
public static readonly FFmpegKnownFilter Tonemap = new("tonemap");
|
||||
public static readonly FFmpegKnownFilter Yadif = new("yadif");
|
||||
public static readonly FFmpegKnownFilter ZScale = new("zscale");
|
||||
|
||||
private FFmpegKnownFilter(string Name) => this.Name = Name;
|
||||
|
||||
@@ -13,6 +34,31 @@ public record FFmpegKnownFilter
|
||||
public static IList<string> AllFilters =>
|
||||
[
|
||||
ScaleNpp.Name,
|
||||
TonemapOpenCL.Name
|
||||
TonemapOpenCL.Name,
|
||||
];
|
||||
|
||||
public static IList<FFmpegKnownFilter> RequiredFilters =>
|
||||
[
|
||||
AudioPad,
|
||||
AudioResample,
|
||||
Color,
|
||||
ColorChannelMixer,
|
||||
Colorspace,
|
||||
Crop,
|
||||
Fade,
|
||||
Format,
|
||||
Fps,
|
||||
FrameRate,
|
||||
Loop,
|
||||
NormalizeLoudness,
|
||||
Overlay,
|
||||
Pad,
|
||||
Realtime,
|
||||
SetPts,
|
||||
Scale,
|
||||
Subtitles,
|
||||
Tonemap,
|
||||
Yadif,
|
||||
ZScale
|
||||
];
|
||||
}
|
||||
|
||||
@@ -66,8 +66,10 @@ public partial class HardwareCapabilitiesFactory(
|
||||
IReadOnlySet<string> ffmpegDecoders = await GetFFmpegCapabilities(ffmpegPath, "decoders", ParseFFmpegLine)
|
||||
.Map(set => set.Intersect(FFmpegKnownDecoder.AllDecoders).ToImmutableHashSet());
|
||||
|
||||
IEnumerable<string> allFilterNames =
|
||||
FFmpegKnownFilter.AllFilters.Union(FFmpegKnownFilter.RequiredFilters.Select(f => f.Name));
|
||||
IReadOnlySet<string> ffmpegFilters = await GetFFmpegCapabilities(ffmpegPath, "filters", ParseFFmpegLine)
|
||||
.Map(set => set.Intersect(FFmpegKnownFilter.AllFilters).ToImmutableHashSet());
|
||||
.Map(set => set.Intersect(allFilterNames).ToImmutableHashSet());
|
||||
|
||||
IReadOnlySet<string> ffmpegEncoders = await GetFFmpegCapabilities(ffmpegPath, "encoders", ParseFFmpegLine)
|
||||
.Map(set => set.Intersect(FFmpegKnownEncoder.AllEncoders).ToImmutableHashSet());
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
using ErsatzTV.FFmpeg.Environment;
|
||||
using ErsatzTV.FFmpeg.Format;
|
||||
|
||||
namespace ErsatzTV.FFmpeg.Decoder;
|
||||
|
||||
public class DecoderAc3Downmix(int sourceChannels, int desiredChannels) : IDecoder
|
||||
{
|
||||
public string Name => "ac3";
|
||||
public EnvironmentVariable[] EnvironmentVariables => [];
|
||||
public string[] GlobalOptions => [];
|
||||
public string[] FilterOptions => [];
|
||||
public string[] OutputOptions => [];
|
||||
|
||||
public string[] InputOptions(InputFile inputFile)
|
||||
{
|
||||
if (sourceChannels >= 2 && desiredChannels == 2)
|
||||
{
|
||||
return ["-c:a", "ac3", "-downmix", AudioLayout.Stereo];
|
||||
}
|
||||
|
||||
// unsupported downmix; hopefully the layout doesn't change
|
||||
return [];
|
||||
}
|
||||
|
||||
public FrameState NextState(FrameState currentState) => currentState;
|
||||
|
||||
public bool AppliesTo(AudioInputFile audioInputFile) => true;
|
||||
|
||||
public bool AppliesTo(VideoInputFile videoInputFile) => false;
|
||||
|
||||
public bool AppliesTo(ConcatInputFile concatInputFile) => false;
|
||||
|
||||
public bool AppliesTo(GraphicsEngineInput graphicsEngineInput) => false;
|
||||
|
||||
}
|
||||
@@ -15,8 +15,8 @@
|
||||
<PackageReference Include="Hardware.Info" Version="101.1.0.1" />
|
||||
<PackageReference Include="LanguageExt.Core" Version="4.4.9" />
|
||||
<PackageReference Include="Lennox.NvEncSharp" Version="2.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.2" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace ErsatzTV.FFmpeg;
|
||||
|
||||
public enum FFmpegFilterMode
|
||||
{
|
||||
HardwareIfPossible = 0,
|
||||
Software = 1
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace ErsatzTV.FFmpeg.Format;
|
||||
|
||||
public static class AudioLayout
|
||||
{
|
||||
public const string Stereo = "stereo";
|
||||
}
|
||||
@@ -13,11 +13,13 @@ public record FrameState(
|
||||
FrameSize ScaledSize,
|
||||
FrameSize PaddedSize,
|
||||
Option<FrameSize> CroppedSize,
|
||||
FFmpegFilterMode PadMode,
|
||||
bool IsAnamorphic,
|
||||
Option<FrameRate> FrameRate,
|
||||
Option<int> VideoBitrate,
|
||||
Option<int> VideoBufferSize,
|
||||
Option<int> VideoTrackTimeScale,
|
||||
bool ColorsAreBt709,
|
||||
bool Deinterlaced,
|
||||
FrameDataLocation FrameDataLocation = FrameDataLocation.Unknown)
|
||||
{
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using ErsatzTV.FFmpeg.Environment;
|
||||
using ErsatzTV.FFmpeg.Format;
|
||||
|
||||
namespace ErsatzTV.FFmpeg.OutputFormat;
|
||||
|
||||
@@ -56,18 +57,27 @@ public class OutputFormatHls : IPipelineStep
|
||||
? (int)Math.Round(frameRate.ParsedFrameRate)
|
||||
: (int)Math.Round(frameRate.ParsedFrameRate * SegmentSeconds);
|
||||
|
||||
List<string> result =
|
||||
List<string> result = [];
|
||||
|
||||
if (_desiredState.VideoFormat != VideoFormat.Copy)
|
||||
{
|
||||
result.AddRange(
|
||||
[
|
||||
"-g", $"{gop}",
|
||||
"-keyint_min", $"{(int)Math.Round(frameRate.ParsedFrameRate * SegmentSeconds)}",
|
||||
"-force_key_frames", $"expr:gte(t,n_forced*{SegmentSeconds})"
|
||||
]);
|
||||
}
|
||||
|
||||
result.AddRange(
|
||||
[
|
||||
"-g", $"{gop}",
|
||||
"-keyint_min", $"{(int)Math.Round(frameRate.ParsedFrameRate * SegmentSeconds)}",
|
||||
"-force_key_frames", $"expr:gte(t,n_forced*{SegmentSeconds})",
|
||||
"-f", "hls",
|
||||
"-hls_time", $"{SegmentSeconds}",
|
||||
"-hls_list_size", "0",
|
||||
"-segment_list_flags", "+live",
|
||||
"-hls_segment_filename",
|
||||
_segmentTemplate
|
||||
];
|
||||
]);
|
||||
|
||||
var independentSegments = "+independent_segments";
|
||||
|
||||
|
||||
@@ -94,15 +94,15 @@ public class AmfPipelineBuilder : SoftwarePipelineBuilder
|
||||
|
||||
protected override List<IPipelineFilterStep> SetPixelFormat(
|
||||
VideoStream videoStream,
|
||||
Option<IPixelFormat> desiredPixelFormat,
|
||||
FrameState desiredState,
|
||||
FrameState currentState,
|
||||
ICollection<IPipelineStep> pipelineSteps)
|
||||
{
|
||||
var result = new List<IPipelineFilterStep>();
|
||||
|
||||
foreach (IPixelFormat pixelFormat in desiredPixelFormat)
|
||||
foreach (IPixelFormat pixelFormat in desiredState.PixelFormat)
|
||||
{
|
||||
if (!videoStream.ColorParams.IsBt709)
|
||||
if (desiredState.ColorsAreBt709 && !videoStream.ColorParams.IsBt709)
|
||||
{
|
||||
// _logger.LogDebug("Adding colorspace filter");
|
||||
var colorspace = new ColorspaceFilter(currentState, videoStream, pixelFormat);
|
||||
|
||||
@@ -347,7 +347,7 @@ public class NvidiaPipelineBuilder : SoftwarePipelineBuilder
|
||||
|
||||
List<IPipelineFilterStep> pixelFormatFilterSteps = SetPixelFormat(
|
||||
videoStream,
|
||||
desiredState.PixelFormat,
|
||||
desiredState,
|
||||
ffmpegState,
|
||||
currentState,
|
||||
context,
|
||||
@@ -392,7 +392,7 @@ public class NvidiaPipelineBuilder : SoftwarePipelineBuilder
|
||||
|
||||
private List<IPipelineFilterStep> SetPixelFormat(
|
||||
VideoStream videoStream,
|
||||
Option<IPixelFormat> desiredPixelFormat,
|
||||
FrameState desiredState,
|
||||
FFmpegState ffmpegState,
|
||||
FrameState currentState,
|
||||
PipelineContext context,
|
||||
@@ -400,7 +400,7 @@ public class NvidiaPipelineBuilder : SoftwarePipelineBuilder
|
||||
{
|
||||
var result = new List<IPipelineFilterStep>();
|
||||
|
||||
foreach (IPixelFormat pixelFormat in desiredPixelFormat)
|
||||
foreach (IPixelFormat pixelFormat in desiredState.PixelFormat)
|
||||
{
|
||||
IPixelFormat format = pixelFormat;
|
||||
|
||||
@@ -419,7 +419,7 @@ public class NvidiaPipelineBuilder : SoftwarePipelineBuilder
|
||||
videoStream.ResetColorParams(ColorParams.Unknown);
|
||||
}
|
||||
|
||||
if (!videoStream.ColorParams.IsBt709)
|
||||
if (desiredState.ColorsAreBt709 && !videoStream.ColorParams.IsBt709)
|
||||
{
|
||||
// _logger.LogDebug("Adding colorspace filter");
|
||||
var colorspace = new ColorspaceFilter(currentState, videoStream, format);
|
||||
|
||||
@@ -214,10 +214,14 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
|
||||
new LoglevelErrorOption(),
|
||||
new StandardFormatFlags(),
|
||||
new NoDemuxDecodeDelayOutputOption(),
|
||||
outputOption,
|
||||
new ClosedGopOutputOption()
|
||||
outputOption
|
||||
};
|
||||
|
||||
if (desiredState.VideoFormat != VideoFormat.Copy)
|
||||
{
|
||||
pipelineSteps.Add(new ClosedGopOutputOption());
|
||||
}
|
||||
|
||||
if (desiredState.VideoFormat != VideoFormat.Copy && !desiredState.AllowBFrames)
|
||||
{
|
||||
pipelineSteps.Add(new NoBFramesOutputOption());
|
||||
@@ -466,6 +470,18 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
|
||||
}
|
||||
}
|
||||
|
||||
// workaround for ac3 audio layout changes
|
||||
// downmix from decoder so filter doesn't need to reinit (it can't, it will fail)
|
||||
// this only really works when the desired output format matches the minimal input format
|
||||
foreach (var audioInputStream in audioInputFile.Streams.OfType<AudioStream>()
|
||||
.Where(s => s.Codec == AudioFormat.Ac3).HeadOrNone())
|
||||
{
|
||||
foreach (int desiredAudioChannels in audioInputFile.DesiredState.AudioChannels)
|
||||
{
|
||||
audioInputFile.AddOption(new DecoderAc3Downmix(audioInputStream.Channels, desiredAudioChannels));
|
||||
}
|
||||
}
|
||||
|
||||
// always need to specify audio codec so ffmpeg doesn't default to a codec we don't want
|
||||
foreach (IEncoder step in AvailableEncoders.ForAudioFormat(ffmpegState, audioInputFile.DesiredState, _logger))
|
||||
{
|
||||
@@ -500,6 +516,11 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
|
||||
|
||||
private void SetAudioLoudness(AudioInputFile audioInputFile)
|
||||
{
|
||||
if (audioInputFile.DesiredState.AudioFormat == AudioFormat.Copy)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (audioInputFile.DesiredState.NormalizeLoudnessFilter is not AudioFilter.None)
|
||||
{
|
||||
var filter = new NormalizeLoudnessFilter(
|
||||
@@ -513,6 +534,11 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
|
||||
|
||||
private static void SetAudioSampleRate(AudioInputFile audioInputFile, List<IPipelineStep> pipelineSteps)
|
||||
{
|
||||
if (audioInputFile.DesiredState.AudioFormat == AudioFormat.Copy)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (int desiredSampleRate in audioInputFile.DesiredState.AudioSampleRate)
|
||||
{
|
||||
pipelineSteps.Add(new AudioSampleRateOutputOption(desiredSampleRate));
|
||||
@@ -521,6 +547,11 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
|
||||
|
||||
private static void SetAudioBufferSize(AudioInputFile audioInputFile, List<IPipelineStep> pipelineSteps)
|
||||
{
|
||||
if (audioInputFile.DesiredState.AudioFormat == AudioFormat.Copy)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (int desiredBufferSize in audioInputFile.DesiredState.AudioBufferSize)
|
||||
{
|
||||
pipelineSteps.Add(new AudioBufferSizeOutputOption(desiredBufferSize));
|
||||
@@ -529,6 +560,11 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
|
||||
|
||||
private static void SetAudioBitrate(AudioInputFile audioInputFile, List<IPipelineStep> pipelineSteps)
|
||||
{
|
||||
if (audioInputFile.DesiredState.AudioFormat == AudioFormat.Copy)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (int desiredBitrate in audioInputFile.DesiredState.AudioBitrate)
|
||||
{
|
||||
pipelineSteps.Add(new AudioBitrateOutputOption(desiredBitrate));
|
||||
@@ -537,6 +573,11 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
|
||||
|
||||
private static void SetAudioChannels(AudioInputFile audioInputFile, List<IPipelineStep> pipelineSteps)
|
||||
{
|
||||
if (audioInputFile.DesiredState.AudioFormat == AudioFormat.Copy)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (AudioStream audioStream in audioInputFile.AudioStreams.HeadOrNone())
|
||||
{
|
||||
foreach (int desiredAudioChannels in audioInputFile.DesiredState.AudioChannels)
|
||||
@@ -866,6 +907,11 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
|
||||
FrameState desiredState,
|
||||
List<IPipelineStep> pipelineSteps)
|
||||
{
|
||||
if (desiredState.VideoFormat == VideoFormat.Copy)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// -sc_threshold 0 is unsupported with mpeg2video
|
||||
if (videoStream.Codec == VideoFormat.Mpeg2Video || desiredState.VideoFormat == VideoFormat.Mpeg2Video ||
|
||||
ffmpegState.DecoderHardwareAccelerationMode == HardwareAccelerationMode.VideoToolbox)
|
||||
|
||||
@@ -259,7 +259,7 @@ public class QsvPipelineBuilder : SoftwarePipelineBuilder
|
||||
List<IPipelineFilterStep> pixelFormatFilterSteps = SetPixelFormat(
|
||||
videoInputFile,
|
||||
videoStream,
|
||||
desiredState.PixelFormat,
|
||||
desiredState,
|
||||
ffmpegState,
|
||||
currentState,
|
||||
context,
|
||||
@@ -279,7 +279,7 @@ public class QsvPipelineBuilder : SoftwarePipelineBuilder
|
||||
private List<IPipelineFilterStep> SetPixelFormat(
|
||||
VideoInputFile videoInputFile,
|
||||
VideoStream videoStream,
|
||||
Option<IPixelFormat> desiredPixelFormat,
|
||||
FrameState desiredState,
|
||||
FFmpegState ffmpegState,
|
||||
FrameState currentState,
|
||||
PipelineContext context,
|
||||
@@ -287,7 +287,7 @@ public class QsvPipelineBuilder : SoftwarePipelineBuilder
|
||||
{
|
||||
var result = new List<IPipelineFilterStep>();
|
||||
|
||||
foreach (IPixelFormat pixelFormat in desiredPixelFormat)
|
||||
foreach (IPixelFormat pixelFormat in desiredState.PixelFormat)
|
||||
{
|
||||
IPixelFormat format = pixelFormat;
|
||||
|
||||
@@ -352,7 +352,7 @@ public class QsvPipelineBuilder : SoftwarePipelineBuilder
|
||||
}
|
||||
}
|
||||
|
||||
if (!videoStream.ColorParams.IsBt709 || usesVppQsv)
|
||||
if (desiredState.ColorsAreBt709 && (!videoStream.ColorParams.IsBt709 || usesVppQsv))
|
||||
{
|
||||
// _logger.LogDebug("Adding colorspace filter");
|
||||
|
||||
|
||||
@@ -126,15 +126,15 @@ public class RkmppPipelineBuilder : SoftwarePipelineBuilder
|
||||
|
||||
protected override List<IPipelineFilterStep> SetPixelFormat(
|
||||
VideoStream videoStream,
|
||||
Option<IPixelFormat> desiredPixelFormat,
|
||||
FrameState desiredState,
|
||||
FrameState currentState,
|
||||
ICollection<IPipelineStep> pipelineSteps)
|
||||
{
|
||||
var result = new List<IPipelineFilterStep>();
|
||||
|
||||
foreach (IPixelFormat pixelFormat in desiredPixelFormat)
|
||||
foreach (IPixelFormat pixelFormat in desiredState.PixelFormat)
|
||||
{
|
||||
if (!videoStream.ColorParams.IsBt709)
|
||||
if (desiredState.ColorsAreBt709 && !videoStream.ColorParams.IsBt709)
|
||||
{
|
||||
// _logger.LogDebug("Adding colorspace filter");
|
||||
var colorspace = new ColorspaceFilter(currentState, videoStream, pixelFormat);
|
||||
|
||||
@@ -152,7 +152,7 @@ public class SoftwarePipelineBuilder : PipelineBuilderBase
|
||||
|
||||
List<IPipelineFilterStep> pixelFormatFilterSteps = SetPixelFormat(
|
||||
videoStream,
|
||||
desiredState.PixelFormat,
|
||||
desiredState,
|
||||
currentState,
|
||||
pipelineSteps);
|
||||
|
||||
@@ -169,15 +169,15 @@ public class SoftwarePipelineBuilder : PipelineBuilderBase
|
||||
|
||||
protected virtual List<IPipelineFilterStep> SetPixelFormat(
|
||||
VideoStream videoStream,
|
||||
Option<IPixelFormat> desiredPixelFormat,
|
||||
FrameState desiredState,
|
||||
FrameState currentState,
|
||||
ICollection<IPipelineStep> pipelineSteps)
|
||||
{
|
||||
var result = new List<IPipelineFilterStep>();
|
||||
|
||||
foreach (IPixelFormat pixelFormat in desiredPixelFormat)
|
||||
foreach (IPixelFormat pixelFormat in desiredState.PixelFormat)
|
||||
{
|
||||
if (!videoStream.ColorParams.IsBt709)
|
||||
if (desiredState.ColorsAreBt709 && !videoStream.ColorParams.IsBt709)
|
||||
{
|
||||
// _logger.LogDebug("Adding colorspace filter");
|
||||
var colorspace = new ColorspaceFilter(currentState, videoStream, pixelFormat);
|
||||
|
||||
@@ -124,15 +124,15 @@ public class V4l2m2mPipelineBuilder : SoftwarePipelineBuilder
|
||||
|
||||
protected override List<IPipelineFilterStep> SetPixelFormat(
|
||||
VideoStream videoStream,
|
||||
Option<IPixelFormat> desiredPixelFormat,
|
||||
FrameState desiredState,
|
||||
FrameState currentState,
|
||||
ICollection<IPipelineStep> pipelineSteps)
|
||||
{
|
||||
var result = new List<IPipelineFilterStep>();
|
||||
|
||||
foreach (IPixelFormat pixelFormat in desiredPixelFormat)
|
||||
foreach (IPixelFormat pixelFormat in desiredState.PixelFormat)
|
||||
{
|
||||
if (!videoStream.ColorParams.IsBt709)
|
||||
if (desiredState.ColorsAreBt709 && !videoStream.ColorParams.IsBt709)
|
||||
{
|
||||
// _logger.LogDebug("Adding colorspace filter");
|
||||
var colorspace = new ColorspaceFilter(currentState, videoStream, pixelFormat);
|
||||
|
||||
@@ -199,7 +199,7 @@ public class VaapiPipelineBuilder : SoftwarePipelineBuilder
|
||||
bool isHdrTonemap = videoStream.ColorParams.IsHdr;
|
||||
currentState = SetTonemap(videoInputFile, videoStream, ffmpegState, desiredState, currentState);
|
||||
|
||||
currentState = SetPad(videoInputFile, ffmpegState, desiredState, currentState, isHdrTonemap);
|
||||
currentState = SetPad(videoInputFile, desiredState, currentState, isHdrTonemap);
|
||||
// _logger.LogDebug("After pad: {PixelFormat}", currentState.PixelFormat);
|
||||
|
||||
currentState = SetCrop(videoInputFile, desiredState, currentState);
|
||||
@@ -286,7 +286,7 @@ public class VaapiPipelineBuilder : SoftwarePipelineBuilder
|
||||
|
||||
List<IPipelineFilterStep> pixelFormatFilterSteps = SetPixelFormat(
|
||||
videoStream,
|
||||
desiredState.PixelFormat,
|
||||
desiredState,
|
||||
ffmpegState,
|
||||
currentState,
|
||||
pipelineSteps);
|
||||
@@ -313,14 +313,14 @@ public class VaapiPipelineBuilder : SoftwarePipelineBuilder
|
||||
|
||||
private List<IPipelineFilterStep> SetPixelFormat(
|
||||
VideoStream videoStream,
|
||||
Option<IPixelFormat> desiredPixelFormat,
|
||||
FrameState desiredState,
|
||||
FFmpegState ffmpegState,
|
||||
FrameState currentState,
|
||||
ICollection<IPipelineStep> pipelineSteps)
|
||||
{
|
||||
var result = new List<IPipelineFilterStep>();
|
||||
|
||||
foreach (IPixelFormat pixelFormat in desiredPixelFormat)
|
||||
foreach (IPixelFormat pixelFormat in desiredState.PixelFormat)
|
||||
{
|
||||
IPixelFormat format = pixelFormat;
|
||||
|
||||
@@ -332,7 +332,7 @@ public class VaapiPipelineBuilder : SoftwarePipelineBuilder
|
||||
}
|
||||
}
|
||||
|
||||
if (!videoStream.ColorParams.IsBt709)
|
||||
if (desiredState.ColorsAreBt709 && !videoStream.ColorParams.IsBt709)
|
||||
{
|
||||
// _logger.LogDebug("Adding colorspace filter");
|
||||
var colorspace = new ColorspaceFilter(
|
||||
@@ -352,12 +352,12 @@ public class VaapiPipelineBuilder : SoftwarePipelineBuilder
|
||||
_logger.LogDebug(
|
||||
"FrameDataLocation == FrameDataLocation.Hardware, {CurrentPixelFormat} bit => {DesiredPixelFormat}",
|
||||
currentState.PixelFormat,
|
||||
desiredPixelFormat);
|
||||
desiredState.PixelFormat);
|
||||
|
||||
// don't try to download from 8-bit to 10-bit, or 10-bit to 8-bit
|
||||
HardwareDownloadFilter hardwareDownload =
|
||||
currentState.BitDepth == 8 && desiredPixelFormat.Map(pf => pf.BitDepth).IfNone(8) == 10 ||
|
||||
currentState.BitDepth == 10 && desiredPixelFormat.Map(pf => pf.BitDepth).IfNone(10) == 8
|
||||
currentState.BitDepth == 8 && desiredState.PixelFormat.Map(pf => pf.BitDepth).IfNone(8) == 10 ||
|
||||
currentState.BitDepth == 10 && desiredState.PixelFormat.Map(pf => pf.BitDepth).IfNone(10) == 8
|
||||
? new HardwareDownloadFilter(currentState)
|
||||
: new HardwareDownloadFilter(currentState with { PixelFormat = Some(format) });
|
||||
|
||||
@@ -617,28 +617,13 @@ public class VaapiPipelineBuilder : SoftwarePipelineBuilder
|
||||
|
||||
private static FrameState SetPad(
|
||||
VideoInputFile videoInputFile,
|
||||
FFmpegState ffmpegState,
|
||||
FrameState desiredState,
|
||||
FrameState currentState,
|
||||
bool isHdrTonemap)
|
||||
{
|
||||
if (desiredState.CroppedSize.IsNone && currentState.PaddedSize != desiredState.PaddedSize)
|
||||
{
|
||||
// pad_vaapi seems to pad with green when input is HDR
|
||||
// also green with i965 driver
|
||||
// also green with radeonsi and h264 main profile
|
||||
// so use software pad in these cases
|
||||
bool is965 = ffmpegState.VaapiDriver
|
||||
.IfNone(string.Empty)
|
||||
.Contains("i965", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
bool isRadeonSiMain = ffmpegState.VaapiDriver.IfNone(string.Empty)
|
||||
.Contains("radeonsi", StringComparison.OrdinalIgnoreCase)
|
||||
&& ffmpegState.EncoderHardwareAccelerationMode is HardwareAccelerationMode.Vaapi
|
||||
&& desiredState.VideoFormat is VideoFormat.H264
|
||||
&& desiredState.VideoProfile.IfNone(string.Empty) is VideoProfile.Main;
|
||||
|
||||
if (isHdrTonemap || is965 || isRadeonSiMain)
|
||||
if (desiredState.PadMode is FFmpegFilterMode.Software || isHdrTonemap)
|
||||
{
|
||||
var padStep = new PadFilter(currentState, desiredState.PaddedSize);
|
||||
currentState = padStep.NextState(currentState);
|
||||
|
||||
@@ -123,15 +123,15 @@ public class VideoToolboxPipelineBuilder : SoftwarePipelineBuilder
|
||||
|
||||
protected override List<IPipelineFilterStep> SetPixelFormat(
|
||||
VideoStream videoStream,
|
||||
Option<IPixelFormat> desiredPixelFormat,
|
||||
FrameState desiredState,
|
||||
FrameState currentState,
|
||||
ICollection<IPipelineStep> pipelineSteps)
|
||||
{
|
||||
var result = new List<IPipelineFilterStep>();
|
||||
|
||||
foreach (IPixelFormat pixelFormat in desiredPixelFormat)
|
||||
foreach (IPixelFormat pixelFormat in desiredState.PixelFormat)
|
||||
{
|
||||
if (!videoStream.ColorParams.IsBt709)
|
||||
if (desiredState.ColorsAreBt709 && !videoStream.ColorParams.IsBt709)
|
||||
{
|
||||
// _logger.LogDebug("Adding colorspace filter");
|
||||
var colorspace = new ColorspaceFilter(currentState, videoStream, pixelFormat);
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="EFCore.BulkExtensions.MySql" Version="[9.0.2,10)" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="[9.0.11,10)" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.12" />
|
||||
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
Generated
+7021
File diff suppressed because it is too large
Load Diff
+39
@@ -0,0 +1,39 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ErsatzTV.Infrastructure.MySql.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class Add_ArtworkIsMetadataOrphan : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsMetadataOrphan",
|
||||
table: "Artwork",
|
||||
type: "tinyint(1)",
|
||||
nullable: true,
|
||||
computedColumnSql: "CASE WHEN COALESCE(ArtistMetadataId, ChannelId, EpisodeMetadataId, MovieMetadataId, MusicVideoMetadataId, OtherVideoMetadataId, SeasonMetadataId, ShowMetadataId, SongMetadataId, ImageMetadataId, RemoteStreamMetadataId) IS NULL THEN 1 ELSE NULL END",
|
||||
stored: false);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Artwork_IsMetadataOrphan",
|
||||
table: "Artwork",
|
||||
column: "IsMetadataOrphan");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Artwork_IsMetadataOrphan",
|
||||
table: "Artwork");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsMetadataOrphan",
|
||||
table: "Artwork");
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+7024
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ErsatzTV.Infrastructure.MySql.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class Add_FFmpegProfilePadMode : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "PadMode",
|
||||
table: "FFmpegProfile",
|
||||
type: "int",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PadMode",
|
||||
table: "FFmpegProfile");
|
||||
}
|
||||
}
|
||||
}
|
||||
+7024
File diff suppressed because it is too large
Load Diff
+38
@@ -0,0 +1,38 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ErsatzTV.Infrastructure.MySql.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class Change_ProgramScheduleItemMultipleCountString : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "Count",
|
||||
table: "ProgramScheduleMultipleItem",
|
||||
type: "longtext",
|
||||
nullable: true,
|
||||
oldClrType: typeof(int),
|
||||
oldType: "int")
|
||||
.Annotation("MySql:CharSet", "utf8mb4");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AlterColumn<int>(
|
||||
name: "Count",
|
||||
table: "ProgramScheduleMultipleItem",
|
||||
type: "int",
|
||||
nullable: false,
|
||||
defaultValue: 0,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "longtext",
|
||||
oldNullable: true)
|
||||
.OldAnnotation("MySql:CharSet", "utf8mb4");
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user