Compare commits

..
45 Commits
Author SHA1 Message Date
Jason Dove ae13db981d fix secrets in release workflow 2026-02-02 14:52:27 -06:00
Jason Dove b7cc8499a3 prep for release v26.2.0 [no ci] 2026-02-02 14:47:44 -06:00
Jason DoveandGitHub 36147b9e9c fix indexing collections in elasticsearch (#2806)
* fix indexing collections in elasticsearch

* more safety
2026-01-29 18:23:07 -06:00
Jason DoveandGitHub bf8c821012 improve erasing playout items and history (#2805)
* improve erasing playout items and history

* fixes
2026-01-28 09:17:56 -06:00
Jason DoveandGitHub a0f5d8d5d5 detect more local movie artwork (#2804)
* expand test coverage

* support "backdrop" files as local movie fanart fallback
2026-01-27 16:35:28 -06:00
Jason DoveandGitHub f1072b70c7 add chapter title to filler expression (#2803)
* fix transcoding tests

* pass chapter title to filler expression

* update changelog
2026-01-27 09:40:38 -06:00
Jason DoveandGitHub e10b28bc0b add normalization options (#2802)
* add new fields to database

* update editor

* audio and video normalization settings appear to work

* implement optional color normalization

* fix transcoding tests

* update changelog
2026-01-26 23:43:56 -06:00
Jason DoveandGitHub cd2bb0f2e0 fix playout build failures due to playlist enumerator access (#2801) 2026-01-26 14:44:07 -06:00
Jason DoveandGitHub e80f687612 add marathon group by director (#2800) 2026-01-26 09:10:01 -06:00
Jason DoveandGitHub 317ca1967c fix building playouts when fill with group mode is used with graphics elements (#2799) 2026-01-25 15:29:18 -06:00
Jason DoveandGitHub b86f45844c add health check to verify ffmpeg capabilities (filters) (#2798)
* add health check to verify ffmpeg capabilities (filters)

* fix loudnorm
2026-01-25 12:28:09 -06:00
Jason DoveandGitHub 353f029452 fix null ref scanning other videos with nfo file (#2797)
* fix null ref scanning other videos with nfo file

* also fix movie null ref
2026-01-25 11:34:06 -06:00
Jason DoveandGitHub 1754e7d5fb add health check for empty classic schedules (#2796) 2026-01-23 15:47:48 -06:00
Jason DoveandGitHub f96be8f99f update plex episode metadata during scan (#2795) 2026-01-21 16:59:01 -06:00
Jason DoveandGitHub 08ceb53b2b make count an expression in classic schedules (#2794)
* make count an expression in classic schedules

* add tests
2026-01-20 09:50:45 -06:00
Jason DoveandGitHub 3d81f760ee fix z-index sorting in graphics engine (#2786) 2026-01-18 09:07:21 -06:00
Jason DoveandGitHub 4ce87feac1 log graphics element z index (#2785) 2026-01-17 08:15:43 -06:00
Jason DoveandGitHub f217ba185b sync jf and emby library name and type changes (#2784) 2026-01-17 06:14:45 -06:00
Jason DoveandGitHub e925bd6913 sync plex library name changes (#2783)
* sync plex library name changes

* feedback
2026-01-16 19:45:34 -06:00
Jason DoveandGitHub 3f4c9e063b don't delete channel watermarks that are still used (#2781)
* don't delete channel watermarks that are still used

* fix folder cleanup check
2026-01-16 14:24:03 -06:00
Jason DoveandGitHub 7f361d1ea9 update dependencies (#2780)
* update messaging

* update dependencies
2026-01-16 13:57:25 -06:00
Jason DoveandGitHub 35d24ffea6 cleanup artwork cache folder (#2779)
* cleanup artwork cache folder

* fixes

* ignore watermarks that no longer exist on the file system
2026-01-16 13:38:31 -06:00
Jason DoveandGitHub a2d023ee69 local scanner artwork cleanup (#2778)
* move plex artwork removal to its own repository

* clean up old local movie artwork

* clien up old music video/artist artwork

* clean up old remote stream artwork

* clean up old song artwork

* clean up old show artwork; properly update season artwork
2026-01-16 10:23:26 -06:00
Jason DoveandGitHub 36f44f14bb fix other video artwork in xmltv (#2777) 2026-01-15 22:42:16 -06:00
Jason DoveandGitHub ccb917d0df add ffmpeg profile pad mode (#2775)
* add ffmpeg profile pad mode

* update changelog
2026-01-15 09:39:45 -06:00
Jason DoveandGitHub 343a4619a6 downmix ac3 to stereo to match output layout (#2774) 2026-01-14 10:40:49 -06:00
Jason DoveandGitHub e167c9318c fix failing unit tests (#2772) 2026-01-14 06:47:34 -06:00
Jason DoveandGitHub de230f92db fix issue reading xmltv fragments (#2771)
* fix issue reading xmltv fragments

* cleanup
2026-01-13 22:30:31 -06:00
Jason DoveandGitHub 974020a98f optimize searching for shows, seasons and movies (#2768)
* load search logging level on startup

* optimize searching for shows, seasons and movies

* use season metadata directly
2026-01-12 19:42:49 -06:00
Jason DoveandGitHub da957c9377 restore roboto font (#2767) 2026-01-12 08:49:57 -06:00
Jason DoveandGitHub b72d150775 add day_of_week to channel stream selector content_condition (#2766) 2026-01-10 11:28:14 -06:00
Jason DoveandGitHub b0b7bd17b3 respect z_index on all graphics element types (#2765) 2026-01-09 10:26:59 -06:00
Jason Dove 1f2f04f3bd more fixes 2026-01-08 21:47:28 -06:00
Jason Dove 5bc90bb245 give id-token write permission 2026-01-08 20:47:30 -06:00
Jason Dove f73a32ec13 restore permissions 2026-01-08 20:36:09 -06:00
Jason Dove 748ed1cf71 properly define secrets 2026-01-08 20:26:24 -06:00
Jason Dove f2deaa6f7a properly pass secrets 2026-01-08 20:25:20 -06:00
Jason Dove 3698fa5b7d try again 2026-01-08 20:19:47 -06:00
Jason Dove dc92cb4ac3 use separate azure login step 2026-01-08 19:59:55 -06:00
Jason Dove 69410b1a9b try to fix signing 2026-01-08 19:39:21 -06:00
Jason DoveandGitHub 4aee03e066 use code signing on all windows executables (#2764) 2026-01-08 19:27:45 -06:00
Jason Dove e16d6c67f1 prep for release v26.1.1 [no ci] 2026-01-08 16:01:29 -06:00
Jason DoveandGitHub 5d8877975d fix macos build (#2763)
* fix macos build

* also update host
2026-01-08 11:51:16 -06:00
Jason DoveandGitHub 367305d960 include web resources locally, using libman (#2762) 2026-01-08 11:24:48 -06:00
Jason DoveandGitHub aa08ad5765 optimize check for orphaned artwork (#2760) 2026-01-07 16:46:17 -06:00
190 changed files with 63879 additions and 902 deletions
+32 -2
View File
@@ -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
+4
View File
@@ -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
-5
View File
@@ -52,11 +52,6 @@ jobs:
- name: Test
run: dotnet test --blame-hang-timeout "2m" --no-restore --verbosity normal
- name: Build Windows
run: |
cd ErsatzTV-Windows
cargo build --release --all-features
build_and_test_linux:
runs-on: ${{ matrix.os }}
strategy:
+3
View File
@@ -41,6 +41,9 @@ jobs:
ac_username: ${{ secrets.AC_USERNAME }}
ac_password: ${{ secrets.AC_PASSWORD }}
gh_token: ${{ secrets.GITHUB_TOKEN }}
azure_client_id: ${{ secrets.AZURE_CLIENT_ID }}
azure_tenant_id: ${{ secrets.AZURE_TENANT_ID }}
azure_subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
build_images:
uses: ersatztv/ersatztv/.github/workflows/docker.yml@main
needs: calculate_version
+84 -8
View File
@@ -5,6 +5,80 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased]
## [26.2.0] - 2026-02-02
### Added
- Channel stream selector: add zero-based culture-specific `day_of_week` to `content_condition`, for example:
- en-US can match sunday using `day_of_week = 0`
- fr-FR can match sunday using `day_of_week = 6`
- As a complete example, to match Saturday from 9pm (inclusive) to 11pm (exclusive), based on content start time
- `content_condition: day_of_week = 6 and (time_of_day_seconds >= 75600 and time_of_day_seconds < 82800)`
- Add `Pad Mode` to ffmpeg profile. Options are:
- `Hardware If Possible` - default/existing behavior when hardware acceleration is properly configured
- `Software` - force software padding
- This can be used to work around buggy GPU driver behavior where padding is green instead of black
- This is most often seen with VAAPI acceleration (radeonsi or i965 drivers)
- Add API endpoint to clean artwork cache folder (on demand)
- POST `/api/maintenance/clean_artwork`
- Add health check to warn about unsupported empty (classic) schedules
- Add health check to warn about incompatible ffmpeg due to missing filters
- This is directly applicable to homebrew `ffmpeg` on MacOS, which is no longer compatible with ErsatzTV
- `ffmpeg@7` or `ffmpeg-full` should be used instead
- Add `Marathon Group By` option `Director`
- This groups the *first* director on Movies, Episodes, Music Videos and Other Videos
- This is supported in classic schedules and sequential schedules
- Add FFmpeg Profile options:
- `Normalize Audio` (default: true) - normalizes audio streams, or stream copies when disabled
- `Normalize Video` (default: true) - normalizes video streams, or stream copies when disabled
- `Normalize Colors` (default: true) - normalizes color parameters when enabled
- Disabling any of these options may have a significant performance benefit *at the expense of stream stability*
- Add chapter `title` to filler expression
- This can be used to include or exclude chapters with specific (case-insensitive) titles
- E.g. `title == 'here'`, `title != 'not here'`, `title like '%here%'`
- Local movie libraries: load fanart from `backdrop` files (created by Jellyfin)
### Changed
- Disable automatic artwork database cleanup
- This will be re-enabled at some point in the future (after more testing)
- For now, the API should be used to clean as needed
- Classic Schedules: make multiple `count` an expression
- The following parameters can be used:
- `count`: the total number of items in the collection
- `random`: a random number between zero and (count - 1)
- For example:
- `count / 2` will play half of the items in the collection
- `random % 4 + 1` will play between 1 and 4 items
- `2` (similar to before this change) will play exactly two items
### Fixed
- Use code signing on all Windows executables (`ErsatzTV-Windows.exe`, `ErsatzTV.exe`, `ErsatzTV.Scanner.exe`)
- Graphics engine:
- Respect `z_index` (draw order) on all graphics element types
- Fix bug with `z_index` sorting
- Restore default UI font that was erroneously removed in v26.1.1
- Classic schedules: fix building playouts when `Fill With Group Mode` schedule items also have graphics elements
- Use configured searching log level on startup, instead of the default log level of `Information`
- MySql: fix searching for shows and seasons in schedule items editor
- Fix 500 errors when serving XMLTV due to concurrent file reads and writes
- Fix playback of AC3 audio when targeting stereo output and input layout changes mid-stream
- Use other video artwork in XMLTV template
- Properly update (add or remove) artwork for all local media libraries when files have changed
- Sync Plex library name changes
- Sync Plex episode title, plot, year, date added, release date, episode number changes
- Sync Jellyfin and Emby library name and type changes
- Library type (movies, shows) can only be changed when synchronization is *disabled* for the library in ETV
- Fix some sequential and scripted playout build failures when using playlists or marathons
- Fix erasing playout items and history so all related data is also erased
- This includes rerun history, unscheduled gaps, build status
- Fix indexing collections when using Elasticsearch backend
## [26.1.1] - 2026-01-08
### Fixed
- Use code signing on Windows launcher (`ErsatzTV-Windows.exe`) to avoid antivirus false positive
### Changed
- Optimize database check for orphaned artwork
- Include web resources (CSS, JS) locally instead of relying on CDNs
## [26.1.0] - 2026-01-06
### Added
- Graphics Engine:
@@ -718,13 +792,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- `random` will start at a random point in the content
- `2` (similar to before this change) will skip the first two items in the content
- YAML playout: make `count` an expression
- The following parameters can be used:
- `count`: the total number of items in the content
- `random`: a random number between zero and (count - 1)
- For example:
- `count / 2` will play half of the items in the content
- `random % 4 + 1` will play between 1 and 4 items
- `2` (similar to before this change) will play exactly two items
- The following parameters can be used:
- `count`: the total number of items in the content
- `random`: a random number between zero and (count - 1)
- For example:
- `count / 2` will play half of the items in the content
- `random % 4 + 1` will play between 1 and 4 items
- `2` (similar to before this change) will play exactly two items
- YAML playout: add `disable_watermarks` property to all content instructions
- This property defaults to `false` (meaning watermarks are allowed by default)
- Setting to `true` will prevent watermarks from ever appearing over the content
@@ -3081,7 +3155,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Initial release to facilitate testing outside of Docker.
[Unreleased]: https://github.com/ErsatzTV/ErsatzTV/compare/v26.1.0...HEAD
[Unreleased]: https://github.com/ErsatzTV/ErsatzTV/compare/v26.2.0...HEAD
[26.2.0]: https://github.com/ErsatzTV/ErsatzTV/compare/v26.1.1...v26.2.0
[26.1.1]: https://github.com/ErsatzTV/ErsatzTV/compare/v26.1.0...v26.1.1
[26.1.0]: https://github.com/ErsatzTV/ErsatzTV/compare/v25.9.0...v26.1.0
[25.9.0]: https://github.com/ErsatzTV/ErsatzTV/compare/v25.8.0...v25.9.0
[25.8.0]: https://github.com/ErsatzTV/ErsatzTV/compare/v25.7.1...v25.8.0
@@ -883,6 +883,8 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
metadata.Genres ??= [];
metadata.Guids ??= [];
string artworkPath = GetPrioritizedArtworkPath(metadata);
var data = new
{
ProgrammeStart = start,
@@ -897,6 +899,8 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
OtherVideoPlot = metadata.Plot,
OtherVideoHasYear = metadata.Year.HasValue,
OtherVideoYear = metadata.Year,
OtherVideoHasArtwork = !string.IsNullOrWhiteSpace(artworkPath),
OtherVideoArtworkUrl = artworkPath,
OtherVideoGenres = metadata.Genres.Map(g => g.Name).OrderBy(n => n),
OtherVideoHasContentRating = !string.IsNullOrWhiteSpace(metadata.ContentRating),
OtherVideoContentRating = metadata.ContentRating
@@ -11,30 +11,18 @@ using Microsoft.IO;
namespace ErsatzTV.Application.Channels;
public partial class GetChannelGuideHandler : IRequestHandler<GetChannelGuide, Either<BaseError, ChannelGuide>>
public partial class GetChannelGuideHandler(
IDbContextFactory<TvContext> dbContextFactory,
RecyclableMemoryStreamManager recyclableMemoryStreamManager,
IFileSystem fileSystem,
ILocalFileSystem localFileSystem)
: IRequestHandler<GetChannelGuide, Either<BaseError, ChannelGuide>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly ILocalFileSystem _localFileSystem;
private readonly RecyclableMemoryStreamManager _recyclableMemoryStreamManager;
private readonly IFileSystem _fileSystem;
public GetChannelGuideHandler(
IDbContextFactory<TvContext> dbContextFactory,
RecyclableMemoryStreamManager recyclableMemoryStreamManager,
IFileSystem fileSystem,
ILocalFileSystem localFileSystem)
{
_dbContextFactory = dbContextFactory;
_recyclableMemoryStreamManager = recyclableMemoryStreamManager;
_fileSystem = fileSystem;
_localFileSystem = localFileSystem;
}
public async Task<Either<BaseError, ChannelGuide>> Handle(
GetChannelGuide request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
var hiddenChannelNumbers = dbContext.Channels
.Where(c => c.ShowInEpg == false)
.Select(c => c.Number)
@@ -42,13 +30,13 @@ public partial class GetChannelGuideHandler : IRequestHandler<GetChannelGuide, E
.Select(n => $"{n}.xml")
.ToImmutableHashSet();
string channelsFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, "channels.xml");
if (!_fileSystem.File.Exists(channelsFile))
string channelsFile = fileSystem.Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, "channels.xml");
if (!fileSystem.File.Exists(channelsFile))
{
return BaseError.New($"Required file {channelsFile} is missing");
}
long mtime = File.GetLastWriteTime(channelsFile).Ticks;
long mtime = fileSystem.File.GetLastWriteTime(channelsFile).Ticks;
var accessTokenUri = $"?v={mtime}";
if (!string.IsNullOrWhiteSpace(request.AccessToken))
@@ -56,7 +44,7 @@ public partial class GetChannelGuideHandler : IRequestHandler<GetChannelGuide, E
accessTokenUri += $"&amp;access_token={request.AccessToken}";
}
string channelsFragment = await File.ReadAllTextAsync(channelsFile, Encoding.UTF8, cancellationToken);
string channelsFragment = await ReadAllTextShared(channelsFile, cancellationToken);
// TODO: is regex faster?
channelsFragment = channelsFragment
@@ -65,30 +53,52 @@ public partial class GetChannelGuideHandler : IRequestHandler<GetChannelGuide, E
var channelDataFragments = new Dictionary<string, string>();
foreach (string fileName in _localFileSystem.ListFiles(FileSystemLayout.ChannelGuideCacheFolder))
foreach (string fileName in localFileSystem.ListFiles(FileSystemLayout.ChannelGuideCacheFolder))
{
if (fileName.Contains("channels"))
{
continue;
}
if (hiddenChannelNumbers.Contains(Path.GetFileName(fileName)))
if (hiddenChannelNumbers.Contains(fileSystem.Path.GetFileName(fileName)))
{
continue;
}
string channelDataFragment = await File.ReadAllTextAsync(fileName, Encoding.UTF8, cancellationToken);
try
{
string channelDataFragment = await ReadAllTextShared(fileName, cancellationToken);
channelDataFragment = channelDataFragment
.Replace("{RequestBase}", $"{request.Scheme}://{request.Host}{request.BaseUrl}")
.Replace("{AccessTokenUri}", accessTokenUri);
channelDataFragment = channelDataFragment
.Replace("{RequestBase}", $"{request.Scheme}://{request.Host}{request.BaseUrl}")
.Replace("{AccessTokenUri}", accessTokenUri);
channelDataFragment = EtvTagRegex().Replace(channelDataFragment, string.Empty);
channelDataFragment = EtvTagRegex().Replace(channelDataFragment, string.Empty);
channelDataFragments.Add(Path.GetFileNameWithoutExtension(fileName), channelDataFragment);
channelDataFragments.Add(fileSystem.Path.GetFileNameWithoutExtension(fileName), channelDataFragment);
}
catch (FileNotFoundException)
{
// ignore this channel fragment
}
catch (IOException)
{
// ignore this channel fragment
}
}
return new ChannelGuide(_recyclableMemoryStreamManager, channelsFragment, channelDataFragments);
return new ChannelGuide(recyclableMemoryStreamManager, channelsFragment, channelDataFragments);
}
private async Task<string> ReadAllTextShared(string fileName, CancellationToken cancellationToken)
{
await using var stream = fileSystem.FileStream.New(
fileName,
FileMode.Open,
FileAccess.Read,
FileShare.ReadWrite);
using var reader = new StreamReader(stream, Encoding.UTF8, leaveOpen: true);
return await reader.ReadToEndAsync(cancellationToken);
}
[GeneratedRegex(@"<etv:[^>]+?>.*?<\/etv:[^>]+?>|<etv:[^>]+?\/>", RegexOptions.Singleline)]
@@ -14,8 +14,8 @@
<PackageReference Include="CliWrap" Version="3.10.0" />
<PackageReference Include="Humanizer.Core" Version="3.0.1" />
<PackageReference Include="MediatR" Version="[12.5.0]" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.2" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.2" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
<PackageReference Include="Serilog.Formatting.Compact.Reader" Version="4.0.0" />
<PackageReference Include="WebMarkupMin.Core" Version="2.20.1" />
@@ -7,6 +7,8 @@ namespace ErsatzTV.Application.FFmpegProfiles;
public record CreateFFmpegProfile(
string Name,
int ThreadCount,
bool NormalizeAudio,
bool NormalizeVideo,
HardwareAccelerationKind HardwareAcceleration,
string VaapiDisplay,
VaapiDriver VaapiDriver,
@@ -14,6 +16,7 @@ public record CreateFFmpegProfile(
int? QsvExtraHardwareFrames,
int ResolutionId,
ScalingBehavior ScalingBehavior,
FilterMode PadMode,
FFmpegProfileVideoFormat VideoFormat,
string VideoProfile,
string VideoPreset,
@@ -30,4 +33,5 @@ public record CreateFFmpegProfile(
int AudioChannels,
int AudioSampleRate,
bool NormalizeFramerate,
bool NormalizeColors,
bool DeinterlaceVideo) : IRequest<Either<BaseError, CreateFFmpegProfileResult>>;
@@ -44,42 +44,63 @@ public class CreateFFmpegProfileHandler :
CancellationToken cancellationToken) =>
(ValidateName(request), ValidateThreadCount(request),
await ResolutionMustExist(dbContext, request, cancellationToken))
.Apply((name, threadCount, resolutionId) => new FFmpegProfile
.Apply((name, threadCount, resolutionId) =>
{
Name = name,
ThreadCount = threadCount,
HardwareAcceleration = request.HardwareAcceleration,
VaapiDriver = request.VaapiDriver,
VaapiDevice = request.VaapiDevice,
QsvExtraHardwareFrames = request.QsvExtraHardwareFrames,
ResolutionId = resolutionId,
ScalingBehavior = request.ScalingBehavior,
VideoFormat = request.VideoFormat,
VideoProfile = request.VideoProfile,
VideoPreset = request.VideoPreset,
AllowBFrames = request.AllowBFrames,
var hwAccel = request.NormalizeVideo
? request.HardwareAcceleration
: HardwareAccelerationKind.None;
// mpeg2video only supports 8-bit content
BitDepth = request.VideoFormat is FFmpegProfileVideoFormat.Mpeg2Video
? FFmpegProfileBitDepth.EightBit
: request.BitDepth,
return new FFmpegProfile
{
Name = name,
ThreadCount = threadCount,
VideoBitrate = request.VideoBitrate,
VideoBufferSize = request.VideoBufferSize,
TonemapAlgorithm = request.TonemapAlgorithm,
AudioFormat = request.AudioFormat,
AudioBitrate = request.AudioBitrate,
AudioBufferSize = request.AudioBufferSize,
NormalizeAudio = request.NormalizeAudio,
NormalizeVideo = request.NormalizeVideo,
NormalizeLoudnessMode = request.NormalizeLoudnessMode,
TargetLoudness = request.NormalizeLoudnessMode is NormalizeLoudnessMode.LoudNorm
? request.TargetLoudness
: null,
HardwareAcceleration = hwAccel,
VaapiDriver = request.VaapiDriver,
VaapiDevice = request.VaapiDevice,
QsvExtraHardwareFrames = request.QsvExtraHardwareFrames,
ResolutionId = resolutionId,
ScalingBehavior = request.ScalingBehavior,
AudioChannels = request.AudioChannels,
AudioSampleRate = request.AudioSampleRate,
NormalizeFramerate = request.NormalizeFramerate,
DeinterlaceVideo = request.DeinterlaceVideo
// only allow customization with VAAPI accel
PadMode = hwAccel switch
{
HardwareAccelerationKind.None => FilterMode.Software,
HardwareAccelerationKind.Vaapi => request.PadMode,
_ => FilterMode.HardwareIfPossible
},
VideoFormat = request.NormalizeVideo ? request.VideoFormat : FFmpegProfileVideoFormat.Copy,
VideoProfile = request.VideoProfile,
VideoPreset = request.VideoPreset,
AllowBFrames = request.AllowBFrames,
// mpeg2video only supports 8-bit content
BitDepth = request.VideoFormat is FFmpegProfileVideoFormat.Mpeg2Video
? FFmpegProfileBitDepth.EightBit
: request.BitDepth,
VideoBitrate = request.VideoBitrate,
VideoBufferSize = request.VideoBufferSize,
TonemapAlgorithm = request.TonemapAlgorithm,
AudioFormat = request.NormalizeAudio ? request.AudioFormat : FFmpegProfileAudioFormat.Copy,
AudioBitrate = request.AudioBitrate,
AudioBufferSize = request.AudioBufferSize,
NormalizeLoudnessMode = request.NormalizeLoudnessMode,
TargetLoudness = request.NormalizeLoudnessMode is NormalizeLoudnessMode.LoudNorm
? request.TargetLoudness
: null,
AudioChannels = request.AudioChannels,
AudioSampleRate = request.AudioSampleRate,
NormalizeFramerate = request.NormalizeFramerate,
NormalizeColors = request.NormalizeColors,
DeinterlaceVideo = request.DeinterlaceVideo
};
});
private static Validation<BaseError, string> ValidateName(CreateFFmpegProfile createFFmpegProfile) =>
@@ -8,6 +8,8 @@ public record UpdateFFmpegProfile(
int FFmpegProfileId,
string Name,
int ThreadCount,
bool NormalizeAudio,
bool NormalizeVideo,
HardwareAccelerationKind HardwareAcceleration,
string VaapiDisplay,
VaapiDriver VaapiDriver,
@@ -15,6 +17,7 @@ public record UpdateFFmpegProfile(
int? QsvExtraHardwareFrames,
int ResolutionId,
ScalingBehavior ScalingBehavior,
FilterMode PadMode,
FFmpegProfileVideoFormat VideoFormat,
string VideoProfile,
string VideoPreset,
@@ -31,4 +34,5 @@ public record UpdateFFmpegProfile(
int AudioChannels,
int AudioSampleRate,
bool NormalizeFramerate,
bool NormalizeColors,
bool DeinterlaceVideo) : IRequest<Either<BaseError, UpdateFFmpegProfileResult>>;
@@ -27,16 +27,23 @@ public class UpdateFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFa
UpdateFFmpegProfile update,
CancellationToken cancellationToken)
{
var hwAccel = update.NormalizeVideo
? update.HardwareAcceleration
: HardwareAccelerationKind.None;
p.Name = update.Name;
p.ThreadCount = update.ThreadCount;
p.HardwareAcceleration = update.HardwareAcceleration;
p.NormalizeAudio = update.NormalizeAudio;
p.NormalizeVideo = update.NormalizeVideo;
p.HardwareAcceleration = hwAccel;
p.VaapiDisplay = update.VaapiDisplay;
p.VaapiDriver = update.VaapiDriver;
p.VaapiDevice = update.VaapiDevice;
p.QsvExtraHardwareFrames = update.QsvExtraHardwareFrames;
p.ResolutionId = update.ResolutionId;
p.ScalingBehavior = update.ScalingBehavior;
p.VideoFormat = update.VideoFormat;
p.PadMode = update.PadMode;
p.VideoFormat = update.NormalizeVideo ? update.VideoFormat : FFmpegProfileVideoFormat.Copy;
p.VideoProfile = update.VideoProfile;
p.VideoPreset = update.VideoPreset;
p.AllowBFrames = update.AllowBFrames;
@@ -53,10 +60,20 @@ public class UpdateFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFa
p.VideoFormat = FFmpegProfileVideoFormat.Hevc;
}
// only allow customization with VAAPI accel
if (p.HardwareAcceleration is HardwareAccelerationKind.None)
{
p.PadMode = FilterMode.Software;
}
else if (p.HardwareAcceleration is not HardwareAccelerationKind.Vaapi)
{
p.PadMode = FilterMode.HardwareIfPossible;
}
p.VideoBitrate = update.VideoBitrate;
p.VideoBufferSize = update.VideoBufferSize;
p.TonemapAlgorithm = update.TonemapAlgorithm;
p.AudioFormat = update.AudioFormat;
p.AudioFormat = update.NormalizeAudio ? update.AudioFormat : FFmpegProfileAudioFormat.Copy;
p.AudioBitrate = update.AudioBitrate;
p.AudioBufferSize = update.AudioBufferSize;
@@ -68,6 +85,7 @@ public class UpdateFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFa
p.AudioChannels = update.AudioChannels;
p.AudioSampleRate = update.AudioSampleRate;
p.NormalizeFramerate = update.NormalizeFramerate;
p.NormalizeColors = update.NormalizeColors;
p.DeinterlaceVideo = update.DeinterlaceVideo;
// don't save invalid preset
@@ -8,6 +8,8 @@ public record FFmpegProfileViewModel(
int Id,
string Name,
int ThreadCount,
bool NormalizeAudio,
bool NormalizeVideo,
HardwareAccelerationKind HardwareAcceleration,
string VaapiDisplay,
VaapiDriver VaapiDriver,
@@ -15,6 +17,7 @@ public record FFmpegProfileViewModel(
int? QsvExtraHardwareFrames,
ResolutionViewModel Resolution,
ScalingBehavior ScalingBehavior,
FilterMode PadMode,
FFmpegProfileVideoFormat VideoFormat,
string VideoProfile,
string VideoPreset,
@@ -31,4 +34,5 @@ public record FFmpegProfileViewModel(
int AudioChannels,
int AudioSampleRate,
bool NormalizeFramerate,
bool NormalizeColors,
bool DeinterlaceVideo);
@@ -10,6 +10,8 @@ internal static class Mapper
profile.Id,
profile.Name,
profile.ThreadCount,
profile.NormalizeAudio,
profile.NormalizeVideo,
profile.HardwareAcceleration,
profile.VaapiDisplay ?? "drm",
profile.VaapiDriver,
@@ -17,6 +19,7 @@ internal static class Mapper
profile.QsvExtraHardwareFrames,
Resolutions.Mapper.ProjectToViewModel(profile.Resolution),
profile.ScalingBehavior,
profile.PadMode,
profile.VideoFormat,
profile.VideoProfile,
profile.VideoPreset ?? string.Empty,
@@ -33,6 +36,7 @@ internal static class Mapper
profile.AudioChannels,
profile.AudioSampleRate,
profile.NormalizeFramerate,
profile.NormalizeColors,
profile.DeinterlaceVideo == true);
internal static FFmpegProfileResponseModel ProjectToResponseModel(FFmpegProfile ffmpegProfile) =>
@@ -1,18 +1,174 @@
using ErsatzTV.Core;
using System.Globalization;
using System.IO.Abstractions;
using ErsatzTV.Core;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Images;
using Humanizer;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Application.Maintenance;
public class DeleteOrphanedArtworkHandler : IRequestHandler<DeleteOrphanedArtwork, Either<BaseError, Unit>>
public class DeleteOrphanedArtworkHandler(
IDbContextFactory<TvContext> dbContextFactory,
IArtworkRepository artworkRepository,
IFileSystem fileSystem,
ILogger<DeleteOrphanedArtworkHandler> logger)
: IRequestHandler<DeleteOrphanedArtwork, Either<BaseError, Unit>>
{
private readonly IArtworkRepository _artworkRepository;
public async Task<Either<BaseError, Unit>> Handle(
DeleteOrphanedArtwork request,
CancellationToken cancellationToken)
{
try
{
await CleanUpDatabase();
await CleanUpFileSystem(cancellationToken);
public DeleteOrphanedArtworkHandler(IArtworkRepository artworkRepository) =>
_artworkRepository = artworkRepository;
return Unit.Default;
}
catch (Exception e)
{
return BaseError.New(e.Message);
}
}
public Task<Either<BaseError, Unit>>
Handle(DeleteOrphanedArtwork request, CancellationToken cancellationToken) =>
_artworkRepository.GetOrphanedArtwork()
.Bind(_artworkRepository.Delete)
.Map(_ => Right<BaseError, Unit>(Unit.Default));
private async Task CleanUpDatabase()
{
List<int> ids = await artworkRepository.GetOrphanedArtworkIds();
if (ids.Count > 0)
{
await artworkRepository.Delete(ids);
}
}
private async Task CleanUpFileSystem(CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
System.Collections.Generic.HashSet<string> validFiles = [];
List<string> watermarks = await dbContext.ChannelWatermarks
.TagWithCallSite()
.AsNoTracking()
.Select(c => c.Image)
.ToListAsync(cancellationToken);
foreach (string watermark in watermarks.Where(w => !string.IsNullOrWhiteSpace(w)))
{
validFiles.Add(watermark);
}
var lastId = 0;
while (true)
{
List<MinimalArtwork> result = await dbContext.Artwork
.TagWithCallSite()
.AsNoTracking()
.Where(a => a.Id > lastId)
.OrderBy(a => a.Id)
.Take(1000)
.Select(a => new MinimalArtwork(a.Id, a.Path, a.BlurHash43, a.BlurHash54, a.BlurHash64))
.ToListAsync(cancellationToken);
if (result.Count == 0)
{
break;
}
foreach (MinimalArtwork artwork in result)
{
if (!string.IsNullOrWhiteSpace(artwork.Path) && !artwork.Path.Contains('/'))
{
validFiles.Add(artwork.Path);
}
if (!string.IsNullOrWhiteSpace(artwork.BlurHash43))
{
validFiles.Add(ImageCache.GetBlurHashFileName(artwork.BlurHash43));
}
if (!string.IsNullOrWhiteSpace(artwork.BlurHash54))
{
validFiles.Add(ImageCache.GetBlurHashFileName(artwork.BlurHash54));
}
if (!string.IsNullOrWhiteSpace(artwork.BlurHash64))
{
validFiles.Add(ImageCache.GetBlurHashFileName(artwork.BlurHash64));
}
}
lastId = result.Last().Id;
}
logger.LogDebug("Loaded {Count} artwork hashes (valid file names)", validFiles.Count);
var deleted = 0;
long bytes = 0;
foreach (string file in fileSystem.Directory.EnumerateFiles(
FileSystemLayout.ArtworkCacheFolder,
"*.*",
SearchOption.AllDirectories))
{
string fileName = fileSystem.Path.GetFileName(file);
if (!validFiles.Contains(fileName))
{
try
{
bytes += fileSystem.FileInfo.New(file).Length;
fileSystem.File.Delete(file);
deleted++;
}
catch (Exception ex)
{
logger.LogWarning(ex, "Could not delete artwork file {File}", file);
}
}
}
logger.LogDebug(
"Deleted {Count} unused artwork cache files totaling {Size}",
deleted,
bytes.Bytes().Humanize(CultureInfo.CurrentCulture));
DeleteEmptySubfolders(FileSystemLayout.ArtworkCacheFolder);
}
private void DeleteEmptySubfolders(string path)
{
if (!fileSystem.Directory.Exists(path))
{
return;
}
foreach (string sub in fileSystem.Directory.GetDirectories(path))
{
DeleteEmptySubfolders(sub);
}
if (!fileSystem.Directory.EnumerateFileSystemEntries(path).Any())
{
try
{
// don't delete artwork cache folder or its direct children
if (path != FileSystemLayout.ArtworkCacheFolder)
{
var parent = fileSystem.Directory.GetParent(path);
if (parent?.FullName != FileSystemLayout.ArtworkCacheFolder)
{
fileSystem.Directory.Delete(path);
}
}
}
catch (Exception ex)
{
logger.LogWarning(ex, "Could not delete empty cache folder {Folder}", path);
}
}
}
private sealed record MinimalArtwork(int Id, string Path, string BlurHash43, string BlurHash54, string BlurHash64);
}
@@ -26,7 +26,7 @@ public record AddProgramScheduleItem(
int? MarathonBatchSize,
FillWithGroupMode FillWithGroupMode,
MultipleMode MultipleMode,
int? MultipleCount,
string MultipleCount,
TimeSpan? PlayoutDuration,
TailMode TailMode,
int? DiscardToFillAttempts,
@@ -24,7 +24,7 @@ public interface IProgramScheduleItemRequest
int? MarathonBatchSize { get; }
FillWithGroupMode FillWithGroupMode { get; }
MultipleMode MultipleMode { get; }
int? MultipleCount { get; }
string MultipleCount { get; }
TimeSpan? PlayoutDuration { get; }
TailMode TailMode { get; }
int? DiscardToFillAttempts { get; }
@@ -79,10 +79,10 @@ public abstract class ProgramScheduleItemCommandBase
"[MultipleMode] cannot be [PlaylistItemSize] when collection is not a playlist");
}
if (item.MultipleMode is MultipleMode.Count && item.MultipleCount.GetValueOrDefault() < 1)
if (item.MultipleMode is MultipleMode.Count && string.IsNullOrWhiteSpace(item.MultipleCount))
{
return BaseError.New(
"[MultipleCount] must be greater than 0 for playout mode 'multiple / count'");
"[MultipleCount] must be valid for playout mode 'multiple / count'");
}
break;
@@ -298,7 +298,7 @@ public abstract class ProgramScheduleItemCommandBase
MarathonBatchSize = item.MarathonBatchSize,
FillWithGroupMode = item.FillWithGroupMode,
MultipleMode = item.MultipleMode,
Count = item.MultipleMode is MultipleMode.Count ? item.MultipleCount.GetValueOrDefault() : 0,
Count = item.MultipleMode is MultipleMode.Count ? item.MultipleCount ?? "0" : "0",
CustomTitle = item.CustomTitle,
GuideMode = item.GuideMode,
PreRollFillerId = item.PreRollFillerId,
@@ -26,7 +26,7 @@ public record ReplaceProgramScheduleItem(
int? MarathonBatchSize,
FillWithGroupMode FillWithGroupMode,
MultipleMode MultipleMode,
int? MultipleCount,
string MultipleCount,
TimeSpan? PlayoutDuration,
TailMode TailMode,
int? DiscardToFillAttempts,
@@ -32,7 +32,7 @@ public record ProgramScheduleItemMultipleViewModel : ProgramScheduleItemViewMode
int? marathonBatchSize,
FillWithGroupMode fillWithGroupMode,
MultipleMode multipleMode,
int count,
string count,
string customTitle,
GuideMode guideMode,
FillerPresetViewModel preRollFiller,
@@ -87,5 +87,5 @@ public record ProgramScheduleItemMultipleViewModel : ProgramScheduleItemViewMode
public MultipleMode MultipleMode { get; set; }
public int Count { get; }
public string Count { get; }
}
@@ -21,27 +21,43 @@ public class ErasePlayoutHistoryHandler(IDbContextFactory<TvContext> dbContextFa
foreach (Playout playout in maybePlayout)
{
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
int nextSeed = new Random().Next();
playout.Seed = nextSeed;
// this deletes the owned PlayoutAnchor
playout.Anchor = null;
playout.OnDemandCheckpoint = null;
await dbContext.SaveChangesAsync(cancellationToken);
await dbContext.Database.ExecuteSqlAsync(
$"DELETE FROM PlayoutItem WHERE PlayoutId = {playout.Id}",
cancellationToken);
await dbContext.PlayoutItems
.Where(pi => pi.PlayoutId == playout.Id)
.ExecuteDeleteAsync(cancellationToken);
await dbContext.Database.ExecuteSqlAsync(
$"DELETE FROM PlayoutHistory WHERE PlayoutId = {playout.Id}",
cancellationToken);
await dbContext.PlayoutHistory
.Where(ph => ph.PlayoutId == playout.Id)
.ExecuteDeleteAsync(cancellationToken);
await dbContext.Database.ExecuteSqlAsync(
$"DELETE FROM PlayoutAnchor WHERE PlayoutId = {playout.Id}",
cancellationToken);
await dbContext.PlayoutProgramScheduleItemAnchors
.Where(a => a.PlayoutId == playout.Id)
.ExecuteDeleteAsync(cancellationToken);
await dbContext.Database.ExecuteSqlAsync(
$"DELETE FROM PlayoutProgramScheduleAnchor WHERE PlayoutId = {playout.Id}",
cancellationToken);
await dbContext.RerunHistory
.Where(rh => rh.PlayoutId == playout.Id)
.ExecuteDeleteAsync(cancellationToken);
await dbContext.PlayoutGaps
.Where(pg => pg.PlayoutId == playout.Id)
.ExecuteDeleteAsync(cancellationToken);
await dbContext.PlayoutBuildStatus
.Where(pb => pb.PlayoutId == playout.Id)
.ExecuteDeleteAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
}
}
}
@@ -1,7 +1,5 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Scheduling;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Scheduling;
@@ -14,39 +12,49 @@ public class ErasePlayoutItemsHandler(IDbContextFactory<TvContext> dbContextFact
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Option<Playout> maybePlayout = await dbContext.Playouts
.Include(p => p.Items)
.Include(p => p.PlayoutHistory)
.TagWithCallSite()
.AsNoTracking()
.Filter(p => p.ScheduleKind == PlayoutScheduleKind.Block ||
p.ScheduleKind == PlayoutScheduleKind.Sequential ||
p.ScheduleKind == PlayoutScheduleKind.Scripted)
.SelectOneAsync(p => p.Id, p => p.Id == request.PlayoutId, cancellationToken);
.SingleOrDefaultAsync(p => p.Id == request.PlayoutId, cancellationToken);
foreach (Playout playout in maybePlayout)
{
// find the earliest item that finishes after "now"
Option<PlayoutItem> maybeFirstItem = playout.Items
.Filter(i => i.FinishOffset > DateTimeOffset.Now)
.OrderBy(i => i.StartOffset)
.HeadOrNone();
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
// delete all history starting with that item
// importantly, do NOT delete earlier history
foreach (PlayoutItem item in maybeFirstItem)
// find the earliest item that finishes after "now"
Option<PlayoutItem> maybeFirstItem = await dbContext.PlayoutItems
.TagWithCallSite()
.AsNoTracking()
.Where(pi => pi.PlayoutId == playout.Id)
.Where(pi => pi.Finish > DateTime.UtcNow)
.OrderBy(i => i.Start)
.FirstOrDefaultAsync(cancellationToken);
foreach (PlayoutItem firstItem in maybeFirstItem)
{
var toRemove = playout.PlayoutHistory.Filter(h => h.When >= item.Start).ToList();
foreach (PlayoutHistory history in toRemove)
{
playout.PlayoutHistory.Remove(history);
}
// delete all history starting with that item
// importantly, do NOT delete earlier history
await dbContext.PlayoutHistory
.Where(ph => ph.PlayoutId == playout.Id)
.Where(ph => ph.When >= firstItem.Start)
.ExecuteDeleteAsync(cancellationToken);
}
// save history changes
await dbContext.SaveChangesAsync(cancellationToken);
await dbContext.PlayoutItems
.Where(pi => pi.PlayoutId == playout.Id)
.ExecuteDeleteAsync(cancellationToken);
// delete all playout items
await dbContext.Database.ExecuteSqlAsync(
$"DELETE FROM PlayoutItem WHERE PlayoutId = {playout.Id}",
cancellationToken);
await dbContext.PlayoutGaps
.Where(pg => pg.PlayoutId == playout.Id)
.ExecuteDeleteAsync(cancellationToken);
await dbContext.PlayoutBuildStatus
.Where(pb => pb.PlayoutId == playout.Id)
.ExecuteDeleteAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
}
}
}
@@ -1,23 +1,32 @@
using System.Collections.Immutable;
using System.Globalization;
using Bugsnag;
using ErsatzTV.Application.MediaItems;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Search;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Search;
public class SearchMoviesHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<SearchMovies, List<NamedMediaItemViewModel>>
public class SearchMoviesHandler(
IClient client,
ISearchIndex searchIndex,
IDbContextFactory<TvContext> dbContextFactory)
: SearchUsingSearchIndexHandler(client, searchIndex), IRequestHandler<SearchMovies, List<NamedMediaItemViewModel>>
{
public async Task<List<NamedMediaItemViewModel>> Handle(SearchMovies request, CancellationToken cancellationToken)
{
ImmutableHashSet<int> ids = await Search(LuceneSearchIndex.MovieType, request.Query, cancellationToken);
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
return await dbContext.MovieMetadata
.TagWithCallSite()
.AsNoTracking()
.Where(m => EF.Functions.Like(m.Title + " " + m.Year, $"%{request.Query}%"))
.OrderBy(m => m.Title)
.ThenBy(m => m.Year)
.Take(10)
.Where(mm => ids.Contains(mm.MovieId))
.OrderBy(mm => mm.Title)
.ThenBy(mm => mm.Year)
.ToListAsync(cancellationToken)
.Map(list => list.Map(ToNamedMediaItem).ToList());
}
@@ -1,30 +1,45 @@
using System.Collections.Immutable;
using System.Globalization;
using Bugsnag;
using ErsatzTV.Application.MediaItems;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Search;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Search;
public class SearchTelevisionSeasonsHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<SearchTelevisionSeasons, List<NamedMediaItemViewModel>>
public class SearchTelevisionSeasonsHandler(
IClient client,
ISearchIndex searchIndex,
IDbContextFactory<TvContext> dbContextFactory)
: SearchUsingSearchIndexHandler(client, searchIndex),
IRequestHandler<SearchTelevisionSeasons, List<NamedMediaItemViewModel>>
{
public async Task<List<NamedMediaItemViewModel>> Handle(
SearchTelevisionSeasons request,
CancellationToken cancellationToken)
{
ImmutableHashSet<int> ids = await Search(LuceneSearchIndex.SeasonType, request.Query, cancellationToken);
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
return await (from season in dbContext.Set<Season>()
join seasonMetadata in dbContext.Set<SeasonMetadata>()
on season.Id equals seasonMetadata.SeasonId
join showMetadata in dbContext.Set<ShowMetadata>()
on season.ShowId equals showMetadata.ShowId
where EF.Functions.Like(showMetadata.Title + " " + seasonMetadata.Title, $"%{request.Query}%")
orderby showMetadata.Title, season.SeasonNumber
select new TelevisionSeason(season.Id, showMetadata.Title, showMetadata.Year, season.SeasonNumber))
.Take(20)
return await dbContext.SeasonMetadata
.TagWithCallSite()
.AsNoTracking()
.Include(s => s.Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
.Where(sm => ids.Contains(sm.SeasonId))
.ToListAsync(cancellationToken)
.Map(list => list.Map(ToNamedMediaItem).ToList());
.Map(list => list.Map(sm => new TelevisionSeason(
sm.SeasonId,
sm.Season.Show.ShowMetadata.HeadOrNone().Match(s => s.Title, string.Empty),
sm.Year,
sm.Season.SeasonNumber))
.OrderBy(s => s.Title)
.ThenBy(s => s.SeasonNumber)
.Map(ToNamedMediaItem)
.ToList());
}
private static NamedMediaItemViewModel ToNamedMediaItem(TelevisionSeason season) =>
@@ -1,25 +1,35 @@
using System.Collections.Immutable;
using System.Globalization;
using Bugsnag;
using ErsatzTV.Application.MediaItems;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Search;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Search;
public class SearchTelevisionShowsHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<SearchTelevisionShows, List<NamedMediaItemViewModel>>
public class SearchTelevisionShowsHandler(
IClient client,
ISearchIndex searchIndex,
IDbContextFactory<TvContext> dbContextFactory)
: SearchUsingSearchIndexHandler(client, searchIndex),
IRequestHandler<SearchTelevisionShows, List<NamedMediaItemViewModel>>
{
public async Task<List<NamedMediaItemViewModel>> Handle(
SearchTelevisionShows request,
CancellationToken cancellationToken)
{
ImmutableHashSet<int> ids = await Search(LuceneSearchIndex.ShowType, request.Query, cancellationToken);
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
return await dbContext.ShowMetadata
.TagWithCallSite()
.AsNoTracking()
.Where(s => EF.Functions.Like(s.Title + " " + s.Year, $"%{request.Query}%"))
.OrderBy(s => s.Title)
.ThenBy(s => s.Year)
.Take(10)
.Where(sm => ids.Contains(sm.ShowId))
.OrderBy(sm => sm.Title)
.ThenBy(sm => sm.Year)
.ToListAsync(cancellationToken)
.Map(list => list.Map(ToNamedMediaItem).ToList());
}
@@ -0,0 +1,25 @@
using System.Collections.Immutable;
using Bugsnag;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Search;
namespace ErsatzTV.Application.Search;
public abstract class SearchUsingSearchIndexHandler(IClient client, ISearchIndex searchIndex)
{
private const int PageSize = 10;
protected async Task<ImmutableHashSet<int>> Search(string type, string query, CancellationToken cancellationToken)
{
var searchResult = await searchIndex.Search(
client,
$"type:{type} AND *{query.Replace(" ", @"\ ")}*",
string.Empty,
0,
PageSize,
[LuceneSearchIndex.TitleAndYearSearchField],
cancellationToken);
return searchResult.Items.Select(i => i.Id).ToImmutableHashSet();
}
}
@@ -10,20 +10,20 @@
<PackageReference Include="Bugsnag" Version="4.1.0" />
<PackageReference Include="CliWrap" Version="3.10.0" />
<PackageReference Include="LanguageExt.Core" Version="4.4.9" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.2" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.2" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.2" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.2" />
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="10.0.2" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="NUnit" Version="4.4.0" />
<PackageReference Include="NUnit3TestAdapter" Version="6.0.1" />
<PackageReference Include="NUnit3TestAdapter" Version="6.1.0" />
<PackageReference Include="Serilog" Version="4.3.0" />
<PackageReference Include="Serilog.Extensions.Logging" Version="10.0.0" />
<PackageReference Include="Serilog.Sinks.Debug" Version="3.0.0" />
<PackageReference Include="Shouldly" Version="4.3.0" />
<PackageReference Include="Testably.Abstractions.Testing" Version="5.0.1" />
<PackageReference Include="Testably.Abstractions.Testing" Version="5.0.2" />
</ItemGroup>
<ItemGroup>
@@ -7,6 +7,7 @@ using NUnit.Framework;
using Serilog;
using Shouldly;
using Testably.Abstractions.Testing;
using TimeZoneConverter;
namespace ErsatzTV.Core.Tests.FFmpeg;
@@ -569,6 +570,289 @@ public class CustomStreamSelectorTests
}
}
[Test]
public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Content_Condition_Fail()
{
const string YAML =
"""
---
items:
- audio_language: ["ja"]
subtitle_language: ["eng"]
content_condition: "day_of_week = 1"
- audio_language: ["eng"]
disable_subtitles: true
""";
var fileSystem = new MockFileSystem();
fileSystem.Initialize()
.WithFile(TestFileName).Which(f => f.HasStringContent(YAML));
var streamSelector = new CustomStreamSelector(fileSystem, _logger);
var tz = TZConvert.GetTimeZoneInfo("America/Chicago");
var start = new DateTime(2026, 1, 11, 0, 0, 0, DateTimeKind.Unspecified); // sunday
var dto = new DateTimeOffset(start, tz.GetUtcOffset(start));
StreamSelectorResult result = await streamSelector.SelectStreams(_channel, dto, _audioVersion, _subtitles);
result.AudioStream.IsSome.ShouldBeTrue();
foreach (MediaStream audioStream in result.AudioStream)
{
audioStream.Index.ShouldBe(1);
audioStream.Language.ShouldBe("eng");
}
result.Subtitle.IsSome.ShouldBeFalse();
}
[Test]
public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Content_Condition_Match()
{
const string YAML =
"""
---
items:
- audio_language: ["ja"]
subtitle_language: ["eng"]
content_condition: "day_of_week = 0"
- audio_language: ["eng"]
disable_subtitles: true
""";
var fileSystem = new MockFileSystem();
fileSystem.Initialize()
.WithFile(TestFileName).Which(f => f.HasStringContent(YAML));
var streamSelector = new CustomStreamSelector(fileSystem, _logger);
var tz = TZConvert.GetTimeZoneInfo("America/Chicago");
var start = new DateTime(2026, 1, 11, 0, 0, 0, DateTimeKind.Unspecified); // sunday
var dto = new DateTimeOffset(start, tz.GetUtcOffset(start));
StreamSelectorResult result = await streamSelector.SelectStreams(_channel, dto, _audioVersion, _subtitles);
result.AudioStream.IsSome.ShouldBeTrue();
foreach (MediaStream audioStream in result.AudioStream)
{
audioStream.Index.ShouldBe(0);
audioStream.Language.ShouldBe("ja");
}
result.Subtitle.IsSome.ShouldBeTrue();
foreach (Subtitle subtitle in result.Subtitle)
{
subtitle.Id.ShouldBe(2);
subtitle.Language.ShouldBe("eng");
}
}
[Test]
public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Time_Of_Day_Content_Condition_Fail_Before()
{
// saturday from 9pm-11pm
const string YAML =
"""
---
items:
- audio_language: ["ja"]
subtitle_language: ["eng"]
content_condition: "day_of_week = 6 and (time_of_day_seconds >= 75600 and time_of_day_seconds < 82800)"
- audio_language: ["eng"]
disable_subtitles: true
""";
var fileSystem = new MockFileSystem();
fileSystem.Initialize()
.WithFile(TestFileName).Which(f => f.HasStringContent(YAML));
var streamSelector = new CustomStreamSelector(fileSystem, _logger);
var tz = TZConvert.GetTimeZoneInfo("America/Chicago");
var start = new DateTime(2026, 1, 10, 20, 59, 59, DateTimeKind.Unspecified); // saturday at 8:59:59pm
var dto = new DateTimeOffset(start, tz.GetUtcOffset(start));
StreamSelectorResult result = await streamSelector.SelectStreams(_channel, dto, _audioVersion, _subtitles);
result.AudioStream.IsSome.ShouldBeTrue();
foreach (MediaStream audioStream in result.AudioStream)
{
audioStream.Index.ShouldBe(1);
audioStream.Language.ShouldBe("eng");
}
result.Subtitle.IsSome.ShouldBeFalse();
}
[Test]
public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Time_Of_Day_Content_Condition_Fail_After()
{
// saturday from 9pm-11pm
const string YAML =
"""
---
items:
- audio_language: ["ja"]
subtitle_language: ["eng"]
content_condition: "day_of_week = 6 and (time_of_day_seconds >= 75600 and time_of_day_seconds < 82800)"
- audio_language: ["eng"]
disable_subtitles: true
""";
var fileSystem = new MockFileSystem();
fileSystem.Initialize()
.WithFile(TestFileName).Which(f => f.HasStringContent(YAML));
var streamSelector = new CustomStreamSelector(fileSystem, _logger);
var tz = TZConvert.GetTimeZoneInfo("America/Chicago");
var start = new DateTime(2026, 1, 10, 23, 0, 0, DateTimeKind.Unspecified); // saturday at 11:00pm
var dto = new DateTimeOffset(start, tz.GetUtcOffset(start));
StreamSelectorResult result = await streamSelector.SelectStreams(_channel, dto, _audioVersion, _subtitles);
result.AudioStream.IsSome.ShouldBeTrue();
foreach (MediaStream audioStream in result.AudioStream)
{
audioStream.Index.ShouldBe(1);
audioStream.Language.ShouldBe("eng");
}
result.Subtitle.IsSome.ShouldBeFalse();
}
[Test]
public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Time_Of_Day_Content_Condition_Fail_Wrong_Day()
{
// saturday from 9pm-11pm
const string YAML =
"""
---
items:
- audio_language: ["ja"]
subtitle_language: ["eng"]
content_condition: "day_of_week = 6 and (time_of_day_seconds >= 75600 and time_of_day_seconds < 82800)"
- audio_language: ["eng"]
disable_subtitles: true
""";
var fileSystem = new MockFileSystem();
fileSystem.Initialize()
.WithFile(TestFileName).Which(f => f.HasStringContent(YAML));
var streamSelector = new CustomStreamSelector(fileSystem, _logger);
var tz = TZConvert.GetTimeZoneInfo("America/Chicago");
var start = new DateTime(2026, 1, 11, 22, 0, 0, DateTimeKind.Unspecified); // sunday at 10:00pm
var dto = new DateTimeOffset(start, tz.GetUtcOffset(start));
StreamSelectorResult result = await streamSelector.SelectStreams(_channel, dto, _audioVersion, _subtitles);
result.AudioStream.IsSome.ShouldBeTrue();
foreach (MediaStream audioStream in result.AudioStream)
{
audioStream.Index.ShouldBe(1);
audioStream.Language.ShouldBe("eng");
}
result.Subtitle.IsSome.ShouldBeFalse();
}
[Test]
public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Time_Of_Day_Content_Condition_Match()
{
// saturday from 9pm-11pm
const string YAML =
"""
---
items:
- audio_language: ["ja"]
subtitle_language: ["eng"]
content_condition: "day_of_week = 6 and (time_of_day_seconds >= 75600 and time_of_day_seconds < 82800)"
- audio_language: ["eng"]
disable_subtitles: true
""";
var fileSystem = new MockFileSystem();
fileSystem.Initialize()
.WithFile(TestFileName).Which(f => f.HasStringContent(YAML));
var streamSelector = new CustomStreamSelector(fileSystem, _logger);
var tz = TZConvert.GetTimeZoneInfo("America/Chicago");
var start = new DateTime(2026, 1, 10, 22, 0, 0, DateTimeKind.Unspecified); // saturday at 10:00pm
var dto = new DateTimeOffset(start, tz.GetUtcOffset(start));
StreamSelectorResult result = await streamSelector.SelectStreams(_channel, dto, _audioVersion, _subtitles);
result.AudioStream.IsSome.ShouldBeTrue();
foreach (MediaStream audioStream in result.AudioStream)
{
audioStream.Index.ShouldBe(0);
audioStream.Language.ShouldBe("ja");
}
result.Subtitle.IsSome.ShouldBeTrue();
foreach (Subtitle subtitle in result.Subtitle)
{
subtitle.Id.ShouldBe(2);
subtitle.Language.ShouldBe("eng");
}
}
[Test]
[SetCulture("fr-FR")]
public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Time_Of_Day_Content_Condition_Match_France()
{
// saturday from 9pm-11pm
const string YAML =
"""
---
items:
- audio_language: ["ja"]
subtitle_language: ["eng"]
content_condition: "day_of_week = 5 and (time_of_day_seconds >= 75600 and time_of_day_seconds < 82800)"
- audio_language: ["eng"]
disable_subtitles: true
""";
var fileSystem = new MockFileSystem();
fileSystem.Initialize()
.WithFile(TestFileName).Which(f => f.HasStringContent(YAML));
var streamSelector = new CustomStreamSelector(fileSystem, _logger);
var tz = TZConvert.GetTimeZoneInfo("America/Chicago");
var start = new DateTime(2026, 1, 10, 22, 0, 0, DateTimeKind.Unspecified); // saturday at 10:00pm
var dto = new DateTimeOffset(start, tz.GetUtcOffset(start));
StreamSelectorResult result = await streamSelector.SelectStreams(_channel, dto, _audioVersion, _subtitles);
result.AudioStream.IsSome.ShouldBeTrue();
foreach (MediaStream audioStream in result.AudioStream)
{
audioStream.Index.ShouldBe(0);
audioStream.Language.ShouldBe("ja");
}
result.Subtitle.IsSome.ShouldBeTrue();
foreach (Subtitle subtitle in result.Subtitle)
{
subtitle.Id.ShouldBe(2);
subtitle.Language.ShouldBe("eng");
}
}
[Test]
public async Task Should_Ignore_Blocked_Audio_Title()
{
@@ -8,6 +8,7 @@ using NSubstitute;
using NUnit.Framework;
using Serilog;
using Shouldly;
using Testably.Abstractions.Testing;
namespace ErsatzTV.Core.Tests.FFmpeg;
@@ -65,8 +66,18 @@ public class WatermarkSelectorTests
var loggerFactory = new LoggerFactory().AddSerilog(Log.Logger);
// watermarks should always exist; effectively ignoring filesystem checks for now
var mockFileSystem = new MockFileSystem();
mockFileSystem.Initialize()
.WithFile("/tmp/watermark");
var fakeImageCache = Substitute.For<IImageCache>();
fakeImageCache.GetPathForImage(Arg.Any<string>(), Arg.Is(ArtworkKind.Watermark), Arg.Any<Option<int>>())
.Returns(_ => "/tmp/watermark");
WatermarkSelector = new WatermarkSelector(
Substitute.For<IImageCache>(),
mockFileSystem,
fakeImageCache,
new DecoSelector(loggerFactory.CreateLogger<DecoSelector>()),
loggerFactory.CreateLogger<WatermarkSelector>());
@@ -76,6 +76,8 @@ public class FakeMediaCollectionRepository : IMediaCollectionRepository
public Task<bool> IsCustomPlaybackOrder(int collectionId) => false.AsTask();
public Task<Option<string>> GetNameFromKey(CollectionKey emptyCollection, CancellationToken cancellationToken) => Option<string>.None.AsTask();
public List<CollectionWithItems> GroupIntoFakeCollections(List<MediaItem> items, string fakeKey = null) =>
throw new NotSupportedException();
public List<CollectionWithItems> GroupIntoFakeCollections(List<MediaItem> items, string fakeKey = null)
{
return [new CollectionWithItems(1, 0, fakeKey, items, true, PlaybackOrder.Shuffle, false)];
}
}
@@ -666,7 +666,7 @@ public class ContinuePlayoutTests : PlayoutBuilderTestBase
Collection = collectionOne,
CollectionId = collectionOne.Id,
StartTime = null,
Count = 3,
Count = "3",
PlaybackOrder = PlaybackOrder.Chronological
},
new ProgramScheduleItemMultiple
@@ -676,7 +676,7 @@ public class ContinuePlayoutTests : PlayoutBuilderTestBase
Collection = collectionTwo,
CollectionId = collectionTwo.Id,
StartTime = null,
Count = 3,
Count = "3",
PlaybackOrder = PlaybackOrder.Chronological
}
};
@@ -15,6 +15,103 @@ namespace ErsatzTV.Core.Tests.Scheduling.ClassicScheduling;
[TestFixture]
public class NewPlayoutTests : PlayoutBuilderTestBase
{
[Test]
public async Task FillWithGroupMode_Should_Not_Fail()
{
var collection = new Collection
{
Id = 1,
Name = "Multiple Items",
MediaItems =
[
TestMovie(1, TimeSpan.FromHours(1), new DateTime(2020, 1, 1)),
TestMovie(2, TimeSpan.FromHours(1), new DateTime(2020, 2, 1))
]
};
var fakeRepository = new FakeMediaCollectionRepository(
Map((collection.Id, collection.MediaItems.ToList())));
var items = new List<ProgramScheduleItem>
{
new ProgramScheduleItemMultiple
{
Id = 1,
Index = 1,
//Collection = collection,
CollectionId = collection.Id,
StartTime = null,
PlaybackOrder = PlaybackOrder.Chronological,
MultipleMode = MultipleMode.Count,
Count = "2",
FillWithGroupMode = FillWithGroupMode.FillWithShuffledGroups,
ProgramScheduleItemGraphicsElements = []
}
};
// having a graphics element reference the schedule item triggers the bug
items[0].ProgramScheduleItemGraphicsElements.Add(new ProgramScheduleItemGraphicsElement
{
GraphicsElementId = 1,
ProgramScheduleItem = items[0],
ProgramScheduleItemId = items[0].Id
});
var playout = new Playout
{
ProgramSchedule = new ProgramSchedule
{
Items = items
},
Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" },
ProgramScheduleAnchors = [],
Items = [],
ProgramScheduleAlternates = [],
FillGroupIndices = []
};
var referenceData =
new PlayoutReferenceData(
playout.Channel,
Option<Deco>.None,
[],
[],
playout.ProgramSchedule,
[],
[],
TimeSpan.Zero);
IConfigElementRepository configRepo = Substitute.For<IConfigElementRepository>();
var televisionRepo = new FakeTelevisionRepository();
IArtistRepository artistRepo = Substitute.For<IArtistRepository>();
IMultiEpisodeShuffleCollectionEnumeratorFactory factory =
Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>();
IRerunHelper rerunHelper = Substitute.For<IRerunHelper>();
var builder = new PlayoutBuilder(
configRepo,
fakeRepository,
televisionRepo,
artistRepo,
factory,
new MockFileSystem(),
rerunHelper,
Logger);
DateTimeOffset start = HoursAfterMidnight(0);
DateTimeOffset finish = start + TimeSpan.FromHours(6);
Either<BaseError, PlayoutBuildResult> buildResult = await builder.Build(
playout,
referenceData,
PlayoutBuildResult.Empty,
PlayoutBuildMode.Reset,
start,
finish,
CancellationToken);
buildResult.IsRight.ShouldBeTrue();
}
[Test]
public async Task OnlyZeroDurationItem_Should_Abort()
{
@@ -852,7 +949,7 @@ public class NewPlayoutTests : PlayoutBuilderTestBase
Collection = fixedCollection,
CollectionId = fixedCollection.Id,
StartTime = TimeSpan.FromHours(3),
Count = 2,
Count = "2",
PlaybackOrder = PlaybackOrder.Chronological
}
};
@@ -981,7 +1078,7 @@ public class NewPlayoutTests : PlayoutBuilderTestBase
Collection = fixedCollection,
CollectionId = fixedCollection.Id,
StartTime = TimeSpan.FromHours(3),
Count = 2,
Count = "2",
PlaybackOrder = PlaybackOrder.Chronological
}
};
@@ -1361,7 +1458,7 @@ public class NewPlayoutTests : PlayoutBuilderTestBase
Collection = multipleCollection,
CollectionId = multipleCollection.Id,
StartTime = null,
Count = 2,
Count = "2",
PlaybackOrder = PlaybackOrder.Chronological
},
new ProgramScheduleItemDuration
@@ -1494,7 +1591,7 @@ public class NewPlayoutTests : PlayoutBuilderTestBase
Collection = collectionOne,
CollectionId = collectionOne.Id,
StartTime = null,
Count = 0,
Count = "0",
MultipleMode = MultipleMode.CollectionSize,
PlaybackOrder = PlaybackOrder.Chronological
},
@@ -1505,7 +1602,7 @@ public class NewPlayoutTests : PlayoutBuilderTestBase
Collection = collectionTwo,
CollectionId = collectionTwo.Id,
StartTime = null,
Count = 0,
Count = "0",
MultipleMode = MultipleMode.CollectionSize,
PlaybackOrder = PlaybackOrder.Chronological
}
@@ -1777,7 +1874,7 @@ public class NewPlayoutTests : PlayoutBuilderTestBase
Collection = collectionOne,
CollectionId = collectionOne.Id,
StartTime = null,
Count = 1,
Count = "1",
PlaybackOrder = PlaybackOrder.Chronological,
PostRollFiller = new FillerPreset
{
@@ -0,0 +1,34 @@
using ErsatzTV.Core.Interfaces.Scheduling;
using ErsatzTV.Core.Scheduling;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Core.Tests.Scheduling;
[TestFixture]
public class CountExpressionTests
{
[Test]
[TestCase("2", 2)]
[TestCase("count", 10)]
[TestCase("count / 2", 5)]
[TestCase("count * 2", 20)]
[TestCase("count + 1", 11)]
[TestCase("count - 1", 9)]
[TestCase("random % 4 + 1", 3)]
[TestCase("invalid", 0)]
[TestCase("count / 0", 0)]
public void Should_Evaluate_Expression(string expression, int expected)
{
var enumerator = Substitute.For<IMediaCollectionEnumerator>();
enumerator.Count.Returns(10);
var random = Substitute.For<Random>();
random.Next().Returns(2);
int result = CountExpression.Evaluate(expression, enumerator, random, CancellationToken.None);
result.ShouldBe(expected);
}
}
@@ -75,4 +75,106 @@ public class FillerExpressionTests
result[1].EndTime.ShouldBe(TimeSpan.FromMinutes(20));
result[2].EndTime.ShouldBe(TimeSpan.FromMinutes(30));
}
[Test]
public void Match_Case_Insensitive_Titles_Expression()
{
// 30 min content
var playoutItem = new PlayoutItem { Start = DateTimeOffset.Now.UtcDateTime };
playoutItem.Finish = playoutItem.Start + TimeSpan.FromMinutes(30);
// chapters every 5 min
var chapters = new List<MediaChapter>
{
new() { ChapterId = 1, StartTime = TimeSpan.Zero, EndTime = TimeSpan.FromMinutes(5), Title = "Not Here" },
new() { ChapterId = 2, StartTime = TimeSpan.FromMinutes(5), EndTime = TimeSpan.FromMinutes(10), Title = "Here" },
new() { ChapterId = 3, StartTime = TimeSpan.FromMinutes(10), EndTime = TimeSpan.FromMinutes(15), Title = "Not Here" },
new() { ChapterId = 4, StartTime = TimeSpan.FromMinutes(15), EndTime = TimeSpan.FromMinutes(20), Title = "Here" },
new() { ChapterId = 5, StartTime = TimeSpan.FromMinutes(20), EndTime = TimeSpan.FromMinutes(25), Title = "Not Here" },
new() { ChapterId = 6, StartTime = TimeSpan.FromMinutes(25), EndTime = TimeSpan.FromMinutes(30), Title = "Here" }
};
var fillerPreset = new FillerPreset
{
FillerKind = FillerKind.MidRoll,
Expression =
"title == 'here'"
};
List<MediaChapter> result = FillerExpression.FilterChapters(fillerPreset.Expression, chapters, playoutItem);
result.Count.ShouldBe(3);
result[0].EndTime.ShouldBe(TimeSpan.FromMinutes(10));
result[1].EndTime.ShouldBe(TimeSpan.FromMinutes(20));
result[2].EndTime.ShouldBe(TimeSpan.FromMinutes(30));
}
[Test]
public void Exclude_Case_Insensitive_Titles_Expression()
{
// 30 min content
var playoutItem = new PlayoutItem { Start = DateTimeOffset.Now.UtcDateTime };
playoutItem.Finish = playoutItem.Start + TimeSpan.FromMinutes(30);
// chapters every 5 min
var chapters = new List<MediaChapter>
{
new() { ChapterId = 1, StartTime = TimeSpan.Zero, EndTime = TimeSpan.FromMinutes(5), Title = "Not Here" },
new() { ChapterId = 2, StartTime = TimeSpan.FromMinutes(5), EndTime = TimeSpan.FromMinutes(10), Title = "Here" },
new() { ChapterId = 3, StartTime = TimeSpan.FromMinutes(10), EndTime = TimeSpan.FromMinutes(15), Title = "Not Here" },
new() { ChapterId = 4, StartTime = TimeSpan.FromMinutes(15), EndTime = TimeSpan.FromMinutes(20), Title = "Here" },
new() { ChapterId = 5, StartTime = TimeSpan.FromMinutes(20), EndTime = TimeSpan.FromMinutes(25), Title = "Not Here" },
new() { ChapterId = 6, StartTime = TimeSpan.FromMinutes(25), EndTime = TimeSpan.FromMinutes(30), Title = "Here" }
};
var fillerPreset = new FillerPreset
{
FillerKind = FillerKind.MidRoll,
Expression =
"title != 'not here'"
};
List<MediaChapter> result = FillerExpression.FilterChapters(fillerPreset.Expression, chapters, playoutItem);
result.Count.ShouldBe(3);
result[0].EndTime.ShouldBe(TimeSpan.FromMinutes(10));
result[1].EndTime.ShouldBe(TimeSpan.FromMinutes(20));
result[2].EndTime.ShouldBe(TimeSpan.FromMinutes(30));
}
[Test]
public void Include_Partial_Case_Insensitive_Titles_Expression()
{
// 30 min content
var playoutItem = new PlayoutItem { Start = DateTimeOffset.Now.UtcDateTime };
playoutItem.Finish = playoutItem.Start + TimeSpan.FromMinutes(30);
// chapters every 5 min
var chapters = new List<MediaChapter>
{
new() { ChapterId = 1, StartTime = TimeSpan.Zero, EndTime = TimeSpan.FromMinutes(5), Title = "Not Here" },
new() { ChapterId = 2, StartTime = TimeSpan.FromMinutes(5), EndTime = TimeSpan.FromMinutes(10), Title = "Here" },
new() { ChapterId = 3, StartTime = TimeSpan.FromMinutes(10), EndTime = TimeSpan.FromMinutes(15), Title = "Not Here" },
new() { ChapterId = 4, StartTime = TimeSpan.FromMinutes(15), EndTime = TimeSpan.FromMinutes(20), Title = "Here" },
new() { ChapterId = 5, StartTime = TimeSpan.FromMinutes(20), EndTime = TimeSpan.FromMinutes(25), Title = "Not Here" },
new() { ChapterId = 6, StartTime = TimeSpan.FromMinutes(25), EndTime = TimeSpan.FromMinutes(30), Title = "Here" }
};
var fillerPreset = new FillerPreset
{
FillerKind = FillerKind.MidRoll,
Expression =
"title like \"%here%\""
};
List<MediaChapter> result = FillerExpression.FilterChapters(fillerPreset.Expression, chapters, playoutItem);
result.Count.ShouldBe(6);
result[0].EndTime.ShouldBe(TimeSpan.FromMinutes(5));
result[1].EndTime.ShouldBe(TimeSpan.FromMinutes(10));
result[2].EndTime.ShouldBe(TimeSpan.FromMinutes(15));
result[3].EndTime.ShouldBe(TimeSpan.FromMinutes(20));
result[4].EndTime.ShouldBe(TimeSpan.FromMinutes(25));
result[5].EndTime.ShouldBe(TimeSpan.FromMinutes(30));
}
}
@@ -796,6 +796,7 @@ public class PlayoutModeSchedulerBaseTests : SchedulerTestBase
ProgramScheduleItem scheduleItem,
ProgramScheduleItem nextScheduleItem,
DateTimeOffset hardStop,
Random random,
CancellationToken cancellationToken) =>
throw new NotSupportedException();
}
@@ -13,9 +13,14 @@ namespace ErsatzTV.Core.Tests.Scheduling;
public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
{
[SetUp]
public void SetUp() => _cancellationToken = new CancellationTokenSource(TimeSpan.FromSeconds(10)).Token;
public void SetUp()
{
_cancellationToken = new CancellationTokenSource(TimeSpan.FromSeconds(10)).Token;
_random = new Random();
}
private CancellationToken _cancellationToken;
private Random _random;
private readonly ILogger<PlayoutModeSchedulerDuration> _logger;
public PlayoutModeSchedulerDurationTests()
@@ -66,6 +71,7 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
scheduleItem,
NextScheduleItem,
HardStop(scheduleItemsEnumerator),
_random,
_cancellationToken);
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(3));
@@ -139,6 +145,7 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
scheduleItem,
NextScheduleItem,
HardStop(scheduleItemsEnumerator),
_random,
_cancellationToken);
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(3));
@@ -211,6 +218,7 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
scheduleItem,
NextScheduleItem,
HardStop(scheduleItemsEnumerator),
_random,
_cancellationToken);
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.Add(new TimeSpan(2, 45, 0)));
@@ -280,6 +288,7 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
scheduleItem,
NextScheduleItem,
HardStop(scheduleItemsEnumerator),
_random,
_cancellationToken);
// duration block should end after exact duration, with gap
@@ -363,6 +372,7 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
scheduleItem,
NextScheduleItem,
HardStop(scheduleItemsEnumerator),
_random,
_cancellationToken);
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(3));
@@ -450,6 +460,7 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
scheduleItem,
NextScheduleItem,
HardStop(scheduleItemsEnumerator),
_random,
_cancellationToken);
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(3));
@@ -549,6 +560,7 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
scheduleItem,
NextScheduleItem,
HardStop(scheduleItemsEnumerator),
_random,
_cancellationToken);
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(3));
@@ -665,6 +677,7 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
scheduleItem,
NextScheduleItem,
HardStop(scheduleItemsEnumerator),
_random,
_cancellationToken);
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(3));
@@ -816,6 +829,7 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
scheduleItem,
NextScheduleItem,
HardStop(scheduleItemsEnumerator),
_random,
_cancellationToken);
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddMinutes(30));
@@ -880,6 +894,7 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
scheduleItem,
NextScheduleItem,
HardStop(scheduleItemsEnumerator),
_random,
_cancellationToken);
playoutItems.ShouldBeEmpty();
@@ -12,9 +12,14 @@ namespace ErsatzTV.Core.Tests.Scheduling;
public class PlayoutModeSchedulerFloodTests : SchedulerTestBase
{
[SetUp]
public void SetUp() => _cancellationToken = new CancellationTokenSource(TimeSpan.FromSeconds(10)).Token;
public void SetUp()
{
_cancellationToken = new CancellationTokenSource(TimeSpan.FromSeconds(10)).Token;
_random = new Random();
}
private CancellationToken _cancellationToken;
private Random _random;
[Test]
public void Should_Fill_Exactly_To_Next_Schedule_Item()
@@ -57,6 +62,7 @@ public class PlayoutModeSchedulerFloodTests : SchedulerTestBase
scheduleItem,
NextScheduleItem,
HardStop(scheduleItemsEnumerator),
_random,
_cancellationToken);
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(3));
@@ -132,6 +138,7 @@ public class PlayoutModeSchedulerFloodTests : SchedulerTestBase
scheduleItem,
scheduleItem,
HardStop(scheduleItemsEnumerator),
_random,
_cancellationToken);
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(6));
@@ -229,6 +236,7 @@ public class PlayoutModeSchedulerFloodTests : SchedulerTestBase
scheduleItem,
NextScheduleItem,
HardStop(scheduleItemsEnumerator),
_random,
_cancellationToken);
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(3));
@@ -314,6 +322,7 @@ public class PlayoutModeSchedulerFloodTests : SchedulerTestBase
scheduleItem,
NextScheduleItem,
HardStop(scheduleItemsEnumerator),
_random,
_cancellationToken);
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(3));
@@ -402,6 +411,7 @@ public class PlayoutModeSchedulerFloodTests : SchedulerTestBase
scheduleItem,
NextScheduleItem,
HardStop(scheduleItemsEnumerator),
_random,
_cancellationToken);
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.Add(new TimeSpan(2, 45, 0)));
@@ -484,6 +494,7 @@ public class PlayoutModeSchedulerFloodTests : SchedulerTestBase
scheduleItem,
NextScheduleItem,
HardStop(scheduleItemsEnumerator),
_random,
_cancellationToken);
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(3));
@@ -582,6 +593,7 @@ public class PlayoutModeSchedulerFloodTests : SchedulerTestBase
scheduleItem,
NextScheduleItem,
HardStop(scheduleItemsEnumerator),
_random,
_cancellationToken);
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(3));
@@ -670,6 +682,7 @@ public class PlayoutModeSchedulerFloodTests : SchedulerTestBase
scheduleItem,
NextScheduleItem,
HardStop(scheduleItemsEnumerator),
_random,
_cancellationToken);
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.Add(new TimeSpan(2, 57, 0)));
@@ -784,6 +797,7 @@ public class PlayoutModeSchedulerFloodTests : SchedulerTestBase
scheduleItem,
NextScheduleItem,
HardStop(scheduleItemsEnumerator),
_random,
_cancellationToken);
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(3));
@@ -896,6 +910,7 @@ public class PlayoutModeSchedulerFloodTests : SchedulerTestBase
scheduleItem,
NextScheduleItem,
hardStop,
_random,
_cancellationToken);
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(2));
@@ -1002,6 +1017,7 @@ public class PlayoutModeSchedulerFloodTests : SchedulerTestBase
scheduleItem,
NextScheduleItem,
hardStop,
_random,
_cancellationToken);
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(2));
@@ -1116,6 +1132,7 @@ public class PlayoutModeSchedulerFloodTests : SchedulerTestBase
scheduleItem,
NextScheduleItem,
HardStop(scheduleItemsEnumerator),
_random,
_cancellationToken);
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(3));
@@ -1190,6 +1207,7 @@ public class PlayoutModeSchedulerFloodTests : SchedulerTestBase
scheduleItem,
NextScheduleItem,
HardStop(scheduleItemsEnumerator),
_random,
_cancellationToken);
playoutItems.ShouldBeEmpty();
@@ -12,9 +12,14 @@ namespace ErsatzTV.Core.Tests.Scheduling;
public class PlayoutModeSchedulerMultipleTests : SchedulerTestBase
{
[SetUp]
public void SetUp() => _cancellationToken = new CancellationTokenSource(TimeSpan.FromSeconds(10)).Token;
public void SetUp()
{
_cancellationToken = new CancellationTokenSource(TimeSpan.FromSeconds(10)).Token;
_random = new Random();
}
private CancellationToken _cancellationToken;
private Random _random;
[Test]
public void Should_Respect_Fixed_Start_Time()
@@ -32,7 +37,7 @@ public class PlayoutModeSchedulerMultipleTests : SchedulerTestBase
PlaybackOrder = PlaybackOrder.Chronological,
TailFiller = null,
FallbackFiller = null,
Count = 0,
Count = "0",
MultipleMode = MultipleMode.CollectionSize,
CustomTitle = "CustomTitle"
};
@@ -59,6 +64,7 @@ public class PlayoutModeSchedulerMultipleTests : SchedulerTestBase
scheduleItem,
NextScheduleItem,
HardStop(scheduleItemsEnumerator),
_random,
_cancellationToken);
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(3));
@@ -134,7 +140,7 @@ public class PlayoutModeSchedulerMultipleTests : SchedulerTestBase
PlaybackOrder = PlaybackOrder.Chronological,
TailFiller = null,
FallbackFiller = null,
Count = 0,
Count = "0",
MultipleMode = MultipleMode.MultiEpisodeGroupSize,
CustomTitle = "CustomTitle"
};
@@ -161,6 +167,7 @@ public class PlayoutModeSchedulerMultipleTests : SchedulerTestBase
scheduleItem,
NextScheduleItem,
HardStop(scheduleItemsEnumerator),
_random,
_cancellationToken);
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(3));
@@ -206,7 +213,7 @@ public class PlayoutModeSchedulerMultipleTests : SchedulerTestBase
PlaybackOrder = PlaybackOrder.Chronological,
TailFiller = null,
FallbackFiller = null,
Count = 3,
Count = "3",
CustomTitle = "CustomTitle"
};
@@ -232,6 +239,7 @@ public class PlayoutModeSchedulerMultipleTests : SchedulerTestBase
scheduleItem,
NextScheduleItem,
HardStop(scheduleItemsEnumerator),
_random,
_cancellationToken);
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(3));
@@ -282,7 +290,7 @@ public class PlayoutModeSchedulerMultipleTests : SchedulerTestBase
PlaybackOrder = PlaybackOrder.Chronological,
TailFiller = null,
FallbackFiller = null,
Count = 3
Count = "3"
};
var scheduleItemsEnumerator = new OrderedScheduleItemsEnumerator(
@@ -307,6 +315,7 @@ public class PlayoutModeSchedulerMultipleTests : SchedulerTestBase
scheduleItem,
NextScheduleItem,
HardStop(scheduleItemsEnumerator),
_random,
_cancellationToken);
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.Add(new TimeSpan(2, 45, 0)));
@@ -360,7 +369,7 @@ public class PlayoutModeSchedulerMultipleTests : SchedulerTestBase
CollectionId = collectionTwo.Id
},
FallbackFiller = null,
Count = 3
Count = "3"
};
var scheduleItemsEnumerator = new OrderedScheduleItemsEnumerator(
@@ -390,6 +399,7 @@ public class PlayoutModeSchedulerMultipleTests : SchedulerTestBase
scheduleItem,
NextScheduleItem,
HardStop(scheduleItemsEnumerator),
_random,
_cancellationToken);
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(3));
@@ -459,7 +469,7 @@ public class PlayoutModeSchedulerMultipleTests : SchedulerTestBase
Collection = collectionTwo,
CollectionId = collectionTwo.Id
},
Count = 3
Count = "3"
};
var scheduleItemsEnumerator = new OrderedScheduleItemsEnumerator(
@@ -489,6 +499,7 @@ public class PlayoutModeSchedulerMultipleTests : SchedulerTestBase
scheduleItem,
NextScheduleItem,
HardStop(scheduleItemsEnumerator),
_random,
_cancellationToken);
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(3));
@@ -548,7 +559,7 @@ public class PlayoutModeSchedulerMultipleTests : SchedulerTestBase
CollectionId = collectionTwo.Id
},
FallbackFiller = null,
Count = 3
Count = "3"
};
var scheduleItemsEnumerator = new OrderedScheduleItemsEnumerator(
@@ -578,6 +589,7 @@ public class PlayoutModeSchedulerMultipleTests : SchedulerTestBase
scheduleItem,
NextScheduleItem,
HardStop(scheduleItemsEnumerator),
_random,
_cancellationToken);
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.Add(new TimeSpan(2, 57, 0)));
@@ -653,7 +665,7 @@ public class PlayoutModeSchedulerMultipleTests : SchedulerTestBase
Collection = collectionThree,
CollectionId = collectionThree.Id
},
Count = 3
Count = "3"
};
var scheduleItemsEnumerator = new OrderedScheduleItemsEnumerator(
@@ -694,6 +706,7 @@ public class PlayoutModeSchedulerMultipleTests : SchedulerTestBase
scheduleItem,
NextScheduleItem,
HardStop(scheduleItemsEnumerator),
_random,
_cancellationToken);
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(3));
@@ -775,7 +788,7 @@ public class PlayoutModeSchedulerMultipleTests : SchedulerTestBase
Collection = collectionThree,
CollectionId = collectionThree.Id
},
Count = 3
Count = "3"
};
var scheduleItemsEnumerator = new OrderedScheduleItemsEnumerator(
@@ -816,6 +829,7 @@ public class PlayoutModeSchedulerMultipleTests : SchedulerTestBase
scheduleItem,
NextScheduleItem,
HardStop(scheduleItemsEnumerator),
_random,
_cancellationToken);
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(3));
@@ -865,7 +879,7 @@ public class PlayoutModeSchedulerMultipleTests : SchedulerTestBase
PlaybackOrder = PlaybackOrder.Chronological,
TailFiller = null,
FallbackFiller = null,
Count = 2
Count = "2"
};
var enumerator = new ChronologicalMediaCollectionEnumerator(
@@ -896,6 +910,7 @@ public class PlayoutModeSchedulerMultipleTests : SchedulerTestBase
scheduleItem,
NextScheduleItem,
HardStop(scheduleItemsEnumerator),
_random,
_cancellationToken);
playoutItems.ShouldBeEmpty();
@@ -12,9 +12,14 @@ namespace ErsatzTV.Core.Tests.Scheduling;
public class PlayoutModeSchedulerOneTests : SchedulerTestBase
{
[SetUp]
public void SetUp() => _cancellationToken = new CancellationTokenSource(TimeSpan.FromSeconds(10)).Token;
public void SetUp()
{
_cancellationToken = new CancellationTokenSource(TimeSpan.FromSeconds(10)).Token;
_random = new Random();
}
private CancellationToken _cancellationToken;
private Random _random;
[Test]
public void Should_Have_Gap_With_No_Tail_No_Fallback()
@@ -51,6 +56,7 @@ public class PlayoutModeSchedulerOneTests : SchedulerTestBase
scheduleItem,
NextScheduleItem,
HardStop(scheduleItemsEnumerator),
_random,
_cancellationToken);
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(1));
@@ -134,6 +140,7 @@ public class PlayoutModeSchedulerOneTests : SchedulerTestBase
scheduleItem,
NextScheduleItem,
HardStop(scheduleItemsEnumerator),
_random,
_cancellationToken);
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(1));
@@ -202,6 +209,7 @@ public class PlayoutModeSchedulerOneTests : SchedulerTestBase
scheduleItem,
NextScheduleItem,
HardStop(scheduleItemsEnumerator),
_random,
_cancellationToken);
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(3));
@@ -284,6 +292,7 @@ public class PlayoutModeSchedulerOneTests : SchedulerTestBase
scheduleItem,
NextScheduleItem,
HardStop(scheduleItemsEnumerator),
_random,
_cancellationToken);
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(3));
@@ -356,6 +365,7 @@ public class PlayoutModeSchedulerOneTests : SchedulerTestBase
scheduleItem,
NextScheduleItem,
HardStop(scheduleItemsEnumerator),
_random,
_cancellationToken);
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.Add(new TimeSpan(2, 57, 0)));
@@ -454,6 +464,7 @@ public class PlayoutModeSchedulerOneTests : SchedulerTestBase
scheduleItem,
NextScheduleItem,
HardStop(scheduleItemsEnumerator),
_random,
_cancellationToken);
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(3));
@@ -558,6 +569,7 @@ public class PlayoutModeSchedulerOneTests : SchedulerTestBase
scheduleItem,
NextScheduleItem,
HardStop(scheduleItemsEnumerator),
_random,
_cancellationToken);
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(3));
@@ -644,6 +656,7 @@ public class PlayoutModeSchedulerOneTests : SchedulerTestBase
scheduleItem,
NextScheduleItem,
HardStop(scheduleItemsEnumerator),
_random,
_cancellationToken);
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(3));
@@ -744,6 +757,7 @@ public class PlayoutModeSchedulerOneTests : SchedulerTestBase
scheduleItem,
NextScheduleItem,
HardStop(scheduleItemsEnumerator),
_random,
_cancellationToken);
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(3));
@@ -823,6 +837,7 @@ public class PlayoutModeSchedulerOneTests : SchedulerTestBase
scheduleItem,
NextScheduleItem,
HardStop(scheduleItemsEnumerator),
_random,
_cancellationToken);
playoutItems.ShouldBeEmpty();
+10 -1
View File
@@ -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
};
}
+7
View File
@@ -0,0 +1,7 @@
namespace ErsatzTV.Core.Domain;
public enum FilterMode
{
HardwareIfPossible = 0,
Software = 1
}
+2 -1
View File
@@ -6,5 +6,6 @@ public enum MarathonGroupBy
Show = 1,
Season = 2,
Artist = 3,
Album = 4
Album = 4,
Director = 5
}
+1
View File
@@ -12,6 +12,7 @@ public class Artwork
public ArtworkKind ArtworkKind { get; set; }
public DateTime DateAdded { get; set; }
public DateTime DateUpdated { get; set; }
public bool? IsMetadataOrphan { get; set; }
public bool IsExternalUrl() => IsExternalUrl(Path);
@@ -4,5 +4,5 @@ public class ProgramScheduleItemMultiple : ProgramScheduleItem
{
public MultipleMode MultipleMode { get; set; }
public int Count { get; set; }
public string Count { get; set; }
}
+6 -6
View File
@@ -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);
}
}
+10 -1
View File
@@ -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))
+75 -20
View File
@@ -1,3 +1,4 @@
using System.IO.Abstractions;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Domain.Scheduling;
@@ -8,7 +9,11 @@ using Microsoft.Extensions.Logging;
namespace ErsatzTV.Core.FFmpeg;
public class WatermarkSelector(IImageCache imageCache, IDecoSelector decoSelector, ILogger<WatermarkSelector> logger)
public class WatermarkSelector(
IFileSystem fileSystem,
IImageCache imageCache,
IDecoSelector decoSelector,
ILogger<WatermarkSelector> logger)
: IWatermarkSelector
{
public List<WatermarkOptions> SelectWatermarks(
@@ -170,10 +175,18 @@ public class WatermarkSelector(IImageCache imageCache, IDecoSelector decoSelecto
{
// used for song progress overlay
case ChannelWatermarkImageSource.Resource:
return new WatermarkOptions(
watermark,
Path.Combine(FileSystemLayout.ResourcesCacheFolder, watermark.Image),
Option<int>.None);
string resourcePath = fileSystem.Path.Combine(
FileSystemLayout.ResourcesCacheFolder,
watermark.Image);
if (fileSystem.File.Exists(resourcePath))
{
return new WatermarkOptions(watermark, resourcePath, Option<int>.None);
}
logger.LogWarning(
"Watermark resource no longer exists at {Path} and will be ignored",
resourcePath);
return None;
case ChannelWatermarkImageSource.Custom:
// bad form validation makes this possible
if (string.IsNullOrWhiteSpace(watermark.Image))
@@ -190,10 +203,16 @@ public class WatermarkSelector(IImageCache imageCache, IDecoSelector decoSelecto
watermark.Image,
ArtworkKind.Watermark,
Option<int>.None);
return new WatermarkOptions(
watermark,
customPath,
None);
if (fileSystem.File.Exists(customPath))
{
return new WatermarkOptions(watermark, customPath, None);
}
logger.LogWarning(
"Custom watermark no longer exists at {Path} and will be ignored",
customPath);
return None;
case ChannelWatermarkImageSource.ChannelLogo:
logger.LogDebug("Watermark will come from playout item (channel logo)");
@@ -207,7 +226,15 @@ public class WatermarkSelector(IImageCache imageCache, IDecoSelector decoSelecto
: imageCache.GetPathForImage(logoArtwork.Path, ArtworkKind.Logo, Option<int>.None);
}
return new WatermarkOptions(watermark, channelPath, None);
if (fileSystem.File.Exists(channelPath))
{
return new WatermarkOptions(watermark, channelPath, None);
}
logger.LogWarning(
"Channel logo no longer exists at {Path} and will be ignored",
channelPath);
return None;
default:
throw new NotSupportedException("Unsupported watermark image source");
}
@@ -225,10 +252,16 @@ public class WatermarkSelector(IImageCache imageCache, IDecoSelector decoSelecto
channel.Watermark.Image,
ArtworkKind.Watermark,
Option<int>.None);
return new WatermarkOptions(
channel.Watermark,
customPath,
None);
if (fileSystem.File.Exists(customPath))
{
return new WatermarkOptions(channel.Watermark, customPath, None);
}
logger.LogWarning(
"Custom watermark no longer exists at {Path} and will be ignored",
customPath);
return None;
case ChannelWatermarkImageSource.ChannelLogo:
logger.LogDebug("Watermark will come from channel (channel logo)");
@@ -242,7 +275,15 @@ public class WatermarkSelector(IImageCache imageCache, IDecoSelector decoSelecto
: imageCache.GetPathForImage(logoArtwork.Path, ArtworkKind.Logo, Option<int>.None);
}
return new WatermarkOptions(channel.Watermark, channelPath, None);
if (fileSystem.File.Exists(channelPath))
{
return new WatermarkOptions(channel.Watermark, channelPath, None);
}
logger.LogWarning(
"Channel logo no longer exists at {Path} and will be ignored",
channelPath);
return None;
default:
throw new NotSupportedException("Unsupported watermark image source");
}
@@ -260,10 +301,16 @@ public class WatermarkSelector(IImageCache imageCache, IDecoSelector decoSelecto
watermark.Image,
ArtworkKind.Watermark,
Option<int>.None);
return new WatermarkOptions(
watermark,
customPath,
None);
if (fileSystem.File.Exists(customPath))
{
return new WatermarkOptions(watermark, customPath, None);
}
logger.LogWarning(
"Custom watermark no longer exists at {Path} and will be ignored",
customPath);
return None;
case ChannelWatermarkImageSource.ChannelLogo:
logger.LogDebug("Watermark will come from global (channel logo)");
@@ -277,7 +324,15 @@ public class WatermarkSelector(IImageCache imageCache, IDecoSelector decoSelecto
: imageCache.GetPathForImage(logoArtwork.Path, ArtworkKind.Logo, Option<int>.None);
}
return new WatermarkOptions(watermark, channelPath, None);
if (fileSystem.File.Exists(channelPath))
{
return new WatermarkOptions(watermark, channelPath, None);
}
logger.LogWarning(
"Channel logo no longer exists at {Path} and will be ignored",
channelPath);
return None;
default:
throw new NotSupportedException("Unsupported watermark image source");
}
@@ -1,6 +1,13 @@
using YamlDotNet.Serialization;
namespace ErsatzTV.Core.Graphics;
public class BaseGraphicsElement
{
public string Name { get; set; }
[YamlIgnore]
public string SourceFileName { get; set; }
public string DebugName() => string.IsNullOrEmpty(Name) ? SourceFileName : Name;
}
@@ -0,0 +1,5 @@
namespace ErsatzTV.Core.Health.Checks;
public interface IEmptyScheduleHealthCheck : IHealthCheck
{
}
@@ -0,0 +1,5 @@
namespace ErsatzTV.Core.Health.Checks;
public interface IFFmpegCapabilitiesHealthCheck : IHealthCheck
{
}
@@ -1,9 +1,7 @@
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Interfaces.Repositories;
namespace ErsatzTV.Core.Interfaces.Repositories;
public interface IArtworkRepository
{
Task<List<Artwork>> GetOrphanedArtwork();
Task<Unit> Delete(List<Artwork> artwork);
Task<List<int>> GetOrphanedArtworkIds();
Task<Unit> Delete(List<int> artworkIds);
}
@@ -16,7 +16,7 @@ public interface IMetadataRepository
Task<bool> UpdateStatistics(MediaItem mediaItem, MediaVersion incoming, bool updateVersion = true);
Task<Unit> UpdateArtworkPath(Artwork artwork);
Task<Unit> AddArtwork(Domain.Metadata metadata, Artwork artwork);
Task<Unit> RemoveArtwork(Domain.Metadata metadata, ArtworkKind artworkKind);
Task<bool> RemoveArtworkWithKind(Domain.Metadata metadata, ArtworkKind artworkKind);
Task<bool> CloneArtwork(
Domain.Metadata metadata,
@@ -0,0 +1,8 @@
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Interfaces.Repositories;
public interface IPlexMetadataRepository
{
Task<Unit> RemoveArtwork(Domain.Metadata metadata, ArtworkKind artworkKind);
}
@@ -11,5 +11,6 @@ public interface IPlayoutModeScheduler<in T> where T : ProgramScheduleItem
T scheduleItem,
ProgramScheduleItem nextScheduleItem,
DateTimeOffset hardStop,
Random random,
CancellationToken cancellationToken);
}
@@ -45,5 +45,14 @@ public interface ISearchIndex : IDisposable
int limit,
CancellationToken cancellationToken);
Task<SearchResult> Search(
IClient client,
string query,
string smartCollectionName,
int skip,
int limit,
List<string> defaultFields,
CancellationToken cancellationToken);
void Commit();
}
@@ -44,10 +44,11 @@ public partial class FallbackMetadataProvider(IClient client) : IFallbackMetadat
{
MetadataKind = MetadataKind.Fallback,
Title = fileName ?? showFolder,
Genres = new List<Genre>(),
Tags = new List<Tag>(),
Studios = new List<Studio>(),
Actors = new List<Actor>()
Genres = [],
Tags = [],
Studios = [],
Actors = [],
Artwork = []
};
return GetTelevisionShowMetadata(fileName, metadata);
}
@@ -56,7 +57,11 @@ public partial class FallbackMetadataProvider(IClient client) : IFallbackMetadat
{
string fileName = Path.GetFileName(artistFolder);
return new ArtistMetadata
{ MetadataKind = MetadataKind.Fallback, Title = fileName ?? artistFolder };
{
MetadataKind = MetadataKind.Fallback,
Title = fileName ?? artistFolder,
Artwork = []
};
}
public List<EpisodeMetadata> GetFallbackMetadata(Episode episode)
@@ -69,18 +74,18 @@ public partial class FallbackMetadataProvider(IClient client) : IFallbackMetadat
Title = Path.GetFileNameWithoutExtension(path) ?? path,
DateAdded = DateTime.UtcNow,
EpisodeNumber = 0,
Actors = new List<Actor>(),
Artwork = new List<Artwork>(),
Directors = new List<Director>(),
Genres = new List<Genre>(),
Guids = new List<MetadataGuid>(),
Studios = new List<Studio>(),
Tags = new List<Tag>(),
Writers = new List<Writer>()
Actors = [],
Artwork = [],
Directors = [],
Genres = [],
Guids = [],
Studios = [],
Tags = [],
Writers = []
};
return fileName != null
? GetEpisodeMetadata(fileName, baseMetadata)
: new List<EpisodeMetadata> { baseMetadata };
: [baseMetadata];
}
public MovieMetadata GetFallbackMetadata(Movie movie)
@@ -91,13 +96,14 @@ public partial class FallbackMetadataProvider(IClient client) : IFallbackMetadat
{
MetadataKind = MetadataKind.Fallback,
Title = Path.GetFileNameWithoutExtension(path) ?? path,
Genres = new List<Genre>(),
Tags = new List<Tag>(),
Studios = new List<Studio>(),
Actors = new List<Actor>(),
Directors = new List<Director>(),
Writers = new List<Writer>(),
Guids = new List<MetadataGuid>()
Artwork = [],
Genres = [],
Tags = [],
Studios = [],
Actors = [],
Directors = [],
Writers = [],
Guids = []
};
return fileName != null ? GetMovieMetadata(fileName, metadata) : metadata;
@@ -110,7 +116,8 @@ public partial class FallbackMetadataProvider(IClient client) : IFallbackMetadat
var metadata = new MusicVideoMetadata
{
MetadataKind = MetadataKind.Fallback,
Title = fileName ?? path
Title = fileName ?? path,
Artwork = []
};
return GetMusicVideoMetadata(fileName, metadata);
@@ -125,13 +132,14 @@ public partial class FallbackMetadataProvider(IClient client) : IFallbackMetadat
MetadataKind = MetadataKind.Fallback,
Title = fileName ?? path,
OtherVideo = otherVideo,
Genres = new List<Genre>(),
Tags = new List<Tag>(),
Studios = new List<Studio>(),
Actors = new List<Actor>(),
Directors = new List<Director>(),
Writers = new List<Writer>(),
Guids = new List<MetadataGuid>()
Artwork = [],
Genres = [],
Tags = [],
Studios = [],
Actors = [],
Directors = [],
Writers = [],
Guids = []
};
return GetOtherVideoMetadata(path, metadata);
@@ -145,7 +153,8 @@ public partial class FallbackMetadataProvider(IClient client) : IFallbackMetadat
{
MetadataKind = MetadataKind.Fallback,
Title = fileName ?? path,
Song = song
Song = song,
Artwork = []
};
return GetSongMetadata(path, metadata);
@@ -160,6 +169,7 @@ public partial class FallbackMetadataProvider(IClient client) : IFallbackMetadat
MetadataKind = MetadataKind.Fallback,
Title = fileName ?? path,
Image = image,
Artwork = [],
Genres = [],
Tags = [],
Studios = [],
@@ -179,6 +189,7 @@ public partial class FallbackMetadataProvider(IClient client) : IFallbackMetadat
MetadataKind = MetadataKind.Fallback,
Title = fileName ?? path,
RemoteStream = remoteStream,
Artwork = [],
Genres = [],
Tags = [],
Studios = [],
@@ -232,14 +243,14 @@ public partial class FallbackMetadataProvider(IClient client) : IFallbackMetadat
DateAdded = baseMetadata.DateAdded,
DateUpdated = baseMetadata.DateAdded,
Title = baseMetadata.Title,
Actors = new List<Actor>(),
Artwork = new List<Artwork>(),
Directors = new List<Director>(),
Genres = new List<Genre>(),
Guids = new List<MetadataGuid>(),
Studios = new List<Studio>(),
Tags = new List<Tag>(),
Writers = new List<Writer>()
Actors = [],
Artwork = [],
Directors = [],
Genres = [],
Guids = [],
Studios = [],
Tags = [],
Writers = []
};
result.Add(metadata);
@@ -290,6 +301,7 @@ public partial class FallbackMetadataProvider(IClient client) : IFallbackMetadat
? match.Groups[2].Value.Trim()
: Path.GetFileNameWithoutExtension(fileName);
metadata.Artists = [];
metadata.Artwork = [];
metadata.Genres = [];
metadata.Tags = [];
metadata.Studios = [];
@@ -326,11 +338,11 @@ public partial class FallbackMetadataProvider(IClient client) : IFallbackMetadat
.Map(t => new Tag { Name = t })
.ToList();
metadata.Artwork = new List<Artwork>();
metadata.Actors = new List<Actor>();
metadata.Genres = new List<Genre>();
metadata.Actors = [];
metadata.Artwork = [];
metadata.Genres = [];
metadata.Tags = tags;
metadata.Studios = new List<Studio>();
metadata.Studios = [];
metadata.DateUpdated = DateTime.UtcNow;
metadata.OriginalTitle = Path.GetRelativePath(libraryPath, path);
@@ -364,8 +376,8 @@ public partial class FallbackMetadataProvider(IClient client) : IFallbackMetadat
.Map(t => new Tag { Name = t })
.ToList();
metadata.Artwork = [];
metadata.Actors = [];
metadata.Artwork = [];
metadata.Genres = [];
metadata.Tags = tags;
metadata.Studios = [];
@@ -402,8 +414,8 @@ public partial class FallbackMetadataProvider(IClient client) : IFallbackMetadat
.Map(t => new Tag { Name = t })
.ToList();
metadata.Artwork = [];
metadata.Actors = [];
metadata.Artwork = [];
metadata.Genres = [];
metadata.Tags = tags;
metadata.Studios = [];
@@ -440,11 +452,11 @@ public partial class FallbackMetadataProvider(IClient client) : IFallbackMetadat
.Map(t => new Tag { Name = t })
.ToList();
metadata.Artwork = new List<Artwork>();
metadata.Actors = new List<Actor>();
metadata.Genres = new List<Genre>();
metadata.Actors = [];
metadata.Artwork = [];
metadata.Genres = [];
metadata.Tags = tags;
metadata.Studios = new List<Studio>();
metadata.Studios = [];
metadata.DateUpdated = DateTime.UtcNow;
metadata.OriginalTitle = Path.GetRelativePath(libraryPath, path);
@@ -0,0 +1,47 @@
using ErsatzTV.Core.Interfaces.Scheduling;
using NCalc;
namespace ErsatzTV.Core.Scheduling;
public static class CountExpression
{
public static int Evaluate(
string countExpression,
IMediaCollectionEnumerator enumerator,
Random random,
CancellationToken cancellationToken)
{
int enumeratorCount = enumerator is PlaylistEnumerator playlistEnumerator
? playlistEnumerator.CountForRandom
: enumerator.Count;
var expression = new Expression(countExpression);
expression.EvaluateParameter += (name, e) =>
{
e.Result = name switch
{
"count" => enumeratorCount,
"random" => enumeratorCount > 0 ? random.Next() % enumeratorCount : 0,
_ => e.Result
};
};
object expressionResult = 0;
try
{
expressionResult = expression.Evaluate(cancellationToken);
}
catch (Exception)
{
// do nothing
}
return expressionResult switch
{
double d when double.IsInfinity(d) || double.IsNaN(d) => 0,
double doubleResult => (int)Math.Floor(doubleResult),
int intResult => intResult,
long longResult => (int)longResult,
_ => 0
};
}
}
@@ -39,6 +39,10 @@ public class MarathonHelper(IMediaCollectionRepository mediaCollectionRepository
groups.AddRange(mediaItems.GroupBy(MediaItemKeyByAlbum));
itemPlaybackOrder = marathonShuffleItems ? PlaybackOrder.Shuffle : PlaybackOrder.Chronological;
break;
case MarathonGroupBy.Director:
groups.AddRange(mediaItems.GroupBy(MediaItemKeyByDirector));
itemPlaybackOrder = marathonShuffleItems ? PlaybackOrder.Shuffle : PlaybackOrder.Chronological;
break;
default:
return Option<PlaylistEnumerator>.None;
}
@@ -111,6 +115,11 @@ public class MarathonHelper(IMediaCollectionRepository mediaCollectionRepository
{
groups.AddRange(allMediaItems.GroupBy(MediaItemKeyByAlbum));
}
// group by (first) director
else if (string.Equals(groupBy, "director", StringComparison.OrdinalIgnoreCase))
{
groups.AddRange(allMediaItems.GroupBy(MediaItemKeyByDirector));
}
Dictionary<PlaylistItem, List<MediaItem>> itemMap = [];
@@ -189,6 +198,39 @@ public class MarathonHelper(IMediaCollectionRepository mediaCollectionRepository
_ => new GroupKey(CollectionType.Collection, 0, null, null, null)
};
private static GroupKey MediaItemKeyByDirector(MediaItem mediaItem) =>
mediaItem switch
{
Movie m => new GroupKey(
CollectionType.Collection,
m.MovieMetadata.HeadOrNone().Map(mm => FirstDirectorHashCode(mm.Directors)).IfNone(0),
null,
null,
null),
Episode e => new GroupKey(
CollectionType.Collection,
e.EpisodeMetadata.HeadOrNone().Map(em => FirstDirectorHashCode(em.Directors)).IfNone(0),
null,
null,
null),
MusicVideo mv => new GroupKey(
CollectionType.Collection,
mv.MusicVideoMetadata.HeadOrNone().Map(mvm => FirstDirectorHashCode(mvm.Directors)).IfNone(0),
null,
null,
null),
OtherVideo ov => new GroupKey(
CollectionType.Collection,
ov.OtherVideoMetadata.HeadOrNone().Map(ovm => FirstDirectorHashCode(ovm.Directors)).IfNone(0),
null,
null,
null),
_ => new GroupKey(CollectionType.Collection, 0, null, null, null)
};
private static int FirstDirectorHashCode(List<Director> directors) =>
directors.HeadOrNone().Select(director => director.Name.GetStableHashCode()).FirstOrDefault();
private static PlaylistItem GroupToPlaylistItem(
int index,
bool playAllItems,
@@ -444,9 +444,13 @@ public class SchedulingEngine(
return false;
}
int enumeratorCount = enumeratorDetails.Enumerator is PlaylistEnumerator playlistEnumerator
? playlistEnumerator.CountForFiller
: enumeratorDetails.Enumerator.Count;
return AddCountInternal(
enumeratorDetails,
enumeratorDetails.Enumerator.Count,
enumeratorCount,
fillerKind,
customTitle,
disableWatermarks);
+6 -4
View File
@@ -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
};
};
+5 -10
View File
@@ -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;
}
+2 -2
View File
@@ -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>
+7
View File
@@ -0,0 +1,7 @@
namespace ErsatzTV.FFmpeg;
public enum FFmpegFilterMode
{
HardwareIfPossible = 0,
Software = 1
}
+6
View File
@@ -0,0 +1,6 @@
namespace ErsatzTV.FFmpeg.Format;
public static class AudioLayout
{
public const string Stereo = "stereo";
}
+2
View File
@@ -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>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,39 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.MySql.Migrations
{
/// <inheritdoc />
public partial class Add_ArtworkIsMetadataOrphan : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsMetadataOrphan",
table: "Artwork",
type: "tinyint(1)",
nullable: true,
computedColumnSql: "CASE WHEN COALESCE(ArtistMetadataId, ChannelId, EpisodeMetadataId, MovieMetadataId, MusicVideoMetadataId, OtherVideoMetadataId, SeasonMetadataId, ShowMetadataId, SongMetadataId, ImageMetadataId, RemoteStreamMetadataId) IS NULL THEN 1 ELSE NULL END",
stored: false);
migrationBuilder.CreateIndex(
name: "IX_Artwork_IsMetadataOrphan",
table: "Artwork",
column: "IsMetadataOrphan");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Artwork_IsMetadataOrphan",
table: "Artwork");
migrationBuilder.DropColumn(
name: "IsMetadataOrphan",
table: "Artwork");
}
}
}
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");
}
}
}
@@ -0,0 +1,38 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.MySql.Migrations
{
/// <inheritdoc />
public partial class Change_ProgramScheduleItemMultipleCountString : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "Count",
table: "ProgramScheduleMultipleItem",
type: "longtext",
nullable: true,
oldClrType: typeof(int),
oldType: "int")
.Annotation("MySql:CharSet", "utf8mb4");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<int>(
name: "Count",
table: "ProgramScheduleMultipleItem",
type: "int",
nullable: false,
defaultValue: 0,
oldClrType: typeof(string),
oldType: "longtext",
oldNullable: true)
.OldAnnotation("MySql:CharSet", "utf8mb4");
}
}
}

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