Compare commits
65 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d26ae336cb | ||
|
|
875069b927 | ||
|
|
fd86cb55f9 | ||
|
|
0c30c47ba9 | ||
|
|
08cbf59527 | ||
|
|
a91de68a5c | ||
|
|
3e3bfbd5f5 | ||
|
|
31b07305ef | ||
|
|
49adcf7c37 | ||
|
|
c0b8ff1a06 | ||
|
|
c6d538e012 | ||
|
|
3dbde17f68 | ||
|
|
794d209941 | ||
|
|
7b9197d48d | ||
|
|
2ad6547349 | ||
|
|
4fa11b6943 | ||
|
|
440d9f708e | ||
|
|
4d469ec8fd | ||
|
|
a77a2d56ae | ||
|
|
240a329526 | ||
|
|
45e7d61676 | ||
|
|
93811876e0 | ||
|
|
607d9b0662 | ||
|
|
f47134d2d0 | ||
|
|
ae13db981d | ||
|
|
b7cc8499a3 | ||
|
|
36147b9e9c | ||
|
|
bf8c821012 | ||
|
|
a0f5d8d5d5 | ||
|
|
f1072b70c7 | ||
|
|
e10b28bc0b | ||
|
|
cd2bb0f2e0 | ||
|
|
e80f687612 | ||
|
|
317ca1967c | ||
|
|
b86f45844c | ||
|
|
353f029452 | ||
|
|
1754e7d5fb | ||
|
|
f96be8f99f | ||
|
|
08ceb53b2b | ||
|
|
3d81f760ee | ||
|
|
4ce87feac1 | ||
|
|
f217ba185b | ||
|
|
e925bd6913 | ||
|
|
3f4c9e063b | ||
|
|
7f361d1ea9 | ||
|
|
35d24ffea6 | ||
|
|
a2d023ee69 | ||
|
|
36f44f14bb | ||
|
|
ccb917d0df | ||
|
|
343a4619a6 | ||
|
|
e167c9318c | ||
|
|
de230f92db | ||
|
|
974020a98f | ||
|
|
da957c9377 | ||
|
|
b72d150775 | ||
|
|
b0b7bd17b3 | ||
|
|
1f2f04f3bd | ||
|
|
5bc90bb245 | ||
|
|
f73a32ec13 | ||
|
|
748ed1cf71 | ||
|
|
f2deaa6f7a | ||
|
|
3698fa5b7d | ||
|
|
dc92cb4ac3 | ||
|
|
69410b1a9b | ||
|
|
4aee03e066 |
6
.github/ISSUE_TEMPLATE/config.yml
vendored
6
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -3,9 +3,9 @@ contact_links:
|
||||
- name: Feature Requests
|
||||
url: https://features.ersatztv.org
|
||||
about: Features
|
||||
- name: Discord
|
||||
url: https://discord.ersatztv.org
|
||||
about: Chat
|
||||
- name: Contact
|
||||
url: https://ersatztv.org/contact
|
||||
about: Chat Options
|
||||
- name: Community
|
||||
url: https://discuss.ersatztv.org
|
||||
about: Forum
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/issue.yml
vendored
2
.github/ISSUE_TEMPLATE/issue.yml
vendored
@@ -12,7 +12,7 @@ body:
|
||||
label: "This issue respects the following points:"
|
||||
description: All conditions are **required**. Failure to comply with any of these conditions may cause your issue to be closed without comment.
|
||||
options:
|
||||
- label: This is a **bug**, not a question or a configuration issue; Please visit our [forum](https://discuss.ersatztv.org) or [chat](https://discord.ersatztv.org) first to troubleshoot with volunteers before creating a report.
|
||||
- label: This is a **bug**, not a question or a configuration issue; Please visit our [forum](https://discuss.ersatztv.org) or [chat](https://ersatztv.org/contact) first to troubleshoot with volunteers before creating a report.
|
||||
required: true
|
||||
- label: This issue is **not** already reported on [GitHub](https://github.com/ErsatzTV/ErsatzTV/issues?q=is%3Aopen+is%3Aissue) _(I've searched it)_.
|
||||
required: true
|
||||
|
||||
34
.github/workflows/artifacts.yml
vendored
34
.github/workflows/artifacts.yml
vendored
@@ -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
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
@@ -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
|
||||
|
||||
3
.github/workflows/release.yml
vendored
3
.github/workflows/release.yml
vendored
@@ -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
|
||||
|
||||
123
CHANGELOG.md
123
CHANGELOG.md
@@ -5,6 +5,109 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [26.3.0] - 2026-02-24
|
||||
### Added
|
||||
- Add log warnings when actual transcoding speed is potentially insufficient to support smooth playback
|
||||
- Log messages will include media item id, channel number and transcoding speed
|
||||
- Add UI language setting to **Settings** > **UI**
|
||||
- A small number of translations have been added for `Português (Brasil)` and `Polski`
|
||||
- Translation contributions are always welcome!
|
||||
- Add `Troubleshoot` button to playout details table to show info that may be helpful in determining the source of a playout item
|
||||
- Classic schedule info includes schedule, schedule item, scheduler, filler, playback order, random seed, collection index
|
||||
- Block schedule info includes block, block item, playback order, random seed, collection index
|
||||
- E.g. items with the same random seed are part of the same shuffle
|
||||
- Add channel setting `Slug Seconds`
|
||||
- This controls how many (optional) seconds of black video and silent audio to insert between *every* playout item
|
||||
- This will drift playback from the wall clock as slugs are not scheduled in the playout, but are inserted dynamically during playback
|
||||
- If this feature turns out to be popular, methods to correct the drift may be investigated
|
||||
- Add `ETV_INSTANCE_ID` environment variable to disambiguate EPG data from multiple ErsatzTV instances
|
||||
- When set, the value will be used in channel identifiers before the final `.ersatztv.org`
|
||||
- Show warning message when selecting audio format `aac (latm)` for general streaming use when it is only intended for DVB-C
|
||||
|
||||
### Changed
|
||||
- Move dark/light mode toggle to **Settings** > **UI**
|
||||
- Use latest (non-deprecated) authorization method with Jellyfin API
|
||||
- Replace direct Discord links with new contact page https://ersatztv.org/contact which also includes other options like Matrix
|
||||
- Lower GOP size and keyframe interval from four seconds to two seconds in accordance with HLS2 draft spec recommendations
|
||||
|
||||
### Fixed
|
||||
- Improve stability of playback orders `Shuffle` and `Shuffle in Order` over time
|
||||
- Fix Trakt list sync
|
||||
- Fix some cases of QSV audio/video desync when *not* seeking by using software decode
|
||||
- This only applies to content that *might* be problematic (using a heuristic)
|
||||
- NVIDIA: force software decode of 10-bit h264 content since hardware decode is unsupported by ffmpeg until version 8
|
||||
- Graphics engine: fix stream seek value used throughout graphics engine
|
||||
- This should fix loading EPG data when used with chapters/mid-roll
|
||||
- This should also fix graphics element visibility when using start_seconds on content with chapters/mid-roll
|
||||
- This bug was caused by stream seek including the playout item in-point (the chapter start time)
|
||||
- Stream seek should only be non-zero when first joining a channel (i.e. in the middle of a playout item or chapter)
|
||||
|
||||
## [26.2.0] - 2026-02-02
|
||||
### Added
|
||||
- Channel stream selector: add zero-based culture-specific `day_of_week` to `content_condition`, for example:
|
||||
- en-US can match sunday using `day_of_week = 0`
|
||||
- fr-FR can match sunday using `day_of_week = 6`
|
||||
- As a complete example, to match Saturday from 9pm (inclusive) to 11pm (exclusive), based on content start time
|
||||
- `content_condition: day_of_week = 6 and (time_of_day_seconds >= 75600 and time_of_day_seconds < 82800)`
|
||||
- Add `Pad Mode` to ffmpeg profile. Options are:
|
||||
- `Hardware If Possible` - default/existing behavior when hardware acceleration is properly configured
|
||||
- `Software` - force software padding
|
||||
- This can be used to work around buggy GPU driver behavior where padding is green instead of black
|
||||
- This is most often seen with VAAPI acceleration (radeonsi or i965 drivers)
|
||||
- Add API endpoint to clean artwork cache folder (on demand)
|
||||
- POST `/api/maintenance/clean_artwork`
|
||||
- Add health check to warn about unsupported empty (classic) schedules
|
||||
- Add health check to warn about incompatible ffmpeg due to missing filters
|
||||
- This is directly applicable to homebrew `ffmpeg` on MacOS, which is no longer compatible with ErsatzTV
|
||||
- `ffmpeg@7` or `ffmpeg-full` should be used instead
|
||||
- Add `Marathon Group By` option `Director`
|
||||
- This groups the *first* director on Movies, Episodes, Music Videos and Other Videos
|
||||
- This is supported in classic schedules and sequential schedules
|
||||
- Add FFmpeg Profile options:
|
||||
- `Normalize Audio` (default: true) - normalizes audio streams, or stream copies when disabled
|
||||
- `Normalize Video` (default: true) - normalizes video streams, or stream copies when disabled
|
||||
- `Normalize Colors` (default: true) - normalizes color parameters when enabled
|
||||
- Disabling any of these options may have a significant performance benefit *at the expense of stream stability*
|
||||
- Add chapter `title` to filler expression
|
||||
- This can be used to include or exclude chapters with specific (case-insensitive) titles
|
||||
- E.g. `title == 'here'`, `title != 'not here'`, `title like '%here%'`
|
||||
- Local movie libraries: load fanart from `backdrop` files (created by Jellyfin)
|
||||
|
||||
### Changed
|
||||
- Disable automatic artwork database cleanup
|
||||
- This will be re-enabled at some point in the future (after more testing)
|
||||
- For now, the API should be used to clean as needed
|
||||
- Classic Schedules: make multiple `count` an expression
|
||||
- The following parameters can be used:
|
||||
- `count`: the total number of items in the collection
|
||||
- `random`: a random number between zero and (count - 1)
|
||||
- For example:
|
||||
- `count / 2` will play half of the items in the collection
|
||||
- `random % 4 + 1` will play between 1 and 4 items
|
||||
- `2` (similar to before this change) will play exactly two items
|
||||
|
||||
### Fixed
|
||||
- Use code signing on all Windows executables (`ErsatzTV-Windows.exe`, `ErsatzTV.exe`, `ErsatzTV.Scanner.exe`)
|
||||
- Graphics engine:
|
||||
- Respect `z_index` (draw order) on all graphics element types
|
||||
- Fix bug with `z_index` sorting
|
||||
- Restore default UI font that was erroneously removed in v26.1.1
|
||||
- Classic schedules: fix building playouts when `Fill With Group Mode` schedule items also have graphics elements
|
||||
- Use configured searching log level on startup, instead of the default log level of `Information`
|
||||
- MySql: fix searching for shows and seasons in schedule items editor
|
||||
- Fix 500 errors when serving XMLTV due to concurrent file reads and writes
|
||||
- Fix playback of AC3 audio when targeting stereo output and input layout changes mid-stream
|
||||
- Use other video artwork in XMLTV template
|
||||
- Properly update (add or remove) artwork for all local media libraries when files have changed
|
||||
- Sync Plex library name changes
|
||||
- Sync Plex episode title, plot, year, date added, release date, episode number changes
|
||||
- Sync Jellyfin and Emby library name and type changes
|
||||
- Library type (movies, shows) can only be changed when synchronization is *disabled* for the library in ETV
|
||||
- Fix some sequential and scripted playout build failures when using playlists or marathons
|
||||
- Fix erasing playout items and history so all related data is also erased
|
||||
- This includes rerun history, unscheduled gaps, build status
|
||||
- Fix indexing collections when using Elasticsearch backend
|
||||
|
||||
## [26.1.1] - 2026-01-08
|
||||
### Fixed
|
||||
- Use code signing on Windows launcher (`ErsatzTV-Windows.exe`) to avoid antivirus false positive
|
||||
@@ -726,13 +829,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
- `random` will start at a random point in the content
|
||||
- `2` (similar to before this change) will skip the first two items in the content
|
||||
- YAML playout: make `count` an expression
|
||||
- The following parameters can be used:
|
||||
- `count`: the total number of items in the content
|
||||
- `random`: a random number between zero and (count - 1)
|
||||
- For example:
|
||||
- `count / 2` will play half of the items in the content
|
||||
- `random % 4 + 1` will play between 1 and 4 items
|
||||
- `2` (similar to before this change) will play exactly two items
|
||||
- The following parameters can be used:
|
||||
- `count`: the total number of items in the content
|
||||
- `random`: a random number between zero and (count - 1)
|
||||
- For example:
|
||||
- `count / 2` will play half of the items in the content
|
||||
- `random % 4 + 1` will play between 1 and 4 items
|
||||
- `2` (similar to before this change) will play exactly two items
|
||||
- YAML playout: add `disable_watermarks` property to all content instructions
|
||||
- This property defaults to `false` (meaning watermarks are allowed by default)
|
||||
- Setting to `true` will prevent watermarks from ever appearing over the content
|
||||
@@ -3089,8 +3192,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
- Initial release to facilitate testing outside of Docker.
|
||||
|
||||
|
||||
[Unreleased]: https://github.com/ErsatzTV/ErsatzTV/compare/v26.1.1...HEAD
|
||||
[26.1.0]: https://github.com/ErsatzTV/ErsatzTV/compare/v26.1.0...v26.1.1
|
||||
[Unreleased]: https://github.com/ErsatzTV/ErsatzTV/compare/v26.3.0...HEAD
|
||||
[26.3.0]: https://github.com/ErsatzTV/ErsatzTV/compare/v26.2.0...v26.3.0
|
||||
[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
|
||||
|
||||
@@ -11,6 +11,7 @@ public record ChannelViewModel(
|
||||
string Group,
|
||||
string Categories,
|
||||
int FFmpegProfileId,
|
||||
double? SlugSeconds,
|
||||
ArtworkContentTypeModel Logo,
|
||||
ChannelStreamSelectorMode StreamSelectorMode,
|
||||
string StreamSelector,
|
||||
|
||||
@@ -10,6 +10,7 @@ public record CreateChannel(
|
||||
string Group,
|
||||
string Categories,
|
||||
int FFmpegProfileId,
|
||||
double? SlugSeconds,
|
||||
ArtworkContentTypeModel Logo,
|
||||
ChannelStreamSelectorMode StreamSelectorMode,
|
||||
string StreamSelector,
|
||||
|
||||
@@ -80,6 +80,7 @@ public class CreateChannelHandler(
|
||||
Group = request.Group,
|
||||
Categories = request.Categories,
|
||||
FFmpegProfileId = ffmpegProfileId,
|
||||
SlugSeconds = request.SlugSeconds,
|
||||
PlayoutSource = request.PlayoutSource,
|
||||
PlayoutMode = request.PlayoutMode,
|
||||
MirrorSourceChannelId = request.MirrorSourceChannelId,
|
||||
|
||||
@@ -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,6 +11,7 @@ public record UpdateChannel(
|
||||
string Group,
|
||||
string Categories,
|
||||
int FFmpegProfileId,
|
||||
double? SlugSeconds,
|
||||
ArtworkContentTypeModel Logo,
|
||||
ChannelStreamSelectorMode StreamSelectorMode,
|
||||
string StreamSelector,
|
||||
|
||||
@@ -52,6 +52,7 @@ public class UpdateChannelHandler(
|
||||
c.Group = update.Group;
|
||||
c.Categories = update.Categories;
|
||||
c.FFmpegProfileId = update.FFmpegProfileId;
|
||||
c.SlugSeconds = update.SlugSeconds;
|
||||
c.StreamSelectorMode = update.StreamSelectorMode;
|
||||
c.StreamSelector = update.StreamSelector;
|
||||
c.PreferredAudioLanguageCode = update.PreferredAudioLanguageCode;
|
||||
|
||||
@@ -14,6 +14,7 @@ internal static class Mapper
|
||||
channel.Group,
|
||||
channel.Categories,
|
||||
channel.FFmpegProfileId,
|
||||
channel.SlugSeconds,
|
||||
GetLogo(channel),
|
||||
channel.StreamSelectorMode,
|
||||
channel.StreamSelector,
|
||||
|
||||
@@ -11,30 +11,18 @@ using Microsoft.IO;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public partial class GetChannelGuideHandler : IRequestHandler<GetChannelGuide, Either<BaseError, ChannelGuide>>
|
||||
public partial class GetChannelGuideHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
RecyclableMemoryStreamManager recyclableMemoryStreamManager,
|
||||
IFileSystem fileSystem,
|
||||
ILocalFileSystem localFileSystem)
|
||||
: IRequestHandler<GetChannelGuide, Either<BaseError, ChannelGuide>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly RecyclableMemoryStreamManager _recyclableMemoryStreamManager;
|
||||
private readonly IFileSystem _fileSystem;
|
||||
|
||||
public GetChannelGuideHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
RecyclableMemoryStreamManager recyclableMemoryStreamManager,
|
||||
IFileSystem fileSystem,
|
||||
ILocalFileSystem localFileSystem)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_recyclableMemoryStreamManager = recyclableMemoryStreamManager;
|
||||
_fileSystem = fileSystem;
|
||||
_localFileSystem = localFileSystem;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, ChannelGuide>> Handle(
|
||||
GetChannelGuide request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
var hiddenChannelNumbers = dbContext.Channels
|
||||
.Where(c => c.ShowInEpg == false)
|
||||
.Select(c => c.Number)
|
||||
@@ -42,13 +30,13 @@ public partial class GetChannelGuideHandler : IRequestHandler<GetChannelGuide, E
|
||||
.Select(n => $"{n}.xml")
|
||||
.ToImmutableHashSet();
|
||||
|
||||
string channelsFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, "channels.xml");
|
||||
if (!_fileSystem.File.Exists(channelsFile))
|
||||
string channelsFile = fileSystem.Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, "channels.xml");
|
||||
if (!fileSystem.File.Exists(channelsFile))
|
||||
{
|
||||
return BaseError.New($"Required file {channelsFile} is missing");
|
||||
}
|
||||
|
||||
long mtime = File.GetLastWriteTime(channelsFile).Ticks;
|
||||
long mtime = fileSystem.File.GetLastWriteTime(channelsFile).Ticks;
|
||||
|
||||
var accessTokenUri = $"?v={mtime}";
|
||||
if (!string.IsNullOrWhiteSpace(request.AccessToken))
|
||||
@@ -56,7 +44,7 @@ public partial class GetChannelGuideHandler : IRequestHandler<GetChannelGuide, E
|
||||
accessTokenUri += $"&access_token={request.AccessToken}";
|
||||
}
|
||||
|
||||
string channelsFragment = await File.ReadAllTextAsync(channelsFile, Encoding.UTF8, cancellationToken);
|
||||
string channelsFragment = await ReadAllTextShared(channelsFile, cancellationToken);
|
||||
|
||||
// TODO: is regex faster?
|
||||
channelsFragment = channelsFragment
|
||||
@@ -65,30 +53,52 @@ public partial class GetChannelGuideHandler : IRequestHandler<GetChannelGuide, E
|
||||
|
||||
var channelDataFragments = new Dictionary<string, string>();
|
||||
|
||||
foreach (string fileName in _localFileSystem.ListFiles(FileSystemLayout.ChannelGuideCacheFolder))
|
||||
foreach (string fileName in localFileSystem.ListFiles(FileSystemLayout.ChannelGuideCacheFolder))
|
||||
{
|
||||
if (fileName.Contains("channels"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (hiddenChannelNumbers.Contains(Path.GetFileName(fileName)))
|
||||
if (hiddenChannelNumbers.Contains(fileSystem.Path.GetFileName(fileName)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string channelDataFragment = await File.ReadAllTextAsync(fileName, Encoding.UTF8, cancellationToken);
|
||||
try
|
||||
{
|
||||
string channelDataFragment = await ReadAllTextShared(fileName, cancellationToken);
|
||||
|
||||
channelDataFragment = channelDataFragment
|
||||
.Replace("{RequestBase}", $"{request.Scheme}://{request.Host}{request.BaseUrl}")
|
||||
.Replace("{AccessTokenUri}", accessTokenUri);
|
||||
channelDataFragment = channelDataFragment
|
||||
.Replace("{RequestBase}", $"{request.Scheme}://{request.Host}{request.BaseUrl}")
|
||||
.Replace("{AccessTokenUri}", accessTokenUri);
|
||||
|
||||
channelDataFragment = EtvTagRegex().Replace(channelDataFragment, string.Empty);
|
||||
channelDataFragment = EtvTagRegex().Replace(channelDataFragment, string.Empty);
|
||||
|
||||
channelDataFragments.Add(Path.GetFileNameWithoutExtension(fileName), channelDataFragment);
|
||||
channelDataFragments.Add(fileSystem.Path.GetFileNameWithoutExtension(fileName), channelDataFragment);
|
||||
}
|
||||
catch (FileNotFoundException)
|
||||
{
|
||||
// ignore this channel fragment
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// ignore this channel fragment
|
||||
}
|
||||
}
|
||||
|
||||
return new ChannelGuide(_recyclableMemoryStreamManager, channelsFragment, channelDataFragments);
|
||||
return new ChannelGuide(recyclableMemoryStreamManager, channelsFragment, channelDataFragments);
|
||||
}
|
||||
|
||||
private async Task<string> ReadAllTextShared(string fileName, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var stream = fileSystem.FileStream.New(
|
||||
fileName,
|
||||
FileMode.Open,
|
||||
FileAccess.Read,
|
||||
FileShare.ReadWrite);
|
||||
using var reader = new StreamReader(stream, Encoding.UTF8, leaveOpen: true);
|
||||
return await reader.ReadToEndAsync(cancellationToken);
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"<etv:[^>]+?>.*?<\/etv:[^>]+?>|<etv:[^>]+?\/>", RegexOptions.Singleline)]
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public record GetSlugSecondsByChannelNumber(string ChannelNumber) : IRequest<Option<double>>;
|
||||
@@ -0,0 +1,17 @@
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class GetSlugSecondsByChannelNumberHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<GetSlugSecondsByChannelNumber, Option<double>>
|
||||
{
|
||||
public async Task<Option<double>> Handle(GetSlugSecondsByChannelNumber request, CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
return await dbContext.Channels
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync(c => c.Number == request.ChannelNumber, cancellationToken)
|
||||
.Map(c => Optional(c?.SlugSeconds));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public record UpdateUiSettings(UiSettingsViewModel UiSettings) : IRequest<Either<BaseError, Unit>>;
|
||||
@@ -0,0 +1,28 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public class UpdateUiSettingsHandler(IConfigElementRepository configElementRepository)
|
||||
: IRequestHandler<UpdateUiSettings, Either<BaseError, Unit>>
|
||||
{
|
||||
public async Task<Either<BaseError, Unit>> Handle(
|
||||
UpdateUiSettings request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return await ApplyUpdate(request.UiSettings, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<Unit> ApplyUpdate(UiSettingsViewModel uiSettings, CancellationToken cancellationToken)
|
||||
{
|
||||
await configElementRepository.Upsert(
|
||||
ConfigElementKey.PagesIsDarkMode,
|
||||
uiSettings.IsDarkMode,
|
||||
cancellationToken);
|
||||
|
||||
await configElementRepository.Upsert(ConfigElementKey.PagesLanguage, uiSettings.Language, cancellationToken);
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public record GetUiSettings : IRequest<UiSettingsViewModel>;
|
||||
@@ -0,0 +1,25 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public class GetUiSettingsHandler(IConfigElementRepository configElementRepository)
|
||||
: IRequestHandler<GetUiSettings, UiSettingsViewModel>
|
||||
{
|
||||
public async Task<UiSettingsViewModel> Handle(GetUiSettings request, CancellationToken cancellationToken)
|
||||
{
|
||||
Option<bool> pagesIsDarkMode = await configElementRepository.GetValue<bool>(
|
||||
ConfigElementKey.PagesIsDarkMode,
|
||||
cancellationToken);
|
||||
|
||||
Option<string> pagesLanguage = await configElementRepository.GetValue<string>(
|
||||
ConfigElementKey.PagesLanguage,
|
||||
cancellationToken);
|
||||
|
||||
return new UiSettingsViewModel
|
||||
{
|
||||
IsDarkMode = await pagesIsDarkMode.IfNoneAsync(true),
|
||||
Language = await pagesLanguage.IfNoneAsync("en")
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public class UiSettingsViewModel
|
||||
{
|
||||
public bool IsDarkMode { get; set; }
|
||||
|
||||
public string Language { get; set; }
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<NoWarn>VSTHRD200</NoWarn>
|
||||
<NoWarn>VSTHRD200,CA1873</NoWarn>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<AnalysisLevel>latest-Recommended</AnalysisLevel>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
@@ -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" />
|
||||
|
||||
@@ -26,7 +26,7 @@ public class RefreshFFmpegCapabilitiesHandler(
|
||||
|
||||
foreach (string ffmpegPath in maybeFFmpegPath)
|
||||
{
|
||||
_ = await hardwareCapabilitiesFactory.GetFFmpegCapabilities(ffmpegPath);
|
||||
_ = await hardwareCapabilitiesFactory.GetFFmpegCapabilities(ffmpegPath, cancellationToken);
|
||||
|
||||
Option<string> maybeFFprobePath = await dbContext.ConfigElements
|
||||
.GetValue<string>(ConfigElementKey.FFprobePath, cancellationToken)
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -31,15 +31,18 @@ public class
|
||||
Validation<BaseError, string> validation = await Validate(dbContext, cancellationToken);
|
||||
|
||||
return await validation.Match(
|
||||
GetHardwareAccelerationKinds,
|
||||
ffmpegPath => GetHardwareAccelerationKinds(ffmpegPath, cancellationToken),
|
||||
_ => Task.FromResult(new List<HardwareAccelerationKind> { HardwareAccelerationKind.None }));
|
||||
}
|
||||
|
||||
private async Task<List<HardwareAccelerationKind>> GetHardwareAccelerationKinds(string ffmpegPath)
|
||||
private async Task<List<HardwareAccelerationKind>> GetHardwareAccelerationKinds(
|
||||
string ffmpegPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = new List<HardwareAccelerationKind> { HardwareAccelerationKind.None };
|
||||
|
||||
IFFmpegCapabilities ffmpegCapabilities = await _hardwareCapabilitiesFactory.GetFFmpegCapabilities(ffmpegPath);
|
||||
IFFmpegCapabilities ffmpegCapabilities =
|
||||
await _hardwareCapabilitiesFactory.GetFFmpegCapabilities(ffmpegPath, cancellationToken);
|
||||
|
||||
if (ffmpegCapabilities.HasHardwareAcceleration(HardwareAccelerationMode.Nvenc))
|
||||
{
|
||||
|
||||
@@ -74,7 +74,8 @@ public class
|
||||
ffmpegPath,
|
||||
originalPath,
|
||||
withExtension,
|
||||
request.MaxHeight.Value);
|
||||
request.MaxHeight.Value,
|
||||
cancellationToken);
|
||||
|
||||
CommandResult resize = await process.ExecuteAsync(cancellationToken);
|
||||
|
||||
|
||||
@@ -36,8 +36,10 @@ public class SaveJellyfinSecretsHandler : IRequestHandler<SaveJellyfinSecrets, E
|
||||
|
||||
private async Task<Validation<BaseError, Parameters>> Validate(SaveJellyfinSecrets request)
|
||||
{
|
||||
var connectionParameters = new JellyfinConnectionParameters(request.Secrets.Address, request.Secrets.ApiKey, 0);
|
||||
|
||||
Either<BaseError, JellyfinServerInformation> maybeServerInformation = await _jellyfinApiClient
|
||||
.GetServerInformation(request.Secrets.Address, request.Secrets.ApiKey);
|
||||
.GetServerInformation(connectionParameters.Address, connectionParameters.AuthorizationHeader);
|
||||
|
||||
return maybeServerInformation.Match(
|
||||
info => Validation<BaseError, Parameters>.Success(new Parameters(request.Secrets, info)),
|
||||
|
||||
@@ -38,7 +38,7 @@ public class
|
||||
.MapT(p => SynchronizeLibraries(p, cancellationToken))
|
||||
.Bind(v => v.ToEitherAsync());
|
||||
|
||||
private Task<Validation<BaseError, ConnectionParameters>> Validate(SynchronizeJellyfinLibraries request) =>
|
||||
private Task<Validation<BaseError, ConnectionAndSource>> Validate(SynchronizeJellyfinLibraries request) =>
|
||||
MediaSourceMustExist(request)
|
||||
.BindT(MediaSourceMustHaveActiveConnection)
|
||||
.BindT(MediaSourceMustHaveApiKey);
|
||||
@@ -48,43 +48,48 @@ public class
|
||||
_mediaSourceRepository.GetJellyfin(request.JellyfinMediaSourceId)
|
||||
.Map(o => o.ToValidation<BaseError>("Jellyfin media source does not exist."));
|
||||
|
||||
private Validation<BaseError, ConnectionParameters> MediaSourceMustHaveActiveConnection(
|
||||
private Validation<BaseError, ConnectionAndSource> MediaSourceMustHaveActiveConnection(
|
||||
JellyfinMediaSource jellyfinMediaSource)
|
||||
{
|
||||
Option<JellyfinConnection> maybeConnection = jellyfinMediaSource.Connections.HeadOrNone();
|
||||
return maybeConnection.Map(connection => new ConnectionParameters(jellyfinMediaSource, connection))
|
||||
return maybeConnection.Map(connection => new ConnectionAndSource(
|
||||
new JellyfinConnectionParameters(connection.Address, string.Empty, connection.JellyfinMediaSourceId),
|
||||
jellyfinMediaSource))
|
||||
.ToValidation<BaseError>("Jellyfin media source requires an active connection");
|
||||
}
|
||||
|
||||
private async Task<Validation<BaseError, ConnectionParameters>> MediaSourceMustHaveApiKey(
|
||||
ConnectionParameters connectionParameters)
|
||||
private async Task<Validation<BaseError, ConnectionAndSource>> MediaSourceMustHaveApiKey(
|
||||
ConnectionAndSource connectionAndSource)
|
||||
{
|
||||
JellyfinSecrets secrets = await _jellyfinSecretStore.ReadSecrets();
|
||||
return Optional(secrets.Address == connectionParameters.ActiveConnection.Address)
|
||||
return Optional(secrets.Address == connectionAndSource.ConnectionParameters.Address)
|
||||
.Where(match => match)
|
||||
.Map(_ => connectionParameters with { ApiKey = secrets.ApiKey })
|
||||
.Map(_ => connectionAndSource with
|
||||
{
|
||||
ConnectionParameters = connectionAndSource.ConnectionParameters with { ApiKey = secrets.ApiKey }
|
||||
})
|
||||
.ToValidation<BaseError>("Jellyfin media source requires an api key");
|
||||
}
|
||||
|
||||
private async Task<Unit> SynchronizeLibraries(
|
||||
ConnectionParameters connectionParameters,
|
||||
ConnectionAndSource connectionAndSource,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Either<BaseError, List<JellyfinLibrary>> maybeLibraries = await _jellyfinApiClient.GetLibraries(
|
||||
connectionParameters.ActiveConnection.Address,
|
||||
connectionParameters.ApiKey);
|
||||
connectionAndSource.ConnectionParameters.Address,
|
||||
connectionAndSource.ConnectionParameters.AuthorizationHeader);
|
||||
|
||||
foreach (BaseError error in maybeLibraries.LeftToSeq())
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Unable to synchronize libraries from jellyfin server {JellyfinServer}: {Error}",
|
||||
connectionParameters.JellyfinMediaSource.ServerName,
|
||||
connectionAndSource.MediaSource.ServerName,
|
||||
error.Value);
|
||||
}
|
||||
|
||||
foreach (List<JellyfinLibrary> libraries in maybeLibraries.RightToSeq())
|
||||
{
|
||||
var existing = connectionParameters.JellyfinMediaSource.Libraries
|
||||
var existing = connectionAndSource.MediaSource.Libraries
|
||||
.OfType<JellyfinLibrary>()
|
||||
.ToList();
|
||||
var toAdd = libraries.Filter(library => existing.All(l => l.ItemId != library.ItemId)).ToList();
|
||||
@@ -92,7 +97,7 @@ public class
|
||||
var toUpdate = libraries
|
||||
.Filter(l => toAdd.All(a => a.ItemId != l.ItemId) && toRemove.All(r => r.ItemId != l.ItemId)).ToList();
|
||||
List<int> ids = await _mediaSourceRepository.UpdateLibraries(
|
||||
connectionParameters.JellyfinMediaSource.Id,
|
||||
connectionAndSource.MediaSource.Id,
|
||||
toAdd,
|
||||
toRemove,
|
||||
toUpdate,
|
||||
@@ -107,10 +112,7 @@ public class
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private sealed record ConnectionParameters(
|
||||
JellyfinMediaSource JellyfinMediaSource,
|
||||
JellyfinConnection ActiveConnection)
|
||||
{
|
||||
public string ApiKey { get; set; }
|
||||
}
|
||||
private sealed record ConnectionAndSource(
|
||||
JellyfinConnectionParameters ConnectionParameters,
|
||||
JellyfinMediaSource MediaSource);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,174 @@
|
||||
using ErsatzTV.Core;
|
||||
using System.Globalization;
|
||||
using System.IO.Abstractions;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Images;
|
||||
using Humanizer;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Application.Maintenance;
|
||||
|
||||
public class DeleteOrphanedArtworkHandler(IArtworkRepository artworkRepository)
|
||||
public class DeleteOrphanedArtworkHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IArtworkRepository artworkRepository,
|
||||
IFileSystem fileSystem,
|
||||
ILogger<DeleteOrphanedArtworkHandler> logger)
|
||||
: IRequestHandler<DeleteOrphanedArtwork, Either<BaseError, Unit>>
|
||||
{
|
||||
public Task<Either<BaseError, Unit>>
|
||||
Handle(DeleteOrphanedArtwork request, CancellationToken cancellationToken) =>
|
||||
artworkRepository.GetOrphanedArtworkIds()
|
||||
.Bind(artworkRepository.Delete)
|
||||
.Map(_ => Right<BaseError, Unit>(Unit.Default));
|
||||
public async Task<Either<BaseError, Unit>> Handle(
|
||||
DeleteOrphanedArtwork request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await CleanUpDatabase();
|
||||
await CleanUpFileSystem(cancellationToken);
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return BaseError.New(e.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CleanUpDatabase()
|
||||
{
|
||||
List<int> ids = await artworkRepository.GetOrphanedArtworkIds();
|
||||
if (ids.Count > 0)
|
||||
{
|
||||
await artworkRepository.Delete(ids);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CleanUpFileSystem(CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
System.Collections.Generic.HashSet<string> validFiles = [];
|
||||
|
||||
List<string> watermarks = await dbContext.ChannelWatermarks
|
||||
.TagWithCallSite()
|
||||
.AsNoTracking()
|
||||
.Select(c => c.Image)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (string watermark in watermarks.Where(w => !string.IsNullOrWhiteSpace(w)))
|
||||
{
|
||||
validFiles.Add(watermark);
|
||||
}
|
||||
|
||||
var lastId = 0;
|
||||
while (true)
|
||||
{
|
||||
List<MinimalArtwork> result = await dbContext.Artwork
|
||||
.TagWithCallSite()
|
||||
.AsNoTracking()
|
||||
.Where(a => a.Id > lastId)
|
||||
.OrderBy(a => a.Id)
|
||||
.Take(1000)
|
||||
.Select(a => new MinimalArtwork(a.Id, a.Path, a.BlurHash43, a.BlurHash54, a.BlurHash64))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (result.Count == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
foreach (MinimalArtwork artwork in result)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(artwork.Path) && !artwork.Path.Contains('/'))
|
||||
{
|
||||
validFiles.Add(artwork.Path);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(artwork.BlurHash43))
|
||||
{
|
||||
validFiles.Add(ImageCache.GetBlurHashFileName(artwork.BlurHash43));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(artwork.BlurHash54))
|
||||
{
|
||||
validFiles.Add(ImageCache.GetBlurHashFileName(artwork.BlurHash54));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(artwork.BlurHash64))
|
||||
{
|
||||
validFiles.Add(ImageCache.GetBlurHashFileName(artwork.BlurHash64));
|
||||
}
|
||||
}
|
||||
|
||||
lastId = result.Last().Id;
|
||||
}
|
||||
|
||||
logger.LogDebug("Loaded {Count} artwork hashes (valid file names)", validFiles.Count);
|
||||
|
||||
var deleted = 0;
|
||||
long bytes = 0;
|
||||
foreach (string file in fileSystem.Directory.EnumerateFiles(
|
||||
FileSystemLayout.ArtworkCacheFolder,
|
||||
"*.*",
|
||||
SearchOption.AllDirectories))
|
||||
{
|
||||
string fileName = fileSystem.Path.GetFileName(file);
|
||||
if (!validFiles.Contains(fileName))
|
||||
{
|
||||
try
|
||||
{
|
||||
bytes += fileSystem.FileInfo.New(file).Length;
|
||||
|
||||
fileSystem.File.Delete(file);
|
||||
deleted++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Could not delete artwork file {File}", file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.LogDebug(
|
||||
"Deleted {Count} unused artwork cache files totaling {Size}",
|
||||
deleted,
|
||||
bytes.Bytes().Humanize(CultureInfo.CurrentCulture));
|
||||
|
||||
DeleteEmptySubfolders(FileSystemLayout.ArtworkCacheFolder);
|
||||
}
|
||||
|
||||
private void DeleteEmptySubfolders(string path)
|
||||
{
|
||||
if (!fileSystem.Directory.Exists(path))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (string sub in fileSystem.Directory.GetDirectories(path))
|
||||
{
|
||||
DeleteEmptySubfolders(sub);
|
||||
}
|
||||
|
||||
if (!fileSystem.Directory.EnumerateFileSystemEntries(path).Any())
|
||||
{
|
||||
try
|
||||
{
|
||||
// don't delete artwork cache folder or its direct children
|
||||
if (path != FileSystemLayout.ArtworkCacheFolder)
|
||||
{
|
||||
var parent = fileSystem.Directory.GetParent(path);
|
||||
if (parent?.FullName != FileSystemLayout.ArtworkCacheFolder)
|
||||
{
|
||||
fileSystem.Directory.Delete(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Could not delete empty cache folder {Folder}", path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record MinimalArtwork(int Id, string Path, string BlurHash43, string BlurHash54, string BlurHash64);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ internal static class Mapper
|
||||
playoutItem.StartOffset,
|
||||
playoutItem.FinishOffset,
|
||||
playoutItem.GetDisplayDuration(),
|
||||
playoutItem.SchedulingContext,
|
||||
Some(playoutItem.FillerKind));
|
||||
|
||||
internal static PlayoutAlternateScheduleViewModel ProjectToViewModel(
|
||||
|
||||
@@ -2,4 +2,10 @@ using ErsatzTV.Core.Domain.Filler;
|
||||
|
||||
namespace ErsatzTV.Application.Playouts;
|
||||
|
||||
public record PlayoutItemViewModel(string Title, DateTimeOffset Start, DateTimeOffset Finish, string Duration, Option<FillerKind> FillerKind);
|
||||
public record PlayoutItemViewModel(
|
||||
string Title,
|
||||
DateTimeOffset Start,
|
||||
DateTimeOffset Finish,
|
||||
string Duration,
|
||||
string SchedulingContext,
|
||||
Option<FillerKind> FillerKind);
|
||||
|
||||
@@ -112,7 +112,8 @@ public class GetFuturePlayoutItemsByIdHandler(IDbContextFactory<TvContext> dbCon
|
||||
gap.FinishOffset,
|
||||
TimeSpan.FromSeconds(Math.Round(gapDuration.TotalSeconds)).ToString(
|
||||
gapDuration.TotalHours >= 1 ? @"h\:mm\:ss" : @"mm\:ss",
|
||||
CultureInfo.CurrentUICulture.DateTimeFormat),
|
||||
CultureInfo.CurrentCulture.DateTimeFormat),
|
||||
string.Empty,
|
||||
None
|
||||
);
|
||||
}).ToList();
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.ProgramSchedules;
|
||||
|
||||
public record ProcessSchedulingContext(string SerializedContext) : IRequest<Option<string>>;
|
||||
@@ -0,0 +1,218 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using JsonSerializer = System.Text.Json.JsonSerializer;
|
||||
|
||||
namespace ErsatzTV.Application.ProgramSchedules;
|
||||
|
||||
public class ProcessSchedulingContextHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<ProcessSchedulingContext, Option<string>>
|
||||
{
|
||||
private static readonly JsonSerializerOptions Options = new()
|
||||
{
|
||||
Converters = { new JsonStringEnumConverter() },
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
public async Task<Option<string>> Handle(ProcessSchedulingContext request, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
using JsonDocument doc = JsonDocument.Parse(request.SerializedContext);
|
||||
if (doc.RootElement.TryGetProperty(nameof(ClassicSchedulingContext.ScheduleId), out _))
|
||||
{
|
||||
var classicContext = JsonSerializer.Deserialize<ClassicSchedulingContext>(request.SerializedContext, Options);
|
||||
if (classicContext is not null && classicContext.ScheduleId > 0)
|
||||
{
|
||||
return await GetClassicDetails(classicContext, cancellationToken);
|
||||
}
|
||||
}
|
||||
else if (doc.RootElement.TryGetProperty(nameof(BlockSchedulingContext.BlockId), out _))
|
||||
{
|
||||
var blockContext = JsonSerializer.Deserialize<BlockSchedulingContext>(request.SerializedContext, Options);
|
||||
if (blockContext is not null && blockContext.BlockId > 0)
|
||||
{
|
||||
return await GetBlockDetails(blockContext, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// not a valid json string, or not a context we can process
|
||||
}
|
||||
|
||||
return request.SerializedContext;
|
||||
}
|
||||
|
||||
private async Task<Option<string>> GetClassicDetails(
|
||||
ClassicSchedulingContext classicContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
string scheduleName = await dbContext.ProgramSchedules
|
||||
.Where(s => s.Id == classicContext.ScheduleId)
|
||||
.Select(s => s.Name)
|
||||
.SingleOrDefaultAsync(cancellationToken);
|
||||
|
||||
var scheduleItem = await dbContext.ProgramScheduleItems
|
||||
.AsNoTracking()
|
||||
.AsSplitQuery()
|
||||
.Include(si => si.Collection)
|
||||
.Include(si => si.MultiCollection)
|
||||
.Include(si => si.Playlist)
|
||||
.Include(si => si.RerunCollection)
|
||||
.Include(si => si.SmartCollection)
|
||||
.SingleOrDefaultAsync(si => si.Id == classicContext.ItemId, cancellationToken);
|
||||
|
||||
var name = "Classic";
|
||||
ClassicContextFiller filler = null;
|
||||
if (classicContext.FillerPresetId is > 0)
|
||||
{
|
||||
name = "Classic - Filler";
|
||||
|
||||
var fillerPreset = await dbContext.FillerPresets
|
||||
.AsNoTracking()
|
||||
.AsSplitQuery()
|
||||
.Include(p => p.Collection)
|
||||
.Include(p => p.MultiCollection)
|
||||
.Include(p => p.Playlist)
|
||||
.Include(p => p.SmartCollection)
|
||||
.SingleOrDefaultAsync(p => p.Id == classicContext.FillerPresetId, cancellationToken);
|
||||
|
||||
if (fillerPreset is not null)
|
||||
{
|
||||
string collectionName = fillerPreset.CollectionType switch
|
||||
{
|
||||
CollectionType.Collection => fillerPreset.Collection.Name,
|
||||
CollectionType.MultiCollection => fillerPreset.MultiCollection.Name,
|
||||
CollectionType.Playlist => fillerPreset.Playlist.Name,
|
||||
CollectionType.SmartCollection => fillerPreset.SmartCollection.Name,
|
||||
_ => null
|
||||
};
|
||||
|
||||
filler = new ClassicContextFiller(
|
||||
fillerPreset.Id,
|
||||
fillerPreset.Name,
|
||||
fillerPreset.FillerKind,
|
||||
fillerPreset.FillerMode,
|
||||
fillerPreset.CollectionType,
|
||||
collectionName);
|
||||
}
|
||||
}
|
||||
|
||||
ClassicContextScheduleItem item;
|
||||
if (scheduleItem is not null)
|
||||
{
|
||||
string collectionName = scheduleItem.CollectionType switch
|
||||
{
|
||||
CollectionType.Collection => scheduleItem.Collection.Name,
|
||||
CollectionType.MultiCollection => scheduleItem.MultiCollection.Name,
|
||||
CollectionType.Playlist => scheduleItem.Playlist.Name,
|
||||
CollectionType.RerunRerun or CollectionType.RerunFirstRun => scheduleItem.RerunCollection.Name,
|
||||
CollectionType.SmartCollection => scheduleItem.SmartCollection.Name,
|
||||
_ => null
|
||||
};
|
||||
|
||||
item = new ClassicContextScheduleItem(scheduleItem.Id, scheduleItem.CollectionType, collectionName);
|
||||
}
|
||||
else
|
||||
{
|
||||
item = new ClassicContextScheduleItem(classicContext.ItemId, null, null);
|
||||
}
|
||||
|
||||
var context = new ClassicContext(
|
||||
new ContextScheduler(name, classicContext.Scheduler),
|
||||
new ClassicContextSchedule(classicContext.ScheduleId, scheduleName ?? string.Empty),
|
||||
item,
|
||||
filler,
|
||||
new ContextEnumerator(classicContext.Enumerator, classicContext.Seed, classicContext.Index));
|
||||
|
||||
return JsonSerializer.Serialize(context, Options);
|
||||
}
|
||||
|
||||
private async Task<Option<string>> GetBlockDetails(BlockSchedulingContext blockContext, CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
var block = await dbContext.Blocks
|
||||
.AsNoTracking()
|
||||
.Include(b => b.BlockGroup)
|
||||
.SingleOrDefaultAsync(s => s.Id == blockContext.BlockId, cancellationToken);
|
||||
|
||||
var blockItem = await dbContext.BlockItems
|
||||
.AsNoTracking()
|
||||
.AsSplitQuery()
|
||||
.Include(si => si.Collection)
|
||||
.Include(si => si.MultiCollection)
|
||||
.Include(si => si.SmartCollection)
|
||||
.SingleOrDefaultAsync(s => s.Id == blockContext.BlockItemId, cancellationToken);
|
||||
|
||||
BlockContextBlockItem item;
|
||||
if (blockItem is not null)
|
||||
{
|
||||
string collectionName = blockItem.CollectionType switch
|
||||
{
|
||||
CollectionType.Collection => blockItem.Collection.Name,
|
||||
CollectionType.MultiCollection => blockItem.MultiCollection.Name,
|
||||
CollectionType.SmartCollection => blockItem.SmartCollection.Name,
|
||||
_ => null
|
||||
};
|
||||
|
||||
item = new BlockContextBlockItem(blockItem.Id, blockItem.CollectionType, collectionName);
|
||||
}
|
||||
else
|
||||
{
|
||||
item = new BlockContextBlockItem(blockContext.BlockItemId, null, null);
|
||||
}
|
||||
|
||||
var context = new BlockContext(
|
||||
new ContextScheduler("Block", null),
|
||||
new BlockContextBlock(
|
||||
blockContext.BlockId,
|
||||
block?.BlockGroup?.Name ?? string.Empty,
|
||||
block?.Name ?? string.Empty),
|
||||
item,
|
||||
new ContextEnumerator(blockContext.Enumerator, blockContext.Seed, blockContext.Index));
|
||||
|
||||
return JsonSerializer.Serialize(context, Options);
|
||||
}
|
||||
|
||||
private sealed record ClassicContext(
|
||||
ContextScheduler Scheduler,
|
||||
ClassicContextSchedule Schedule,
|
||||
ClassicContextScheduleItem ScheduleItem,
|
||||
ClassicContextFiller Filler,
|
||||
ContextEnumerator Enumerator);
|
||||
|
||||
private sealed record ContextScheduler(string Type, string Mode);
|
||||
|
||||
private sealed record ClassicContextSchedule(int Id, string Name);
|
||||
|
||||
private sealed record ClassicContextScheduleItem(int Id, CollectionType? CollectionType, string CollectionName);
|
||||
|
||||
private sealed record ClassicContextFiller(
|
||||
int Id,
|
||||
string Name,
|
||||
FillerKind Kind,
|
||||
FillerMode Mode,
|
||||
CollectionType CollectionType,
|
||||
string CollectionName);
|
||||
|
||||
private sealed record ContextEnumerator(string Name, int Seed, int Index);
|
||||
|
||||
private sealed record BlockContext(
|
||||
ContextScheduler Scheduler,
|
||||
BlockContextBlock Block,
|
||||
BlockContextBlockItem BlockItem,
|
||||
ContextEnumerator Enumerator);
|
||||
|
||||
private sealed record BlockContextBlock(int Id, string Group, string Name);
|
||||
|
||||
private sealed record BlockContextBlockItem(int Id, CollectionType? CollectionType, string CollectionName);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -6,5 +6,7 @@ public enum HlsSessionState
|
||||
ZeroAndWorkAhead,
|
||||
SeekAndRealtime,
|
||||
ZeroAndRealtime,
|
||||
SlugAndWorkAhead,
|
||||
SlugAndRealtime,
|
||||
PlayoutUpdated
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ using System.Timers;
|
||||
using Bugsnag;
|
||||
using CliWrap;
|
||||
using CliWrap.Buffered;
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Application.Playouts;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
@@ -54,6 +55,7 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
private Timer _timer;
|
||||
private DateTimeOffset _transcodedUntil;
|
||||
private string _workingDirectory;
|
||||
private Option<double> _slugSeconds;
|
||||
|
||||
public HlsSessionWorker(
|
||||
IServiceScopeFactory serviceScopeFactory,
|
||||
@@ -215,6 +217,10 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
new GetPlayoutIdByChannelNumber(_channelNumber),
|
||||
cancellationToken);
|
||||
|
||||
_slugSeconds = await _mediator.Send(
|
||||
new GetSlugSecondsByChannelNumber(_channelNumber),
|
||||
cancellationToken);
|
||||
|
||||
// time shift on-demand playout if needed
|
||||
foreach (int playoutId in maybePlayoutId)
|
||||
{
|
||||
@@ -307,9 +313,6 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
var sw = Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
DateTimeOffset start = DateTimeOffset.Now;
|
||||
DateTimeOffset finish = start.AddSeconds(8);
|
||||
|
||||
string playlistFileName = Path.Combine(_workingDirectory, "live.m3u8");
|
||||
|
||||
_logger.LogDebug("Waiting for playlist to exist");
|
||||
@@ -320,6 +323,10 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
|
||||
_logger.LogDebug("Playlist exists");
|
||||
|
||||
// start the segment-wait deadline only after the playlist file appears,
|
||||
// so slow pipeline setup (e.g. h264 profile probing) doesn't consume the budget
|
||||
DateTimeOffset finish = DateTimeOffset.Now.AddSeconds(8);
|
||||
|
||||
var segmentCount = 0;
|
||||
int lastSegmentCount = -1;
|
||||
while (DateTimeOffset.Now < finish && segmentCount < initialSegmentCount)
|
||||
@@ -382,6 +389,14 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
// after seeking and NOT completing the item, seek again, transcode method will accelerate if needed
|
||||
HlsSessionState.SeekAndWorkAhead when !isComplete => HlsSessionState.SeekAndRealtime,
|
||||
|
||||
// switch back to normal item after slug
|
||||
HlsSessionState.SlugAndWorkAhead => HlsSessionState.ZeroAndWorkAhead,
|
||||
HlsSessionState.SlugAndRealtime => HlsSessionState.ZeroAndRealtime,
|
||||
|
||||
// after completing the item, insert a slug
|
||||
HlsSessionState.ZeroAndWorkAhead or HlsSessionState.SeekAndWorkAhead when isComplete && _slugSeconds.IsSome => HlsSessionState.SlugAndWorkAhead,
|
||||
HlsSessionState.ZeroAndRealtime or HlsSessionState.SeekAndRealtime when isComplete && _slugSeconds.IsSome => HlsSessionState.SlugAndRealtime,
|
||||
|
||||
// after seeking and completing the item, start at zero
|
||||
HlsSessionState.SeekAndWorkAhead => HlsSessionState.ZeroAndWorkAhead,
|
||||
|
||||
@@ -441,6 +456,7 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
{
|
||||
HlsSessionState.SeekAndWorkAhead => HlsSessionState.SeekAndRealtime,
|
||||
HlsSessionState.ZeroAndWorkAhead => HlsSessionState.ZeroAndRealtime,
|
||||
HlsSessionState.SlugAndWorkAhead => HlsSessionState.SlugAndRealtime,
|
||||
_ => _state
|
||||
};
|
||||
|
||||
@@ -456,19 +472,32 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
_logger.LogDebug("HLS session state: {State}", _state);
|
||||
|
||||
DateTimeOffset now = wasSeekAndWorkAhead ? DateTimeOffset.Now : _transcodedUntil;
|
||||
bool startAtZero = _state is HlsSessionState.ZeroAndWorkAhead or HlsSessionState.ZeroAndRealtime;
|
||||
bool startAtZero = _state is HlsSessionState.ZeroAndWorkAhead or HlsSessionState.ZeroAndRealtime
|
||||
or HlsSessionState.SlugAndWorkAhead or HlsSessionState.SlugAndRealtime;
|
||||
|
||||
var request = new GetPlayoutItemProcessByChannelNumber(
|
||||
_channelNumber,
|
||||
StreamingMode.HttpLiveStreamingSegmenter,
|
||||
now,
|
||||
startAtZero,
|
||||
realtime,
|
||||
_channelStart,
|
||||
ptsOffset,
|
||||
_targetFramerate,
|
||||
IsTroubleshooting: false,
|
||||
Option<int>.None);
|
||||
bool isSlug = _state is HlsSessionState.SlugAndWorkAhead or HlsSessionState.SlugAndRealtime;
|
||||
|
||||
FFmpegProcessRequest request = isSlug
|
||||
? new GetSlugProcessByChannelNumber(
|
||||
_channelNumber,
|
||||
StreamingMode.HttpLiveStreamingSegmenter,
|
||||
now,
|
||||
realtime,
|
||||
_channelStart,
|
||||
ptsOffset,
|
||||
_targetFramerate,
|
||||
_slugSeconds)
|
||||
: new GetPlayoutItemProcessByChannelNumber(
|
||||
_channelNumber,
|
||||
StreamingMode.HttpLiveStreamingSegmenter,
|
||||
now,
|
||||
startAtZero,
|
||||
realtime,
|
||||
_channelStart,
|
||||
ptsOffset,
|
||||
_targetFramerate,
|
||||
IsTroubleshooting: false,
|
||||
Option<int>.None);
|
||||
|
||||
// _logger.LogInformation("Request {@Request}", request);
|
||||
|
||||
@@ -528,9 +557,12 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
linkedCts.Token);
|
||||
}
|
||||
|
||||
var progressParser = new FFmpegProgress();
|
||||
|
||||
CommandResult commandResult = await processWithPipe
|
||||
.WithWorkingDirectory(_workingDirectory)
|
||||
.WithStandardErrorPipe(PipeTarget.ToStringBuilder(stdErrBuffer))
|
||||
.WithStandardOutputPipe(PipeTarget.ToDelegate(progressParser.ParseLine))
|
||||
.WithValidation(CommandResultValidation.None)
|
||||
.ExecuteAsync(linkedCts.Token);
|
||||
|
||||
@@ -538,12 +570,20 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
{
|
||||
_logger.LogDebug("HLS process has completed for channel {Channel}", _channelNumber);
|
||||
_logger.LogDebug(
|
||||
"Transcoded until: {Until} - Buffer: {Buffer} seconds",
|
||||
"Transcoded until: {Until} - Buffer: {Buffer} seconds - Speed {Speed}",
|
||||
processModel.Until,
|
||||
processModel.Until.Subtract(DateTimeOffset.Now).TotalSeconds);
|
||||
processModel.Until.Subtract(DateTimeOffset.Now).TotalSeconds,
|
||||
progressParser.Speed);
|
||||
_transcodedUntil = processModel.Until;
|
||||
_state = NextState(_state, processModel);
|
||||
_hasWrittenSegments = true;
|
||||
|
||||
progressParser.LogSpeed(
|
||||
processModel.MediaItemId,
|
||||
processModel.IsWorkingAhead,
|
||||
_channelNumber,
|
||||
_logger);
|
||||
|
||||
return true;
|
||||
}
|
||||
else
|
||||
|
||||
@@ -36,7 +36,8 @@ public class GetConcatProcessByChannelNumberHandler : FFmpegProcessHandler<GetCo
|
||||
saveReports,
|
||||
channel,
|
||||
request.Scheme,
|
||||
request.Host);
|
||||
request.Host,
|
||||
cancellationToken);
|
||||
|
||||
return new PlayoutItemProcessModel(
|
||||
process,
|
||||
|
||||
@@ -32,7 +32,8 @@ public class GetErrorProcessHandler(
|
||||
channel.FFmpegProfile.VaapiDisplay,
|
||||
channel.FFmpegProfile.VaapiDriver,
|
||||
channel.FFmpegProfile.VaapiDevice,
|
||||
Optional(channel.FFmpegProfile.QsvExtraHardwareFrames));
|
||||
Optional(channel.FFmpegProfile.QsvExtraHardwareFrames),
|
||||
cancellationToken);
|
||||
|
||||
return new PlayoutItemProcessModel(
|
||||
process,
|
||||
|
||||
@@ -321,7 +321,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
||||
|
||||
if (request.IsTroubleshooting)
|
||||
{
|
||||
channel.Number = ".troubleshooting";
|
||||
channel.Number = FileSystemLayout.TranscodeTroubleshootingChannel;
|
||||
}
|
||||
|
||||
if (_isDebugNoSync)
|
||||
@@ -337,7 +337,8 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
||||
channel.FFmpegProfile.VaapiDisplay,
|
||||
channel.FFmpegProfile.VaapiDriver,
|
||||
channel.FFmpegProfile.VaapiDevice,
|
||||
Optional(channel.FFmpegProfile.QsvExtraHardwareFrames));
|
||||
Optional(channel.FFmpegProfile.QsvExtraHardwareFrames),
|
||||
cancellationToken);
|
||||
|
||||
return new PlayoutItemProcessModel(
|
||||
doesNotExistProcess,
|
||||
@@ -498,7 +499,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
||||
Option<TimeSpan> maybeDuration = maybeNextStart.Map(s => s - now);
|
||||
|
||||
// limit working ahead on errors to 1 minute
|
||||
if (!request.HlsRealtime && maybeDuration.IfNone(TimeSpan.FromMinutes(2)) > TimeSpan.FromMinutes(1))
|
||||
if (!request.HlsRealtime && await maybeDuration.IfNoneAsync(TimeSpan.FromMinutes(2)) > TimeSpan.FromMinutes(1))
|
||||
{
|
||||
maybeNextStart = now.AddMinutes(1);
|
||||
maybeDuration = TimeSpan.FromMinutes(1);
|
||||
@@ -508,7 +509,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
||||
|
||||
if (request.IsTroubleshooting)
|
||||
{
|
||||
channel.Number = ".troubleshooting";
|
||||
channel.Number = FileSystemLayout.TranscodeTroubleshootingChannel;
|
||||
|
||||
maybeDuration = TimeSpan.FromSeconds(30);
|
||||
finish = now + TimeSpan.FromSeconds(30);
|
||||
@@ -534,7 +535,8 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
||||
channel.FFmpegProfile.VaapiDisplay,
|
||||
channel.FFmpegProfile.VaapiDriver,
|
||||
channel.FFmpegProfile.VaapiDevice,
|
||||
Optional(channel.FFmpegProfile.QsvExtraHardwareFrames));
|
||||
Optional(channel.FFmpegProfile.QsvExtraHardwareFrames),
|
||||
cancellationToken);
|
||||
|
||||
return new PlayoutItemProcessModel(
|
||||
offlineProcess,
|
||||
@@ -558,7 +560,8 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
||||
channel.FFmpegProfile.VaapiDisplay,
|
||||
channel.FFmpegProfile.VaapiDriver,
|
||||
channel.FFmpegProfile.VaapiDevice,
|
||||
Optional(channel.FFmpegProfile.QsvExtraHardwareFrames));
|
||||
Optional(channel.FFmpegProfile.QsvExtraHardwareFrames),
|
||||
cancellationToken);
|
||||
|
||||
return new PlayoutItemProcessModel(
|
||||
doesNotExistProcess,
|
||||
@@ -582,7 +585,8 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
||||
channel.FFmpegProfile.VaapiDisplay,
|
||||
channel.FFmpegProfile.VaapiDriver,
|
||||
channel.FFmpegProfile.VaapiDevice,
|
||||
Optional(channel.FFmpegProfile.QsvExtraHardwareFrames));
|
||||
Optional(channel.FFmpegProfile.QsvExtraHardwareFrames),
|
||||
cancellationToken);
|
||||
|
||||
return new PlayoutItemProcessModel(
|
||||
errorProcess,
|
||||
@@ -766,7 +770,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
// always play min(duration to next item, version.Duration)
|
||||
TimeSpan duration = maybeDuration.IfNone(version.Duration);
|
||||
TimeSpan duration = await maybeDuration.IfNoneAsync(version.Duration);
|
||||
if (version.Duration < duration)
|
||||
{
|
||||
duration = version.Duration;
|
||||
|
||||
@@ -21,19 +21,21 @@ public class GetSeekTextSubtitleProcessHandler(
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, string> validation = await Validate(dbContext, cancellationToken);
|
||||
return await validation.Match(
|
||||
ffmpegPath => GetProcess(request, ffmpegPath),
|
||||
ffmpegPath => GetProcess(request, ffmpegPath, cancellationToken),
|
||||
error => Task.FromResult<Either<BaseError, SeekTextSubtitleProcess>>(error.Join()));
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, SeekTextSubtitleProcess>> GetProcess(
|
||||
GetSeekTextSubtitleProcess request,
|
||||
string ffmpegPath)
|
||||
string ffmpegPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Command process = await ffmpegProcessService.SeekTextSubtitle(
|
||||
ffmpegPath,
|
||||
request.PathAndCodec.Path,
|
||||
request.PathAndCodec.Codec,
|
||||
request.Seek);
|
||||
request.Seek,
|
||||
cancellationToken);
|
||||
|
||||
return new SeekTextSubtitleProcess(process);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.FFmpeg;
|
||||
|
||||
namespace ErsatzTV.Application.Streaming;
|
||||
|
||||
public record GetSlugProcessByChannelNumber(
|
||||
string ChannelNumber,
|
||||
StreamingMode Mode,
|
||||
DateTimeOffset Now,
|
||||
bool HlsRealtime,
|
||||
DateTimeOffset ChannelStart,
|
||||
TimeSpan PtsOffset,
|
||||
Option<FrameRate> TargetFramerate,
|
||||
Option<double> SlugSeconds) : FFmpegProcessRequest(
|
||||
ChannelNumber,
|
||||
Mode,
|
||||
Now,
|
||||
StartAtZero: true,
|
||||
HlsRealtime,
|
||||
ChannelStart,
|
||||
PtsOffset,
|
||||
FFmpegProfileId: Option<int>.None);
|
||||
@@ -0,0 +1,53 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Streaming;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Streaming;
|
||||
|
||||
public class GetSlugProcessByChannelNumberHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IFFmpegProcessService ffmpegProcessService)
|
||||
: FFmpegProcessHandler<GetSlugProcessByChannelNumber>(dbContextFactory)
|
||||
{
|
||||
protected override async Task<Either<BaseError, PlayoutItemProcessModel>> GetProcess(
|
||||
TvContext dbContext,
|
||||
GetSlugProcessByChannelNumber request,
|
||||
Channel channel,
|
||||
string ffmpegPath,
|
||||
string ffprobePath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var duration = TimeSpan.FromSeconds(await request.SlugSeconds.IfNoneAsync(0));
|
||||
if (duration <= TimeSpan.Zero)
|
||||
{
|
||||
return BaseError.New("Slug seconds must be non-zero");
|
||||
}
|
||||
|
||||
DateTimeOffset finish = request.Now.Add(duration);
|
||||
|
||||
var playoutItemResult = await ffmpegProcessService.Slug(
|
||||
ffmpegPath,
|
||||
channel,
|
||||
request.Now,
|
||||
duration,
|
||||
request.HlsRealtime,
|
||||
request.PtsOffset,
|
||||
cancellationToken);
|
||||
|
||||
var result = new PlayoutItemProcessModel(
|
||||
playoutItemResult,
|
||||
Option<GraphicsEngineContext>.None,
|
||||
duration,
|
||||
finish,
|
||||
isComplete: true,
|
||||
request.Now.ToUnixTimeSeconds(),
|
||||
Option<int>.None,
|
||||
Optional(channel.PlayoutOffset),
|
||||
!request.HlsRealtime);
|
||||
|
||||
return Right<BaseError, PlayoutItemProcessModel>(result);
|
||||
}
|
||||
}
|
||||
@@ -185,7 +185,7 @@ public class PrepareTroubleshootingPlaybackHandler(
|
||||
{
|
||||
Artwork = [],
|
||||
Name = "ETV",
|
||||
Number = ".troubleshooting",
|
||||
Number = FileSystemLayout.TranscodeTroubleshootingChannel,
|
||||
FFmpegProfile = ffmpegProfile,
|
||||
StreamingMode = request.StreamingMode,
|
||||
StreamSelectorMode = ChannelStreamSelectorMode.Troubleshooting,
|
||||
|
||||
@@ -2,10 +2,10 @@ using System.IO.Pipelines;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.RegularExpressions;
|
||||
using CliWrap;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Locking;
|
||||
using ErsatzTV.Core.Interfaces.Streaming;
|
||||
using ErsatzTV.Core.Interfaces.Troubleshooting;
|
||||
@@ -17,7 +17,7 @@ using Serilog.Events;
|
||||
|
||||
namespace ErsatzTV.Application.Troubleshooting;
|
||||
|
||||
public partial class StartTroubleshootingPlaybackHandler(
|
||||
public class StartTroubleshootingPlaybackHandler(
|
||||
ITroubleshootingNotifier notifier,
|
||||
IMediator mediator,
|
||||
IEntityLocker entityLocker,
|
||||
@@ -138,14 +138,23 @@ public partial class StartTroubleshootingPlaybackHandler(
|
||||
linkedCts.Token);
|
||||
}
|
||||
|
||||
var progressParser = new FFmpegProgress();
|
||||
|
||||
CommandResult commandResult = await processWithPipe
|
||||
.WithWorkingDirectory(FileSystemLayout.TranscodeTroubleshootingFolder)
|
||||
.WithStandardErrorPipe(PipeTarget.Null)
|
||||
.WithStandardOutputPipe(PipeTarget.ToDelegate(progressParser.ParseLine))
|
||||
.WithValidation(CommandResultValidation.None)
|
||||
.ExecuteAsync(linkedCts.Token);
|
||||
|
||||
logger.LogDebug("Troubleshooting playback completed with exit code {ExitCode}", commandResult.ExitCode);
|
||||
|
||||
progressParser.LogSpeed(
|
||||
request.MediaItemInfo.Map(i => i.Id),
|
||||
true,
|
||||
FileSystemLayout.TranscodeTroubleshootingChannel,
|
||||
logger);
|
||||
|
||||
try
|
||||
{
|
||||
IEnumerable<string> logs = logService.Sink.GetLogs(request.SessionId);
|
||||
@@ -160,26 +169,11 @@ public partial class StartTroubleshootingPlaybackHandler(
|
||||
// do nothing
|
||||
}
|
||||
|
||||
Option<double> maybeSpeed = Option<double>.None;
|
||||
Option<string> maybeFile = Directory.GetFiles(FileSystemLayout.TranscodeTroubleshootingFolder, "ffmpeg*.log").HeadOrNone();
|
||||
foreach (string file in maybeFile)
|
||||
{
|
||||
await foreach (string line in File.ReadLinesAsync(file, linkedCts.Token))
|
||||
{
|
||||
Match match = FFmpegSpeed().Match(line);
|
||||
if (match.Success && double.TryParse(match.Groups[1].Value, out double speed))
|
||||
{
|
||||
maybeSpeed = speed;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await mediator.Publish(
|
||||
new PlaybackTroubleshootingCompletedNotification(
|
||||
commandResult.ExitCode,
|
||||
Option<Exception>.None,
|
||||
maybeSpeed),
|
||||
progressParser.Speed),
|
||||
linkedCts.Token);
|
||||
|
||||
if (commandResult.ExitCode != 0)
|
||||
@@ -210,7 +204,4 @@ public partial class StartTroubleshootingPlaybackHandler(
|
||||
loggingLevelSwitches.StreamingLevelSwitch.MinimumLevel = currentStreamingLevel;
|
||||
}
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"speed=\s*([\d\.]+)x", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex FFmpegSpeed();
|
||||
}
|
||||
|
||||
@@ -161,7 +161,8 @@ public class GetTroubleshootingInfoHandler : IRequestHandler<GetTroubleshootingI
|
||||
videoToolboxCapabilities.AppendLine();
|
||||
}
|
||||
|
||||
var ffmpegCapabilities = await _hardwareCapabilitiesFactory.GetFFmpegCapabilities(ffmpegPath.Value);
|
||||
var ffmpegCapabilities =
|
||||
await _hardwareCapabilitiesFactory.GetFFmpegCapabilities(ffmpegPath.Value, cancellationToken);
|
||||
aviSynthDemuxer = ffmpegCapabilities.HasDemuxFormat(FFmpegKnownFormat.AviSynth);
|
||||
aviSynthInstalled = _hardwareCapabilitiesFactory.IsAviSynthInstalled();
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<NoWarn>VSTHRD200</NoWarn>
|
||||
<NoWarn>VSTHRD200,CA1873</NoWarn>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -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.1.0" />
|
||||
</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
|
||||
{
|
||||
|
||||
34
ErsatzTV.Core.Tests/Scheduling/CountExpressionTests.cs
Normal file
34
ErsatzTV.Core.Tests/Scheduling/CountExpressionTests.cs
Normal file
@@ -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,7 +796,10 @@ public class PlayoutModeSchedulerBaseTests : SchedulerTestBase
|
||||
ProgramScheduleItem scheduleItem,
|
||||
ProgramScheduleItem nextScheduleItem,
|
||||
DateTimeOffset hardStop,
|
||||
Random random,
|
||||
CancellationToken cancellationToken) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
protected override string SchedulingContextName => "Test";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using LanguageExt.UnsafeValueAccess;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Core.Tests.Scheduling;
|
||||
|
||||
[TestFixture]
|
||||
public class ShuffleInOrderCollectionEnumeratorTests
|
||||
{
|
||||
[Test]
|
||||
public void Should_Not_Repeat_Items_Until_Cycle_Complete()
|
||||
{
|
||||
var collections = new List<CollectionWithItems>
|
||||
{
|
||||
new(
|
||||
0,
|
||||
0,
|
||||
"1",
|
||||
Enumerable.Range(1, 10).Select(i => new Movie { Id = i, MovieMetadata = [] }).Cast<MediaItem>().ToList(),
|
||||
true,
|
||||
PlaybackOrder.ShuffleInOrder,
|
||||
false),
|
||||
new(
|
||||
0,
|
||||
0,
|
||||
"2",
|
||||
Enumerable.Range(11, 20).Select(i => new Movie { Id = i, MovieMetadata = [] }).Cast<MediaItem>().ToList(),
|
||||
true,
|
||||
PlaybackOrder.ShuffleInOrder,
|
||||
false)
|
||||
};
|
||||
|
||||
var state = new CollectionEnumeratorState { Seed = 1234, Index = 0 };
|
||||
var enumerator = new ShuffleInOrderCollectionEnumerator(collections, state, false, CancellationToken.None);
|
||||
|
||||
var seenIds = new System.Collections.Generic.HashSet<int>();
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
enumerator.Current.IsSome.ShouldBeTrue();
|
||||
int id = enumerator.Current.ValueUnsafe().Id;
|
||||
seenIds.ShouldNotContain(id, $"at index {i}");
|
||||
seenIds.Add(id);
|
||||
enumerator.MoveNext(Option<DateTimeOffset>.None);
|
||||
}
|
||||
|
||||
seenIds.Count.ShouldBe(20);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_Handle_Single_Collection()
|
||||
{
|
||||
var collections = new List<CollectionWithItems>
|
||||
{
|
||||
new(
|
||||
0,
|
||||
0,
|
||||
"1",
|
||||
Enumerable.Range(1, 10).Select(i => new Movie { Id = i, MovieMetadata = [] }).Cast<MediaItem>().ToList(),
|
||||
true,
|
||||
PlaybackOrder.ShuffleInOrder,
|
||||
false)
|
||||
};
|
||||
|
||||
var state = new CollectionEnumeratorState { Seed = 1234, Index = 0 };
|
||||
var enumerator = new ShuffleInOrderCollectionEnumerator(collections, state, false, CancellationToken.None);
|
||||
|
||||
var seenIds = new List<int>();
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
seenIds.Add(enumerator.Current.ValueUnsafe().Id);
|
||||
enumerator.MoveNext(Option<DateTimeOffset>.None);
|
||||
}
|
||||
|
||||
seenIds.Count.ShouldBe(10);
|
||||
seenIds.ShouldBeInOrder(SortDirection.Ascending);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_Reshuffle_After_Cycle()
|
||||
{
|
||||
var collections = new List<CollectionWithItems>
|
||||
{
|
||||
new(
|
||||
0,
|
||||
0,
|
||||
"1",
|
||||
Enumerable.Range(1, 10).Select(i => new Movie { Id = i, MovieMetadata = [] }).Cast<MediaItem>().ToList(),
|
||||
true,
|
||||
PlaybackOrder.ShuffleInOrder,
|
||||
false)
|
||||
};
|
||||
|
||||
var state = new CollectionEnumeratorState { Seed = 1234, Index = 0 };
|
||||
var enumerator = new ShuffleInOrderCollectionEnumerator(collections, state, false, CancellationToken.None);
|
||||
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
enumerator.MoveNext(Option<DateTimeOffset>.None);
|
||||
}
|
||||
|
||||
enumerator.State.Index.ShouldBe(0);
|
||||
// Should have a new seed
|
||||
enumerator.State.Seed.ShouldNotBe(1234);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ResetState_Should_Update_Seed()
|
||||
{
|
||||
var collections = new List<CollectionWithItems>
|
||||
{
|
||||
new(
|
||||
0,
|
||||
0,
|
||||
"1",
|
||||
Enumerable.Range(1, 10).Select(i => new Movie { Id = i, MovieMetadata = [] }).Cast<MediaItem>().ToList(),
|
||||
true,
|
||||
PlaybackOrder.ShuffleInOrder,
|
||||
false)
|
||||
};
|
||||
|
||||
var state = new CollectionEnumeratorState { Seed = 1234, Index = 0 };
|
||||
var enumerator = new ShuffleInOrderCollectionEnumerator(collections, state, false, CancellationToken.None);
|
||||
|
||||
var newState = new CollectionEnumeratorState { Seed = 5678, Index = 5 };
|
||||
enumerator.ResetState(newState);
|
||||
|
||||
enumerator.State.Seed.ShouldBe(5678);
|
||||
enumerator.State.Index.ShouldBe(5);
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ public class Channel
|
||||
public string Categories { get; set; }
|
||||
public int FFmpegProfileId { get; set; }
|
||||
public FFmpegProfile FFmpegProfile { get; set; }
|
||||
public double? SlugSeconds { get; set; }
|
||||
public int? WatermarkId { get; set; }
|
||||
public ChannelWatermark Watermark { get; set; }
|
||||
public int? FallbackFillerId { get; set; }
|
||||
|
||||
@@ -32,6 +32,7 @@ public class ConfigElementKey
|
||||
public static ConfigElementKey HDHRTunerCount => new("hdhr.tuner_count");
|
||||
public static ConfigElementKey HDHRUUID => new("hdhr.uuid");
|
||||
public static ConfigElementKey PagesIsDarkMode => new("pages.is_dark_mode");
|
||||
public static ConfigElementKey PagesLanguage => new("pages.language");
|
||||
public static ConfigElementKey ChannelsPageSize => new("pages.channels.page_size");
|
||||
public static ConfigElementKey ChannelsShowDisabled => new("pages.channels.show_disabled");
|
||||
public static ConfigElementKey CollectionsPageSize => new("pages.collections.page_size");
|
||||
|
||||
@@ -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
ErsatzTV.Core/Domain/FilterMode.cs
Normal file
7
ErsatzTV.Core/Domain/FilterMode.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace ErsatzTV.Core.Domain;
|
||||
|
||||
public enum FilterMode
|
||||
{
|
||||
HardwareIfPossible = 0,
|
||||
Software = 1
|
||||
}
|
||||
@@ -6,5 +6,6 @@ public enum MarathonGroupBy
|
||||
Show = 1,
|
||||
Season = 2,
|
||||
Artist = 3,
|
||||
Album = 4
|
||||
Album = 4,
|
||||
Director = 5
|
||||
}
|
||||
|
||||
@@ -37,6 +37,9 @@ public class PlayoutItem
|
||||
public DateTimeOffset StartOffset => new DateTimeOffset(Start, TimeSpan.Zero).ToLocalTime();
|
||||
public DateTimeOffset FinishOffset => new DateTimeOffset(Finish, TimeSpan.Zero).ToLocalTime();
|
||||
|
||||
// for troubleshooting
|
||||
public string SchedulingContext { get; set; }
|
||||
|
||||
public DateTimeOffset? GuideFinishOffset => GuideFinish.HasValue
|
||||
? new DateTimeOffset(GuideFinish.Value, TimeSpan.Zero).ToLocalTime()
|
||||
: null;
|
||||
@@ -81,7 +84,8 @@ public class PlayoutItem
|
||||
CollectionKey = CollectionKey,
|
||||
CollectionEtag = CollectionEtag,
|
||||
PlayoutItemWatermarks = watermarksCopy,
|
||||
PlayoutItemGraphicsElements = graphicsElementsCopy
|
||||
PlayoutItemGraphicsElements = graphicsElementsCopy,
|
||||
SchedulingContext = SchedulingContext
|
||||
};
|
||||
}
|
||||
|
||||
@@ -127,7 +131,8 @@ public class PlayoutItem
|
||||
CollectionKey = CollectionKey,
|
||||
CollectionEtag = CollectionEtag,
|
||||
PlayoutItemWatermarks = watermarksCopy,
|
||||
PlayoutItemGraphicsElements = graphicsElementsCopy
|
||||
PlayoutItemGraphicsElements = graphicsElementsCopy,
|
||||
SchedulingContext = SchedulingContext
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -4,5 +4,5 @@ public class ProgramScheduleItemMultiple : ProgramScheduleItem
|
||||
{
|
||||
public MultipleMode MultipleMode { get; set; }
|
||||
|
||||
public int Count { get; set; }
|
||||
public string Count { get; set; }
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<NoWarn>VSTHRD200</NoWarn>
|
||||
<NoWarn>VSTHRD200,CA1873</NoWarn>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<AnalysisLevel>latest-Recommended</AnalysisLevel>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
@@ -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" />
|
||||
|
||||
25
ErsatzTV.Core/Extensions/ProgramScheduleItemExtensions.cs
Normal file
25
ErsatzTV.Core/Extensions/ProgramScheduleItemExtensions.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace ErsatzTV.Core.Extensions;
|
||||
|
||||
public static class ProgramScheduleItemExtensions
|
||||
{
|
||||
public static ProgramScheduleItem DeepCopy(this ProgramScheduleItem item)
|
||||
{
|
||||
if (item == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var settings = new JsonSerializerSettings
|
||||
{
|
||||
// program schedule item => graphics element => (same) program schedule item should be ignored
|
||||
ReferenceLoopHandling = ReferenceLoopHandling.Ignore,
|
||||
NullValueHandling = NullValueHandling.Ignore
|
||||
};
|
||||
|
||||
string json = JsonConvert.SerializeObject(item, settings);
|
||||
return (ProgramScheduleItem)JsonConvert.DeserializeObject(json, item.GetType(), settings);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Globalization;
|
||||
using System.IO.Abstractions;
|
||||
using System.IO.Enumeration;
|
||||
using ErsatzTV.Core.Domain;
|
||||
@@ -315,7 +316,8 @@ public class CustomStreamSelector(IFileSystem fileSystem, ILogger<CustomStreamSe
|
||||
{
|
||||
"channel_number" => channel.Number,
|
||||
"channel_name" => channel.Name,
|
||||
"time_of_day_seconds" => contentStartTime.LocalDateTime.TimeOfDay.TotalSeconds,
|
||||
"time_of_day_seconds" => contentStartTime.TimeOfDay.TotalSeconds,
|
||||
"day_of_week" => GetLocalizedDayOfWeekIndex(contentStartTime),
|
||||
_ => e.Result
|
||||
};
|
||||
};
|
||||
@@ -341,4 +343,11 @@ public class CustomStreamSelector(IFileSystem fileSystem, ILogger<CustomStreamSe
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static int GetLocalizedDayOfWeekIndex(DateTimeOffset date)
|
||||
{
|
||||
DayOfWeek first = CultureInfo.CurrentCulture.DateTimeFormat.FirstDayOfWeek;
|
||||
DayOfWeek current = date.DayOfWeek;
|
||||
return ((int)current - (int)first + 7) % 7;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,6 +233,16 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
scanKind = await ProbeScanKind(ffmpegPath, videoVersion.MediaItem, cancellationToken);
|
||||
}
|
||||
|
||||
HardwareAccelerationMode hwAccel = GetHardwareAccelerationMode(playbackSettings);
|
||||
|
||||
// QSV may have sync issues with h264 files that have multiple profiles
|
||||
// check and flag here so software decoding can be used if needed
|
||||
bool hasMultipleProfiles = false;
|
||||
if (hwAccel is HardwareAccelerationMode.Qsv && videoStream.Codec is VideoFormat.H264)
|
||||
{
|
||||
hasMultipleProfiles = await ProbeHasMultipleProfiles(ffmpegPath, videoVersion.MediaItem, cancellationToken);
|
||||
}
|
||||
|
||||
var ffmpegVideoStream = new VideoStream(
|
||||
videoStream.Index,
|
||||
videoStream.Codec,
|
||||
@@ -248,7 +258,10 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
videoVersion.MediaVersion.DisplayAspectRatio,
|
||||
new FrameRate(videoVersion.MediaVersion.RFrameRate),
|
||||
videoPath != audioPath, // still image when paths are different
|
||||
scanKind);
|
||||
scanKind)
|
||||
{
|
||||
HasMultipleProfiles = hasMultipleProfiles
|
||||
};
|
||||
|
||||
var videoInputFile = new VideoInputFile(
|
||||
videoPath,
|
||||
@@ -405,8 +418,6 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
graphicsElementContexts.AddRange(watermarks.Map(wm => new WatermarkElementContext(wm)));
|
||||
}
|
||||
|
||||
HardwareAccelerationMode hwAccel = GetHardwareAccelerationMode(playbackSettings);
|
||||
|
||||
string videoFormat = GetVideoFormat(playbackSettings);
|
||||
Option<string> maybeVideoProfile = GetVideoProfile(videoFormat, channel.FFmpegProfile.VideoProfile);
|
||||
Option<string> maybeVideoPreset = GetVideoPreset(
|
||||
@@ -509,15 +520,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);
|
||||
|
||||
@@ -534,7 +549,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
frameRate,
|
||||
channelStartTime,
|
||||
start,
|
||||
await playbackSettings.StreamSeek.IfNoneAsync(TimeSpan.Zero),
|
||||
now > start ? now - start : TimeSpan.Zero,
|
||||
finish - now,
|
||||
originalContentDuration);
|
||||
|
||||
@@ -572,7 +587,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
videoVersion.MediaVersion is BackgroundImageMediaVersion { IsSongWithProgress: true },
|
||||
false,
|
||||
GetTonemapAlgorithm(playbackSettings),
|
||||
channel.Number == ".troubleshooting");
|
||||
channel.Number == FileSystemLayout.TranscodeTroubleshootingChannel);
|
||||
|
||||
_logger.LogDebug("FFmpeg desired state {FrameState}", desiredState);
|
||||
|
||||
@@ -589,7 +604,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
VaapiDeviceName(hwAccel, vaapiDevice),
|
||||
await customReportsFolder.IfNoneAsync(FileSystemLayout.FFmpegReportsFolder),
|
||||
FileSystemLayout.FontsCacheFolder,
|
||||
ffmpegPath);
|
||||
ffmpegPath,
|
||||
cancellationToken);
|
||||
|
||||
FFmpegPipeline pipeline = pipelineBuilder.Build(ffmpegState, desiredState);
|
||||
|
||||
@@ -648,6 +664,19 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task<bool> ProbeHasMultipleProfiles(
|
||||
string ffmpegPath,
|
||||
MediaItem mediaItem,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogDebug("Will probe for h264 profile count");
|
||||
|
||||
Option<int> profileCount =
|
||||
await _localStatisticsProvider.GetProfileCount(ffmpegPath, mediaItem, cancellationToken);
|
||||
|
||||
return await profileCount.IfNoneAsync(1) > 1;
|
||||
}
|
||||
|
||||
public async Task<Command> ForError(
|
||||
string ffmpegPath,
|
||||
Channel channel,
|
||||
@@ -659,9 +688,10 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
string vaapiDisplay,
|
||||
VaapiDriver vaapiDriver,
|
||||
string vaapiDevice,
|
||||
Option<int> qsvExtraHardwareFrames)
|
||||
Option<int> qsvExtraHardwareFrames,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
FFmpegPlaybackSettings playbackSettings = FFmpegPlaybackSettingsCalculator.CalculateErrorSettings(
|
||||
FFmpegPlaybackSettings playbackSettings = FFmpegPlaybackSettingsCalculator.CalculateGeneratedImageSettings(
|
||||
channel.StreamingMode,
|
||||
channel.FFmpegProfile,
|
||||
hlsRealtime);
|
||||
@@ -711,64 +741,17 @@ 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;
|
||||
switch (channel.StreamingMode)
|
||||
{
|
||||
case StreamingMode.HttpLiveStreamingSegmenter:
|
||||
outputFormat = OutputFormatKind.Hls;
|
||||
break;
|
||||
}
|
||||
|
||||
Option<string> hlsPlaylistPath = outputFormat is OutputFormatKind.Hls or OutputFormatKind.HlsMp4
|
||||
? Path.Combine(FileSystemLayout.TranscodeFolder, channel.Number, "live.m3u8")
|
||||
: Option<string>.None;
|
||||
|
||||
long nowSeconds = now.ToUnixTimeSeconds();
|
||||
|
||||
Option<string> hlsSegmentTemplate = outputFormat switch
|
||||
{
|
||||
OutputFormatKind.Hls => Path.Combine(FileSystemLayout.TranscodeFolder, channel.Number, "live%06d.ts"),
|
||||
OutputFormatKind.HlsMp4 => Path.Combine(
|
||||
FileSystemLayout.TranscodeFolder,
|
||||
channel.Number,
|
||||
$"live_{nowSeconds}_%06d.m4s"),
|
||||
_ => Option<string>.None
|
||||
};
|
||||
|
||||
Option<string> hlsInitTemplate = outputFormat switch
|
||||
{
|
||||
OutputFormatKind.HlsMp4 => $"{nowSeconds}_init.mp4",
|
||||
_ => Option<string>.None
|
||||
};
|
||||
|
||||
Option<string> hlsSegmentOptions = Option<string>.None;
|
||||
if (outputFormat is OutputFormatKind.Hls)
|
||||
{
|
||||
string options = string.Empty;
|
||||
|
||||
if (ptsOffset == TimeSpan.Zero)
|
||||
{
|
||||
options += "+initial_discontinuity";
|
||||
}
|
||||
|
||||
if (audioFormat == AudioFormat.AacLatm)
|
||||
{
|
||||
options += "+latm";
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options))
|
||||
{
|
||||
hlsSegmentOptions = $"mpegts_flags={options}";
|
||||
}
|
||||
}
|
||||
|
||||
string videoPath = _localFileSystem.GetCustomOrDefaultFile(
|
||||
FileSystemLayout.ResourcesCacheFolder,
|
||||
"background.png");
|
||||
@@ -794,8 +777,10 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
HardwareAccelerationMode hwAccel = GetHardwareAccelerationMode(playbackSettings);
|
||||
_logger.LogDebug("HW accel mode: {HwAccel}", hwAccel);
|
||||
|
||||
var hlsOptions = GetHlsOptions(channel, now, ptsOffset, audioFormat);
|
||||
|
||||
var ffmpegState = new FFmpegState(
|
||||
channel.Number == ".troubleshooting",
|
||||
channel.Number == FileSystemLayout.TranscodeTroubleshootingChannel,
|
||||
HardwareAccelerationMode.None, // no hw accel decode since errors loop
|
||||
hwAccel,
|
||||
VaapiDriverName(hwAccel, vaapiDriver),
|
||||
@@ -808,18 +793,18 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
outputFormat,
|
||||
hlsPlaylistPath,
|
||||
hlsSegmentTemplate,
|
||||
hlsInitTemplate,
|
||||
hlsSegmentOptions,
|
||||
hlsOptions.OutputFormat,
|
||||
hlsOptions.HlsPlaylistPath,
|
||||
hlsOptions.HlsSegmentTemplate,
|
||||
hlsOptions.HlsInitTemplate,
|
||||
hlsOptions.HlsSegmentOptions,
|
||||
ptsOffset,
|
||||
Option<int>.None,
|
||||
qsvExtraHardwareFrames,
|
||||
false,
|
||||
false,
|
||||
GetTonemapAlgorithm(playbackSettings),
|
||||
channel.Number == ".troubleshooting");
|
||||
channel.Number == FileSystemLayout.TranscodeTroubleshootingChannel);
|
||||
|
||||
var ffmpegSubtitleStream = new ErsatzTV.FFmpeg.MediaStream(0, "ass", StreamKind.Video);
|
||||
|
||||
@@ -843,11 +828,144 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
VaapiDisplayName(hwAccel, vaapiDisplay),
|
||||
VaapiDriverName(hwAccel, vaapiDriver),
|
||||
VaapiDeviceName(hwAccel, vaapiDevice),
|
||||
channel.Number == ".troubleshooting"
|
||||
channel.Number == FileSystemLayout.TranscodeTroubleshootingChannel
|
||||
? FileSystemLayout.TranscodeTroubleshootingFolder
|
||||
: FileSystemLayout.FFmpegReportsFolder,
|
||||
FileSystemLayout.FontsCacheFolder,
|
||||
ffmpegPath);
|
||||
ffmpegPath,
|
||||
cancellationToken);
|
||||
|
||||
FFmpegPipeline pipeline = pipelineBuilder.Build(ffmpegState, desiredState);
|
||||
|
||||
return GetCommand(ffmpegPath, videoInputFile, audioInputFile, None, None, None, pipeline);
|
||||
}
|
||||
|
||||
public async Task<Command> Slug(
|
||||
string ffmpegPath,
|
||||
Channel channel,
|
||||
DateTimeOffset now,
|
||||
TimeSpan duration,
|
||||
bool hlsRealtime,
|
||||
TimeSpan ptsOffset,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
FFmpegPlaybackSettings playbackSettings = FFmpegPlaybackSettingsCalculator.CalculateGeneratedImageSettings(
|
||||
channel.StreamingMode,
|
||||
channel.FFmpegProfile,
|
||||
hlsRealtime);
|
||||
|
||||
Resolution desiredResolution = channel.FFmpegProfile.Resolution;
|
||||
|
||||
string audioFormat = playbackSettings.AudioFormat switch
|
||||
{
|
||||
FFmpegProfileAudioFormat.Ac3 => AudioFormat.Ac3,
|
||||
FFmpegProfileAudioFormat.AacLatm => AudioFormat.AacLatm,
|
||||
_ => AudioFormat.Aac
|
||||
};
|
||||
|
||||
var audioState = new AudioState(
|
||||
audioFormat,
|
||||
playbackSettings.AudioChannels,
|
||||
playbackSettings.AudioBitrate,
|
||||
playbackSettings.AudioBufferSize,
|
||||
playbackSettings.AudioSampleRate,
|
||||
false,
|
||||
AudioFilter.None,
|
||||
playbackSettings.TargetLoudness);
|
||||
|
||||
string videoFormat = GetVideoFormat(playbackSettings);
|
||||
|
||||
var desiredState = new FrameState(
|
||||
playbackSettings.RealtimeOutput,
|
||||
InfiniteLoop: false,
|
||||
videoFormat,
|
||||
GetVideoProfile(videoFormat, channel.FFmpegProfile.VideoProfile),
|
||||
VideoPreset.Unset,
|
||||
channel.FFmpegProfile.AllowBFrames,
|
||||
new PixelFormatYuv420P(),
|
||||
new FrameSize(desiredResolution.Width, desiredResolution.Height),
|
||||
new FrameSize(desiredResolution.Width, desiredResolution.Height),
|
||||
Option<FrameSize>.None,
|
||||
channel.FFmpegProfile.PadMode is FilterMode.HardwareIfPossible
|
||||
? FFmpegFilterMode.HardwareIfPossible
|
||||
: FFmpegFilterMode.Software,
|
||||
IsAnamorphic: false,
|
||||
Option<FrameRate>.None,
|
||||
playbackSettings.VideoBitrate,
|
||||
playbackSettings.VideoBufferSize,
|
||||
playbackSettings.VideoTrackTimeScale,
|
||||
playbackSettings.NormalizeColors,
|
||||
playbackSettings.Deinterlace);
|
||||
|
||||
var frameRate = await playbackSettings.FrameRate.IfNoneAsync(new FrameRate("24"));
|
||||
|
||||
var ffmpegVideoStream = new VideoStream(
|
||||
0,
|
||||
VideoFormat.GeneratedImage,
|
||||
string.Empty,
|
||||
new PixelFormatUnknown(), // leave this unknown so we convert to desired yuv420p
|
||||
ColorParams.Default,
|
||||
desiredState.PaddedSize,
|
||||
MaybeSampleAspectRatio: "1:1",
|
||||
DisplayAspectRatio: string.Empty,
|
||||
frameRate,
|
||||
true,
|
||||
ScanKind.Progressive);
|
||||
|
||||
var videoInputFile = new LavfiInputFile(
|
||||
$"color=c=black:s={desiredState.PaddedSize.Width}x{desiredState.PaddedSize.Height}:r={frameRate.FrameRateString}:d={duration.TotalSeconds}",
|
||||
ffmpegVideoStream);
|
||||
|
||||
HardwareAccelerationMode hwAccel = GetHardwareAccelerationMode(playbackSettings);
|
||||
|
||||
var hlsOptions = GetHlsOptions(channel, now, ptsOffset, audioFormat);
|
||||
|
||||
var ffmpegState = new FFmpegState(
|
||||
channel.Number == FileSystemLayout.TranscodeTroubleshootingChannel,
|
||||
HardwareAccelerationMode.None, // no hw accel decode since errors loop
|
||||
hwAccel,
|
||||
VaapiDriverName(hwAccel, channel.FFmpegProfile.VaapiDriver),
|
||||
VaapiDeviceName(hwAccel, channel.FFmpegProfile.VaapiDevice),
|
||||
playbackSettings.StreamSeek,
|
||||
duration,
|
||||
channel.StreamingMode != StreamingMode.HttpLiveStreamingDirect,
|
||||
"ErsatzTV",
|
||||
channel.Name,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
hlsOptions.OutputFormat,
|
||||
hlsOptions.HlsPlaylistPath,
|
||||
hlsOptions.HlsSegmentTemplate,
|
||||
hlsOptions.HlsInitTemplate,
|
||||
hlsOptions.HlsSegmentOptions,
|
||||
ptsOffset,
|
||||
Option<int>.None,
|
||||
Optional(channel.FFmpegProfile.QsvExtraHardwareFrames),
|
||||
false,
|
||||
false,
|
||||
GetTonemapAlgorithm(playbackSettings),
|
||||
channel.Number == FileSystemLayout.TranscodeTroubleshootingChannel);
|
||||
|
||||
var audioInputFile = new NullAudioInputFile(audioState);
|
||||
|
||||
IPipelineBuilder pipelineBuilder = await _pipelineBuilderFactory.GetBuilder(
|
||||
hwAccel,
|
||||
videoInputFile,
|
||||
audioInputFile,
|
||||
Option<WatermarkInputFile>.None,
|
||||
Option<SubtitleInputFile>.None,
|
||||
Option<ConcatInputFile>.None,
|
||||
Option<GraphicsEngineInput>.None,
|
||||
VaapiDisplayName(hwAccel, channel.FFmpegProfile.VaapiDisplay),
|
||||
VaapiDriverName(hwAccel, channel.FFmpegProfile.VaapiDriver),
|
||||
VaapiDeviceName(hwAccel, channel.FFmpegProfile.VaapiDevice),
|
||||
channel.Number == FileSystemLayout.TranscodeTroubleshootingChannel
|
||||
? FileSystemLayout.TranscodeTroubleshootingFolder
|
||||
: FileSystemLayout.FFmpegReportsFolder,
|
||||
FileSystemLayout.FontsCacheFolder,
|
||||
ffmpegPath,
|
||||
cancellationToken);
|
||||
|
||||
FFmpegPipeline pipeline = pipelineBuilder.Build(ffmpegState, desiredState);
|
||||
|
||||
@@ -859,7 +977,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
bool saveReports,
|
||||
Channel channel,
|
||||
string scheme,
|
||||
string host)
|
||||
string host,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var resolution = new FrameSize(channel.FFmpegProfile.Resolution.Width, channel.FFmpegProfile.Resolution.Height);
|
||||
|
||||
@@ -880,7 +999,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
Option<string>.None,
|
||||
FileSystemLayout.FFmpegReportsFolder,
|
||||
FileSystemLayout.FontsCacheFolder,
|
||||
ffmpegPath);
|
||||
ffmpegPath,
|
||||
cancellationToken);
|
||||
|
||||
FFmpegPipeline pipeline = pipelineBuilder.Concat(
|
||||
concatInputFile,
|
||||
@@ -950,7 +1070,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
Option<string>.None,
|
||||
FileSystemLayout.FFmpegReportsFolder,
|
||||
FileSystemLayout.FontsCacheFolder,
|
||||
ffmpegPath);
|
||||
ffmpegPath,
|
||||
cancellationToken);
|
||||
|
||||
FFmpegPipeline pipeline = pipelineBuilder.WrapSegmenter(
|
||||
concatInputFile,
|
||||
@@ -959,7 +1080,12 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
return GetCommand(ffmpegPath, None, None, None, concatInputFile, None, pipeline);
|
||||
}
|
||||
|
||||
public async Task<Command> ResizeImage(string ffmpegPath, string inputFile, string outputFile, int height)
|
||||
public async Task<Command> ResizeImage(
|
||||
string ffmpegPath,
|
||||
string inputFile,
|
||||
string outputFile,
|
||||
int height,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var videoInputFile = new VideoInputFile(
|
||||
inputFile,
|
||||
@@ -992,7 +1118,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
Option<string>.None,
|
||||
FileSystemLayout.FFmpegReportsFolder,
|
||||
FileSystemLayout.FontsCacheFolder,
|
||||
ffmpegPath);
|
||||
ffmpegPath,
|
||||
cancellationToken);
|
||||
|
||||
FFmpegPipeline pipeline = pipelineBuilder.Resize(outputFile, new FrameSize(-1, height));
|
||||
|
||||
@@ -1028,7 +1155,12 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
watermarkWidthPercent,
|
||||
cancellationToken);
|
||||
|
||||
public async Task<Command> SeekTextSubtitle(string ffmpegPath, string inputFile, string codec, TimeSpan seek)
|
||||
public async Task<Command> SeekTextSubtitle(
|
||||
string ffmpegPath,
|
||||
string inputFile,
|
||||
string codec,
|
||||
TimeSpan seek,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var videoInputFile = new VideoInputFile(
|
||||
inputFile,
|
||||
@@ -1061,7 +1193,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
Option<string>.None,
|
||||
FileSystemLayout.FFmpegReportsFolder,
|
||||
FileSystemLayout.FontsCacheFolder,
|
||||
ffmpegPath);
|
||||
ffmpegPath,
|
||||
cancellationToken);
|
||||
|
||||
FFmpegPipeline pipeline = pipelineBuilder.Seek(inputFile, codec, seek);
|
||||
|
||||
@@ -1252,4 +1385,67 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static HlsOptions GetHlsOptions(Channel channel, DateTimeOffset now, TimeSpan ptsOffset, string audioFormat)
|
||||
{
|
||||
OutputFormatKind outputFormat = OutputFormatKind.MpegTs;
|
||||
switch (channel.StreamingMode)
|
||||
{
|
||||
case StreamingMode.HttpLiveStreamingSegmenter:
|
||||
outputFormat = OutputFormatKind.Hls;
|
||||
break;
|
||||
}
|
||||
|
||||
Option<string> hlsPlaylistPath = outputFormat is OutputFormatKind.Hls or OutputFormatKind.HlsMp4
|
||||
? Path.Combine(FileSystemLayout.TranscodeFolder, channel.Number, "live.m3u8")
|
||||
: Option<string>.None;
|
||||
|
||||
long nowSeconds = now.ToUnixTimeSeconds();
|
||||
|
||||
Option<string> hlsSegmentTemplate = outputFormat switch
|
||||
{
|
||||
OutputFormatKind.Hls => Path.Combine(FileSystemLayout.TranscodeFolder, channel.Number, "live%06d.ts"),
|
||||
OutputFormatKind.HlsMp4 => Path.Combine(
|
||||
FileSystemLayout.TranscodeFolder,
|
||||
channel.Number,
|
||||
$"live_{nowSeconds}_%06d.m4s"),
|
||||
_ => Option<string>.None
|
||||
};
|
||||
|
||||
Option<string> hlsInitTemplate = outputFormat switch
|
||||
{
|
||||
OutputFormatKind.HlsMp4 => $"{nowSeconds}_init.mp4",
|
||||
_ => Option<string>.None
|
||||
};
|
||||
|
||||
Option<string> hlsSegmentOptions = Option<string>.None;
|
||||
if (outputFormat is OutputFormatKind.Hls)
|
||||
{
|
||||
string options = string.Empty;
|
||||
|
||||
if (ptsOffset == TimeSpan.Zero)
|
||||
{
|
||||
options += "+initial_discontinuity";
|
||||
}
|
||||
|
||||
if (audioFormat == AudioFormat.AacLatm)
|
||||
{
|
||||
options += "+latm";
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options))
|
||||
{
|
||||
hlsSegmentOptions = $"mpegts_flags={options}";
|
||||
}
|
||||
}
|
||||
|
||||
return new HlsOptions(outputFormat, hlsPlaylistPath, hlsSegmentTemplate, hlsInitTemplate, hlsSegmentOptions);
|
||||
}
|
||||
|
||||
private sealed record HlsOptions(
|
||||
OutputFormatKind OutputFormat,
|
||||
Option<string> HlsPlaylistPath,
|
||||
Option<string> HlsSegmentTemplate,
|
||||
Option<string> HlsInitTemplate,
|
||||
Option<string> HlsSegmentOptions);
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
@@ -176,7 +181,7 @@ public static class FFmpegPlaybackSettingsCalculator
|
||||
return result;
|
||||
}
|
||||
|
||||
public static FFmpegPlaybackSettings CalculateErrorSettings(
|
||||
public static FFmpegPlaybackSettings CalculateGeneratedImageSettings(
|
||||
StreamingMode streamingMode,
|
||||
FFmpegProfile ffmpegProfile,
|
||||
bool hlsRealtime) =>
|
||||
|
||||
@@ -71,7 +71,7 @@ public class FFmpegProcessService
|
||||
}
|
||||
|
||||
FFmpegPlaybackSettings playbackSettings =
|
||||
FFmpegPlaybackSettingsCalculator.CalculateErrorSettings(
|
||||
FFmpegPlaybackSettingsCalculator.CalculateGeneratedImageSettings(
|
||||
StreamingMode.TransportStream,
|
||||
channel.FFmpegProfile,
|
||||
false);
|
||||
|
||||
55
ErsatzTV.Core/FFmpeg/FFmpegProgress.cs
Normal file
55
ErsatzTV.Core/FFmpeg/FFmpegProgress.cs
Normal file
@@ -0,0 +1,55 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Core.FFmpeg;
|
||||
|
||||
public partial class FFmpegProgress
|
||||
{
|
||||
public Option<double> Speed { get; private set; } = Option<double>.None;
|
||||
|
||||
public void ParseLine(string line)
|
||||
{
|
||||
Match match = FFmpegSpeed().Match(line);
|
||||
if (match.Success && double.TryParse(match.Groups[1].Value, out double speed))
|
||||
{
|
||||
Speed = speed;
|
||||
}
|
||||
}
|
||||
|
||||
public void LogSpeed(Option<int> mediaItemId, bool isWorkingAhead, string channelNumber, ILogger logger)
|
||||
{
|
||||
foreach (double speed in Speed)
|
||||
{
|
||||
if (isWorkingAhead)
|
||||
{
|
||||
if (speed < 1.0)
|
||||
{
|
||||
logger.LogCritical(
|
||||
"Media item {MediaItemId} on channel {Channel} transcoded at {Speed}x (NOT throttled) which is NOT fast enough to support playback",
|
||||
mediaItemId,
|
||||
channelNumber,
|
||||
speed);
|
||||
}
|
||||
else if (speed <= 1.5)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Media item {MediaItemId} on channel {Channel} transcoded at {Speed}x (NOT throttled) which may not be fast enough to support playback",
|
||||
mediaItemId,
|
||||
channelNumber,
|
||||
speed);
|
||||
}
|
||||
}
|
||||
else if (speed < 0.99)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Media item {MediaItemId} on channel {Channel} transcoded at {Speed}x (throttled) which may not be fast enough to support playback",
|
||||
mediaItemId,
|
||||
channelNumber,
|
||||
speed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"speed=\s*([\d\.]+)x", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex FFmpegSpeed();
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ public static class FileSystemLayout
|
||||
public static readonly string AppDataFolder;
|
||||
|
||||
public static readonly string TranscodeFolder;
|
||||
public static readonly string TranscodeTroubleshootingChannel;
|
||||
public static readonly string TranscodeTroubleshootingFolder;
|
||||
|
||||
public static readonly string DataProtectionFolder;
|
||||
@@ -132,7 +133,8 @@ public static class FileSystemLayout
|
||||
}
|
||||
|
||||
TranscodeFolder = useCustomTranscodeFolder ? customTranscodeFolder : defaultTranscodeFolder;
|
||||
TranscodeTroubleshootingFolder = Path.Combine(TranscodeFolder, ".troubleshooting");
|
||||
TranscodeTroubleshootingChannel = ".troubleshooting";
|
||||
TranscodeTroubleshootingFolder = Path.Combine(TranscodeFolder, TranscodeTroubleshootingChannel);
|
||||
|
||||
DataProtectionFolder = Path.Combine(AppDataFolder, "data-protection");
|
||||
LogsFolder = Path.Combine(AppDataFolder, "logs");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
5
ErsatzTV.Core/Health/Checks/IEmptyScheduleHealthCheck.cs
Normal file
5
ErsatzTV.Core/Health/Checks/IEmptyScheduleHealthCheck.cs
Normal file
@@ -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
|
||||
{
|
||||
}
|
||||
@@ -56,9 +56,25 @@ public interface IFFmpegProcessService
|
||||
string vaapiDisplay,
|
||||
VaapiDriver vaapiDriver,
|
||||
string vaapiDevice,
|
||||
Option<int> qsvExtraHardwareFrames);
|
||||
Option<int> qsvExtraHardwareFrames,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<Command> ConcatChannel(string ffmpegPath, bool saveReports, Channel channel, string scheme, string host);
|
||||
Task<Command> Slug(
|
||||
string ffmpegPath,
|
||||
Channel channel,
|
||||
DateTimeOffset now,
|
||||
TimeSpan duration,
|
||||
bool hlsRealtime,
|
||||
TimeSpan ptsOffset,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<Command> ConcatChannel(
|
||||
string ffmpegPath,
|
||||
bool saveReports,
|
||||
Channel channel,
|
||||
string scheme,
|
||||
string host,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<Command> WrapSegmenter(
|
||||
string ffmpegPath,
|
||||
@@ -69,7 +85,12 @@ public interface IFFmpegProcessService
|
||||
string accessToken,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<Command> ResizeImage(string ffmpegPath, string inputFile, string outputFile, int height);
|
||||
Task<Command> ResizeImage(
|
||||
string ffmpegPath,
|
||||
string inputFile,
|
||||
string outputFile,
|
||||
int height,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<Either<BaseError, string>> GenerateSongImage(
|
||||
string ffmpegPath,
|
||||
@@ -86,5 +107,10 @@ public interface IFFmpegProcessService
|
||||
int watermarkWidthPercent,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<Command> SeekTextSubtitle(string ffmpegPath, string inputFile, string codec, TimeSpan seek);
|
||||
Task<Command> SeekTextSubtitle(
|
||||
string ffmpegPath,
|
||||
string inputFile,
|
||||
string codec,
|
||||
TimeSpan seek,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -5,69 +5,69 @@ namespace ErsatzTV.Core.Interfaces.Jellyfin;
|
||||
|
||||
public interface IJellyfinApiClient
|
||||
{
|
||||
Task<Either<BaseError, JellyfinServerInformation>> GetServerInformation(string address, string apiKey);
|
||||
Task<Either<BaseError, List<JellyfinLibrary>>> GetLibraries(string address, string apiKey);
|
||||
Task<Either<BaseError, JellyfinServerInformation>> GetServerInformation(string address, string authorizationHeader);
|
||||
Task<Either<BaseError, List<JellyfinLibrary>>> GetLibraries(string address, string authorizationHeader);
|
||||
|
||||
IAsyncEnumerable<Tuple<JellyfinMovie, int>> GetMovieLibraryItems(
|
||||
string address,
|
||||
string apiKey,
|
||||
string authorizationHeader,
|
||||
JellyfinLibrary library);
|
||||
|
||||
IAsyncEnumerable<Tuple<JellyfinShow, int>> GetShowLibraryItemsWithoutPeople(
|
||||
string address,
|
||||
string apiKey,
|
||||
string authorizationHeader,
|
||||
JellyfinLibrary library);
|
||||
|
||||
IAsyncEnumerable<Tuple<JellyfinSeason, int>> GetSeasonLibraryItems(
|
||||
string address,
|
||||
string apiKey,
|
||||
string authorizationHeader,
|
||||
JellyfinLibrary library,
|
||||
string showId);
|
||||
|
||||
IAsyncEnumerable<Tuple<JellyfinEpisode, int>> GetEpisodeLibraryItems(
|
||||
string address,
|
||||
string apiKey,
|
||||
string authorizationHeader,
|
||||
JellyfinLibrary library,
|
||||
string seasonId);
|
||||
|
||||
IAsyncEnumerable<Tuple<JellyfinEpisode, int>> GetEpisodeLibraryItemsWithoutPeople(
|
||||
string address,
|
||||
string apiKey,
|
||||
string authorizationHeader,
|
||||
JellyfinLibrary library,
|
||||
string seasonId);
|
||||
|
||||
IAsyncEnumerable<Tuple<JellyfinCollection, int>> GetCollectionLibraryItems(
|
||||
string address,
|
||||
string apiKey,
|
||||
string authorizationHeader,
|
||||
int mediaSourceId);
|
||||
|
||||
IAsyncEnumerable<Tuple<MediaItem, int>> GetCollectionItems(
|
||||
string address,
|
||||
string apiKey,
|
||||
string authorizationHeader,
|
||||
int mediaSourceId,
|
||||
string collectionId);
|
||||
|
||||
Task<Either<BaseError, MediaVersion>> GetPlaybackInfo(
|
||||
string address,
|
||||
string apiKey,
|
||||
string authorizationHeader,
|
||||
JellyfinLibrary library,
|
||||
string itemId);
|
||||
|
||||
Task<Either<BaseError, Option<JellyfinShow>>> GetSingleShow(
|
||||
string address,
|
||||
string apiKey,
|
||||
string authorizationHeader,
|
||||
JellyfinLibrary library,
|
||||
string showId);
|
||||
|
||||
Task<Either<BaseError, List<JellyfinShow>>> SearchShowsByTitle(
|
||||
string address,
|
||||
string apiKey,
|
||||
string authorizationHeader,
|
||||
JellyfinLibrary library,
|
||||
string showTitle);
|
||||
|
||||
Task<Either<BaseError, Option<JellyfinEpisode>>> GetSingleEpisode(
|
||||
string address,
|
||||
string apiKey,
|
||||
string authorizationHeader,
|
||||
JellyfinLibrary library,
|
||||
string seasonId,
|
||||
string episodeId);
|
||||
|
||||
@@ -2,5 +2,9 @@
|
||||
|
||||
public interface IJellyfinCollectionScanner
|
||||
{
|
||||
Task<Either<BaseError, Unit>> ScanCollections(string address, string apiKey, int mediaSourceId, bool deepScan);
|
||||
Task<Either<BaseError, Unit>> ScanCollections(
|
||||
string address,
|
||||
string authorizationHeader,
|
||||
int mediaSourceId,
|
||||
bool deepScan);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Jellyfin;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Jellyfin;
|
||||
|
||||
public interface IJellyfinMovieLibraryScanner
|
||||
{
|
||||
Task<Either<BaseError, Unit>> ScanLibrary(
|
||||
string address,
|
||||
string apiKey,
|
||||
JellyfinConnectionParameters connectionParameters,
|
||||
JellyfinLibrary library,
|
||||
bool deepScan,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Jellyfin;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Jellyfin;
|
||||
|
||||
public interface IJellyfinTelevisionLibraryScanner
|
||||
{
|
||||
Task<Either<BaseError, Unit>> ScanLibrary(
|
||||
string address,
|
||||
string apiKey,
|
||||
JellyfinConnectionParameters connectionParameters,
|
||||
JellyfinLibrary library,
|
||||
bool deepScan,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<Either<BaseError, Unit>> ScanSingleShow(
|
||||
string address,
|
||||
string apiKey,
|
||||
JellyfinConnectionParameters connectionParameters,
|
||||
JellyfinLibrary library,
|
||||
string showId,
|
||||
string showTitle,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user