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 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+76
-9
@@ -5,6 +5,72 @@ 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
|
||||
@@ -726,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
|
||||
@@ -3089,8 +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.1...HEAD
|
||||
[26.1.0]: https://github.com/ErsatzTV/ErsatzTV/compare/v26.1.0...v26.1.1
|
||||
[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,14 +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(IArtworkRepository artworkRepository)
|
||||
public class DeleteOrphanedArtworkHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IArtworkRepository artworkRepository,
|
||||
IFileSystem fileSystem,
|
||||
ILogger<DeleteOrphanedArtworkHandler> logger)
|
||||
: IRequestHandler<DeleteOrphanedArtwork, Either<BaseError, Unit>>
|
||||
{
|
||||
public Task<Either<BaseError, Unit>>
|
||||
Handle(DeleteOrphanedArtwork request, CancellationToken cancellationToken) =>
|
||||
artworkRepository.GetOrphanedArtworkIds()
|
||||
.Bind(artworkRepository.Delete)
|
||||
.Map(_ => Right<BaseError, Unit>(Unit.Default));
|
||||
public async Task<Either<BaseError, Unit>> Handle(
|
||||
DeleteOrphanedArtwork request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await CleanUpDatabase();
|
||||
await CleanUpFileSystem(cancellationToken);
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return BaseError.New(e.Message);
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
@@ -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
+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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+7039
File diff suppressed because it is too large
Load Diff
+51
@@ -0,0 +1,51 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ErsatzTV.Infrastructure.MySql.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class Add_FFmpegProfileNormalizationOptions : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "NormalizeAudio",
|
||||
table: "FFmpegProfile",
|
||||
type: "tinyint(1)",
|
||||
nullable: false,
|
||||
defaultValue: true);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "NormalizeColors",
|
||||
table: "FFmpegProfile",
|
||||
type: "tinyint(1)",
|
||||
nullable: false,
|
||||
defaultValue: true);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "NormalizeVideo",
|
||||
table: "FFmpegProfile",
|
||||
type: "tinyint(1)",
|
||||
nullable: false,
|
||||
defaultValue: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "NormalizeAudio",
|
||||
table: "FFmpegProfile");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "NormalizeColors",
|
||||
table: "FFmpegProfile");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "NormalizeVideo",
|
||||
table: "FFmpegProfile");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "9.0.11")
|
||||
.HasAnnotation("ProductVersion", "9.0.12")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 64);
|
||||
|
||||
MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder);
|
||||
@@ -757,6 +757,16 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("longtext");
|
||||
|
||||
b.Property<bool>("NormalizeAudio")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("tinyint(1)")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<bool>("NormalizeColors")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("tinyint(1)")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<bool>("NormalizeFramerate")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("tinyint(1)")
|
||||
@@ -765,6 +775,14 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
|
||||
b.Property<int>("NormalizeLoudnessMode")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<bool>("NormalizeVideo")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("tinyint(1)")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<int>("PadMode")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int?>("QsvExtraHardwareFrames")
|
||||
.HasColumnType("int");
|
||||
|
||||
@@ -4127,8 +4145,8 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
|
||||
{
|
||||
b.HasBaseType("ErsatzTV.Core.Domain.ProgramScheduleItem");
|
||||
|
||||
b.Property<int>("Count")
|
||||
.HasColumnType("int");
|
||||
b.Property<string>("Count")
|
||||
.HasColumnType("longtext");
|
||||
|
||||
b.Property<int>("MultipleMode")
|
||||
.HasColumnType("int");
|
||||
|
||||
@@ -14,8 +14,8 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Dapper" Version="2.1.66" />
|
||||
<PackageReference Include="EFCore.BulkExtensions.Sqlite" Version="[9.0.2,10)" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="[9.0.11,10)" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="[9.0.11,10)" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.12" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.12" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
|
||||
Generated
+6851
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user