Compare commits

..
Author SHA1 Message Date
Jason Dove 60965d0961 update changelog for release v0.5.1-beta [no ci] 2022-04-17 17:36:32 -05:00
Jason DoveandGitHub ff1a7b376f add empty trash button (#739) 2022-04-17 14:45:49 -05:00
Jason DoveandGitHub 741b00fd52 fix multiple filler scheduling bugs (#738) 2022-04-17 13:30:47 -05:00
Jason DoveandGitHub 7e55681916 fix cliwrap usage (#737) 2022-04-16 20:12:18 -05:00
Jason DoveandGitHub 210630d299 subtitle fixes for software, videotoolbox, vaapi (#736)
* fix subtitles using software encoders

* videotoolbox fixes

* fix some vaapi subtitle edge cases
2022-04-16 16:06:49 -05:00
Jason DoveandGitHub 0ddbb898d6 fix subtitle stream selection (#735) 2022-04-15 19:40:08 -05:00
Jason Dove d6bf579436 fix alpha => beta versioning 2022-04-15 09:08:30 -05:00
Jason DoveandGitHub 765df64555 add picture subtitle transcoding tests, and make them all pass with nvenc (#734) 2022-04-14 22:30:26 -05:00
Jason Dove 8764fb93fa update for release v0.5.0-beta [no ci] 2022-04-13 11:01:54 -05:00
Jason Dove 7d5c3e2384 update docs 2022-04-13 11:01:49 -05:00
Jason DoveandGitHub 1ee3446589 add schedule item watermark setting (#733) 2022-04-12 21:01:20 -05:00
Jason DoveandGitHub af39d93442 more scheduling fixes (#732)
* fix skipping days with fixed start times

* fix playouts getting "stuck" on the same items

* rebuild all playouts

* update dependencies
2022-04-12 09:19:13 -05:00
Jason DoveandGitHub 5d6a6d3a76 fix schedule anchors (#726) 2022-04-10 11:29:50 -05:00
Jason DoveandGitHub 1f27aef11d allow two decimals in channel numbers (#724) 2022-04-05 20:44:50 -05:00
Jason DoveandGitHub ddb6d99cf9 remove sqlite startup messages (#723) 2022-04-03 23:37:38 -05:00
Jason DoveandGitHub d54766866e optimize image manipulation (#722)
* update dependencies

* use ffmpeg to resize images

* use ffprobe to check for animated logos and watermarks

* remove last use of imagesharp
2022-04-03 22:43:11 -05:00
Jason DoveandGitHub 25bc500a2b ensure HDHR clients always get an MPEG-TS stream (#721) 2022-04-03 18:03:34 -05:00
Jason DoveandGitHub c2eec2fc2d playout rework to maintain collection progress (#720)
* initial work on maintaining playout state

* debugging wip

* fix refresh playout logic

* fix failing test

* more fixes

* update changelog

* comment out some debug logs

* comment out more logs
2022-04-02 21:19:59 -05:00
Jason DoveandGitHub f9781a4c05 detect and handle nonzero hls segmenter exit code (#719) 2022-04-01 20:27:40 -05:00
Jason DoveandGitHub df45b93819 burn in picture-based subtitles (#718)
* add subtitle mode setting

* start to add subtitle support

* cuda test

* move subtitle settings from ffmpeg profile to channel

* fix image-based subtitles

* experimental wip

* subtitle fixes
2022-03-31 18:15:57 -05:00
Jason DoveandGitHub e697fd36e9 remove legacy transcoder logic option (#717) 2022-03-31 16:00:50 -05:00
Jason Dove 0308106c1b update changelog for release v0.4.5-alpha [no ci] 2022-03-29 20:12:01 -05:00
Jason DoveandGitHub ba93e3eeea always check for plex on the localhost (#716) 2022-03-29 19:39:55 -05:00
Jason DoveandGitHub aa2c914d8a always transcode and normalize, except with HLS Direct (#715)
* remove transcode, normalize video, normalize audio settings

* cleanup

* update changelog
2022-03-28 18:25:33 -05:00
Jason Dove caa9bf82d5 update changelog [no ci] 2022-03-28 13:10:33 -05:00
Jason Dove e397035c5a update changelog [no ci] 2022-03-18 14:07:15 -05:00
Jason DoveandGitHub d32f881c4e use cliwrap for windows wrapper (#710) 2022-03-18 13:36:38 -05:00
Jason DoveandGitHub 9b3c24559d add deinterlace option to ffmpeg profile (#709)
* update dependencies

* add option to deinterlace video
2022-03-18 10:30:56 -05:00
Jason Dove 7c75b169ec try gon again 2022-03-16 11:39:44 -05:00
Jason Dove 4f1952340f fix gon since brew is not working 2022-03-16 11:24:52 -05:00
Jason Dove ac2de24f6e fix build 2022-03-16 10:50:38 -05:00
Jason DoveandGitHub 4b9781dad4 add v2 channels table (#708)
* change mock api port; fix cors

* fix version request

* accept base url env var

* tweak colors

* fix version service

* add basic channels list

* fix auto page title for ts classes

* add GET /api/channels
2022-03-16 10:28:54 -05:00
Jason DoveandGitHub d88c179b63 axios test (#705)
* add version controller

* use axios to get version

* allow typescript

* use version api service
2022-03-15 17:55:05 -05:00
Jason DoveandGitHub 809a623a95 vue formatting tweaks (#704)
* add .prettierrc

* apply formatting
2022-03-14 10:59:04 -05:00
Jason Dove b453dce57e try npm ci with cache 2022-03-13 20:38:04 -05:00
Jason Dove edd31755c0 try npm install again 2022-03-13 20:21:16 -05:00
Jason DoveandGitHub 7de1a87bbf bug fixes (#703)
* catch expected shutdown error in scheduler service

* fix streaming mode inconsistencies
2022-03-13 18:45:21 -05:00
Jason DoveandGitHub 5731edc82e v2 ui tweaks (#702)
* fix m3u and xml urls (kind of)

* use svg logos

* tweak theme colors
2022-03-13 12:44:35 -05:00
Jason DoveandGitHub 2f668e53dd fix npm build (#701) 2022-03-13 11:11:08 -05:00
Jason DoveandGitHub abd223acd2 fix docker builds (#700) 2022-03-13 10:52:03 -05:00
6a9075dc11 add vue ui at /v2 (#698)
* add vue ui

* add channels mock api

* Initial Vue framework with Vuetify UI (#688)

* fix hls direct streaming mode (#682)

* duration analysis on files with missing duration metadata (#683)

* first pass

* analyze zero-duration files

* add readme note for WIP

* add vuetify and basic sidebar layout - responsive

* add vue-router and initial home page

* setup composition-api for vue2

* install pinia ie... vuex4

* mixing for automatic page title

* add logo files

* tweak theme colors

* install store

* use store for menu toggle

* replicate old menu

* implement menu and children menus

* rename state to application state

* update vue files and add version to sidebar

* lock logo and make expandable list remove minified menu

* remove todo, will add to PR

* top bar links and attempt at snackbar with state

* fix snackbar

* add search basic component

* fix search bar placement

* remove un-used footer

* Revert "Merge branch 'jasongdove:main' into intitial-vuetify-ui"

This reverts commit 43016d502b.

Co-authored-by: Jason Dove <jason@jasondove.me>

* Add ESLint and Prettier for VueJS (#691)

* add prettier to config

* run npm run lint over project to clean up files

* replace hr tag with v-dividers

* add vue-lint github action

* fix the dodo in me

* Hu

* Fix path

* convert to multi-run step

* forgot the name

* add vue-lint github action

fix the dodo in me

Hu

Fix path

convert to multi-run step

forgot the name

* Fix new line at end of file

* WIP

* dockerfile consistency

* use npm ci and node v14

* force prettier indenting and end of line (#695)

* disable filename hashing

* don't build tests in docker

* update dependencies

* fix running both uis

Co-authored-by: James Mackay <info@notexpectedyet.com>
2022-03-12 14:15:31 -06:00
Jason DoveandGitHub d5a03963c0 use video and audio format instead of video and audio codec (#696) 2022-03-11 19:58:05 -06:00
261 changed files with 75467 additions and 3118 deletions
+23 -3
View File
@@ -51,6 +51,16 @@ jobs:
with:
dotnet-version: 6.0.x
- name: Setup Node.js
uses: actions/setup-node@v2
with:
node-version: '14'
- name: Cache NPM dependencies
uses: bahmutov/npm-install@v1.4.5
with:
working-directory: ErsatzTV/client-app
- name: Clean
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
@@ -104,9 +114,9 @@ jobs:
- name: Notarize
shell: bash
run: |
brew tap mitchellh/gon
brew install mitchellh/gon/gon
gon -log-level=debug -log-json ./gon.json
curl -o gon.zip -L -s "https://github.com/mitchellh/gon/releases/latest/download/gon_macos.zip"
unzip -o -q gon.zip
./gon -log-level=debug -log-json ./gon.json
env:
AC_USERNAME: ${{ secrets.ac_username }}
AC_PASSWORD: ${{ secrets.ac_password }}
@@ -166,6 +176,16 @@ jobs:
with:
dotnet-version: 6.0.x
- name: Setup Node.js
uses: actions/setup-node@v2
with:
node-version: '14'
- name: Cache NPM dependencies
uses: bahmutov/npm-install@v1.4.5
with:
working-directory: ErsatzTV/client-app
- name: Clean
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
+2 -2
View File
@@ -19,14 +19,14 @@ jobs:
tag=$(git describe --tags --abbrev=0)
tag2="${tag:1}"
short=$(git rev-parse --short HEAD)
final="${tag2/alpha/$short}"
final="${tag2/beta/$short}"
echo "GIT_TAG=${final}" >> $GITHUB_ENV
- name: Extract Artifacts Version
shell: bash
run: |
tag=$(git describe --tags --abbrev=0)
short=$(git rev-parse --short HEAD)
final="${tag/alpha/$short}"
final="${tag/beta/$short}"
echo "ARTIFACTS_VERSION=${final}" >> $GITHUB_ENV
echo "INFO_VERSION=${tag:1}" >> $GITHUB_ENV
outputs:
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
run: |
tag=$(git describe --tags --abbrev=0)
echo "GIT_TAG=${tag:1}" >> $GITHUB_ENV
echo "DOCKER_TAG=${tag/-alpha/}" >> $GITHUB_ENV
echo "DOCKER_TAG=${tag/-beta/}" >> $GITHUB_ENV
- name: Extract Artifacts Version
shell: bash
run: |
+22
View File
@@ -0,0 +1,22 @@
name: Lint VueJS Files on PR Request
on:
pull_request:
jobs:
vue-lint:
runs-on: ubuntu-latest
steps:
# Checkout the current repo
- name: Checkout current repository
uses: actions/checkout@v2
# Setup NodeJS version 14
- name: Setup NodeJS V14.x.x
uses: actions/setup-node@v2
with:
node-version: '14'
# CD into the current client directory and lint and build the client
- name: Lint and Build the client
run: |
cd ./ErsatzTV/client-app/
npm ci --no-optional
npm run lint
npm run build --if-present
+1
View File
@@ -43,3 +43,4 @@ scripts/generate-api-sdk/swagger.json
docker-compose.override.yml
ErsatzTV/wwwroot/v2/
+69 -1
View File
@@ -5,6 +5,71 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased]
## [0.5.1-beta] - 2022-04-17
### Fixed
- Fix subtitles edge case with NVENC
- Only select picture subtitles (text subtitles are not yet supported)
- Supported picture subtitles are `hdmv_pgs_subtitle` and `dvd_subtitle`
- Fix subtitles using software encoders, videotoolbox, VAAPI
- Fix setting VAAPI driver name
- Fix ffmpeg troubleshooting reports
- Fix bug where filler would behave as if it were configured to pad even though a different mode was selected
- Fix bug where mid-roll count filler would skip scheduling the final chapter in an episode
### Added
- Add `Empty Trash` button to `Trash` page
## [0.5.0-beta] - 2022-04-13
### Fixed
- Fix `HLS Segmenter` bug where it would drift off of the schedule if a playout was changed while the segmenter was running
- Ensure clients that use HDHomeRun emulation (like Plex) always get an `MPEG-TS` stream, regardless of the configured streaming mode
- Fix scheduling bug that caused some days to be skipped when fixed start times were used with fallback filler
### Added
- Add `Preferred Subtitle Language` and `Subtitle Mode` to channel settings
- `Preferred Subtitle Language` will filter all subtitle streams based on language
- `Subtitle Mode` will further filter subtitle streams based on attributes (forced, default)
- If picture-based subtitles are found after filtering, they will be burned into the video stream
- Detect non-zero ffmpeg exit code from `HLS Segmenter` and `MPEG-TS`, log error output and display error output on stream
- Add `Watermark` setting to schedule items; this allows override the channel watermark. Watermark priority is:
- Schedule Item
- Channel
- Global
### Changed
- Remove legacy transcoder logic option; all channels will use the new transcoder logic
- Renamed channel setting `Preferred Language` to `Preferred Audio Language`
- Reworked playout build logic to maintain collection progress in some scenarios. There are now three build modes:
- `Continue` - add new items to the end of an existing playout
- This mode is used when playouts are automatically extended in the background
- `Refresh` - this mode will try to maintain collection progress while rebuilding the entire playout
- This mode is used when a schedule is updated, or when collection modifications trigger a playout rebuild
- `Reset` - this mode will rebuild the entire playout and will NOT maintain progress
- This mode is only used when the `Reset Playout` button is clicked on the Playouts page
- **This requires rebuilding all playouts, which will happen on startup after upgrading**
- Use ffmpeg to resize images; this should help reduce ErsatzTV's memory use
- Use ffprobe to check for animated logos and watermarks; this should help reduce ErsatzTV's memory use
- Allow two decimals in channel numbers (e.g. `5.73`)
## [0.4.5-alpha] - 2022-03-29
### Fixed
- Fix streaming mode inconsistencies when `mode` parameter is unspecified
- Fix startup on Windows 7
### Added
- Add option to automatically deinterlace video when transcoding
- Previously, this was always enabled; the purpose of the option is to allow disabling any deinterlace filters
- Note that there is no performance gain to disabling the option with progressive content; filters are only ever applied to interlaced content
### Changed
- Change FFmpeg Profile video codec and audio codec text fields to select fields
- The appropriate video encoder will be determined based on the video format and hardware acceleration selections
- Remove FFmpeg Profile `Transcode`, `Normalize Video` and `Normalize Audio` settings
- All content will be transcoded and have audio and video normalized
- The only exception to this rule is `HLS Direct` streaming mode, which directly copies video and audio streams
- Always try to connect to Plex at `http://localhost:32400` even if that address isn't advertised by the Plex API
- If Plex isn't on the localhost, all other addresses will be checked as with previous releases
## [0.4.4-alpha] - 2022-03-10
### Fixed
- Fix `HLS Direct` streaming mode
@@ -1017,7 +1082,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/jasongdove/ErsatzTV/compare/v0.4.4-alpha...HEAD
[Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.5.1-beta...HEAD
[0.5.1-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.5.0-beta...v0.5.1-beta
[0.5.0-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.4.5-alpha...v0.5.0-beta
[0.4.5-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.4.4-alpha...v0.4.5-alpha
[0.4.4-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.4.3-alpha...v0.4.4-alpha
[0.4.3-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.4.2-alpha...v0.4.3-alpha
[0.4.2-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.4.1-alpha...v0.4.2-alpha
+1 -1
View File
@@ -17,7 +17,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Asmichi.ChildProcess" Version="0.11.0" />
<PackageReference Include="CliWrap" Version="3.4.2" />
</ItemGroup>
<ItemGroup>
+10 -6
View File
@@ -1,14 +1,13 @@
using ErsatzTV.Core;
using System.Diagnostics;
using Asmichi.ProcessManagement;
using System.Reflection;
using CliWrap;
namespace ErsatzTV_Windows;
public class TrayApplicationContext : ApplicationContext
{
private readonly NotifyIcon _trayIcon;
private readonly IChildProcess? _childProcess;
private readonly CancellationTokenSource _tokenSource;
public TrayApplicationContext()
{
@@ -19,6 +18,8 @@ public class TrayApplicationContext : ApplicationContext
Visible = true
};
_tokenSource = new CancellationTokenSource();
AddMenuItem("Launch Web UI", LaunchWebUI);
AddMenuItem("Show Logs", ShowLogs);
_trayIcon.ContextMenuStrip.Items.Add(new ToolStripSeparator());
@@ -29,8 +30,11 @@ public class TrayApplicationContext : ApplicationContext
if (File.Exists(exe))
{
var si = new ChildProcessStartInfo(exe);
_childProcess = ChildProcess.Start(si);
Cli.Wrap(exe)
.WithWorkingDirectory(folder)
.WithValidation(CommandResultValidation.None)
.ExecuteAsync(_tokenSource.Token);
}
}
@@ -64,7 +68,7 @@ public class TrayApplicationContext : ApplicationContext
protected override void Dispose(bool disposing)
{
_childProcess?.Dispose();
_tokenSource?.Cancel();
base.Dispose(disposing);
}
@@ -10,8 +10,10 @@ public record ChannelViewModel(
string Categories,
int FFmpegProfileId,
string Logo,
string PreferredLanguageCode,
string PreferredAudioLanguageCode,
StreamingMode StreamingMode,
int? WatermarkId,
int? FallbackFillerId,
int PlayoutCount);
int PlayoutCount,
string PreferredSubtitleLanguageCode,
ChannelSubtitleMode SubtitleMode);
@@ -11,7 +11,9 @@ public record CreateChannel
string Categories,
int FFmpegProfileId,
string Logo,
string PreferredLanguageCode,
string PreferredAudioLanguageCode,
StreamingMode StreamingMode,
int? WatermarkId,
int? FallbackFillerId) : IRequest<Either<BaseError, CreateChannelResult>>;
int? FallbackFillerId,
string PreferredSubtitleLanguageCode,
ChannelSubtitleMode SubtitleMode) : IRequest<Either<BaseError, CreateChannelResult>>;
@@ -21,7 +21,7 @@ public class CreateChannelHandler : IRequestHandler<CreateChannel, Either<BaseEr
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Channel> validation = await Validate(dbContext, request);
return await LanguageExtensions.Apply(validation, c => PersistChannel(dbContext, c));
return await validation.Apply(c => PersistChannel(dbContext, c));
}
private static async Task<CreateChannelResult> PersistChannel(TvContext dbContext, Channel channel)
@@ -34,11 +34,19 @@ public class CreateChannelHandler : IRequestHandler<CreateChannel, Either<BaseEr
private static async Task<Validation<BaseError, Channel>> Validate(TvContext dbContext, CreateChannel request) =>
(ValidateName(request), await ValidateNumber(dbContext, request),
await FFmpegProfileMustExist(dbContext, request),
ValidatePreferredLanguage(request),
ValidatePreferredAudioLanguage(request),
ValidatePreferredSubtitleLanguage(request),
await WatermarkMustExist(dbContext, request),
await FillerPresetMustExist(dbContext, request))
.Apply(
(name, number, ffmpegProfileId, preferredLanguageCode, watermarkId, fillerPresetId) =>
(
name,
number,
ffmpegProfileId,
preferredAudioLanguageCode,
preferredSubtitleLanguageCode,
watermarkId,
fillerPresetId) =>
{
var artwork = new List<Artwork>();
if (!string.IsNullOrWhiteSpace(request.Logo))
@@ -62,7 +70,9 @@ public class CreateChannelHandler : IRequestHandler<CreateChannel, Either<BaseEr
FFmpegProfileId = ffmpegProfileId,
StreamingMode = request.StreamingMode,
Artwork = artwork,
PreferredLanguageCode = preferredLanguageCode
PreferredAudioLanguageCode = preferredAudioLanguageCode,
PreferredSubtitleLanguageCode = preferredSubtitleLanguageCode,
SubtitleMode = request.SubtitleMode
};
foreach (int id in watermarkId)
@@ -82,12 +92,19 @@ public class CreateChannelHandler : IRequestHandler<CreateChannel, Either<BaseEr
createChannel.NotEmpty(c => c.Name)
.Bind(_ => createChannel.NotLongerThan(50)(c => c.Name));
private static Validation<BaseError, string> ValidatePreferredLanguage(CreateChannel createChannel) =>
Optional(createChannel.PreferredLanguageCode ?? string.Empty)
private static Validation<BaseError, string> ValidatePreferredAudioLanguage(CreateChannel createChannel) =>
Optional(createChannel.PreferredAudioLanguageCode ?? string.Empty)
.Filter(
lc => string.IsNullOrWhiteSpace(lc) || CultureInfo.GetCultures(CultureTypes.NeutralCultures).Any(
ci => string.Equals(ci.ThreeLetterISOLanguageName, lc, StringComparison.OrdinalIgnoreCase)))
.ToValidation<BaseError>("Preferred language code is invalid");
.ToValidation<BaseError>("Preferred audio language code is invalid");
private static Validation<BaseError, string> ValidatePreferredSubtitleLanguage(CreateChannel createChannel) =>
Optional(createChannel.PreferredSubtitleLanguageCode ?? string.Empty)
.Filter(
lc => string.IsNullOrWhiteSpace(lc) || CultureInfo.GetCultures(CultureTypes.NeutralCultures).Any(
ci => string.Equals(ci.ThreeLetterISOLanguageName, lc, StringComparison.OrdinalIgnoreCase)))
.ToValidation<BaseError>("Preferred subtitle language code is invalid");
private static async Task<Validation<BaseError, string>> ValidateNumber(TvContext dbContext, CreateChannel createChannel)
{
@@ -102,7 +119,7 @@ public class CreateChannelHandler : IRequestHandler<CreateChannel, Either<BaseEr
return createChannel.Number;
}
return BaseError.New("Invalid channel number; one decimal is allowed for subchannels");
return BaseError.New("Invalid channel number; two decimals are allowed for subchannels");
});
}
@@ -12,7 +12,9 @@ public record UpdateChannel
string Categories,
int FFmpegProfileId,
string Logo,
string PreferredLanguageCode,
string PreferredAudioLanguageCode,
StreamingMode StreamingMode,
int? WatermarkId,
int? FallbackFillerId) : IRequest<Either<BaseError, ChannelViewModel>>;
int? FallbackFillerId,
string PreferredSubtitleLanguageCode,
ChannelSubtitleMode SubtitleMode) : IRequest<Either<BaseError, ChannelViewModel>>;
@@ -32,7 +32,9 @@ public class UpdateChannelHandler : IRequestHandler<UpdateChannel, Either<BaseEr
c.Group = update.Group;
c.Categories = update.Categories;
c.FFmpegProfileId = update.FFmpegProfileId;
c.PreferredLanguageCode = update.PreferredLanguageCode;
c.PreferredAudioLanguageCode = update.PreferredAudioLanguageCode;
c.PreferredSubtitleLanguageCode = update.PreferredSubtitleLanguageCode;
c.SubtitleMode = update.SubtitleMode;
c.Artwork ??= new List<Artwork>();
if (!string.IsNullOrWhiteSpace(update.Logo))
@@ -69,7 +71,7 @@ public class UpdateChannelHandler : IRequestHandler<UpdateChannel, Either<BaseEr
private async Task<Validation<BaseError, Channel>> Validate(TvContext dbContext, UpdateChannel request) =>
(await ChannelMustExist(dbContext, request), ValidateName(request),
await ValidateNumber(dbContext, request),
ValidatePreferredLanguage(request))
ValidatePreferredAudioLanguage(request))
.Apply((channelToUpdate, _, _, _) => channelToUpdate);
private static Task<Validation<BaseError, Channel>> ChannelMustExist(
@@ -100,16 +102,16 @@ public class UpdateChannelHandler : IRequestHandler<UpdateChannel, Either<BaseEr
return updateChannel.Number;
}
return BaseError.New("Invalid channel number; one decimal is allowed for subchannels");
return BaseError.New("Invalid channel number; two decimals are allowed for subchannels");
}
return BaseError.New("Channel number must be unique");
}
private static Validation<BaseError, string> ValidatePreferredLanguage(UpdateChannel updateChannel) =>
Optional(updateChannel.PreferredLanguageCode ?? string.Empty)
private static Validation<BaseError, string> ValidatePreferredAudioLanguage(UpdateChannel updateChannel) =>
Optional(updateChannel.PreferredAudioLanguageCode ?? string.Empty)
.Filter(
lc => string.IsNullOrWhiteSpace(lc) || CultureInfo.GetCultures(CultureTypes.NeutralCultures).Any(
ci => string.Equals(ci.ThreeLetterISOLanguageName, lc, StringComparison.OrdinalIgnoreCase)))
.ToValidation<BaseError>("Preferred language code is invalid");
.ToValidation<BaseError>("Preferred audio language code is invalid");
}
+24 -6
View File
@@ -1,4 +1,5 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Channels;
@@ -13,17 +14,34 @@ internal static class Mapper
channel.Categories,
channel.FFmpegProfileId,
GetLogo(channel),
channel.PreferredLanguageCode,
channel.PreferredAudioLanguageCode,
channel.StreamingMode,
channel.WatermarkId,
channel.FallbackFillerId,
channel.Playouts?.Count ?? 0);
channel.Playouts?.Count ?? 0,
channel.PreferredSubtitleLanguageCode,
channel.SubtitleMode);
internal static ChannelResponseModel ProjectToResponseModel(Channel channel) =>
new(
channel.Id,
channel.Number,
channel.Name,
channel.FFmpegProfile.Name,
channel.PreferredAudioLanguageCode,
GetStreamingMode(channel));
private static string GetLogo(Channel channel) =>
Optional(channel.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Logo))
.Match(a => a.Path, string.Empty);
private static string GetWatermark(Channel channel) =>
Optional(channel.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Watermark))
.Match(a => a.Path, string.Empty);
private static string GetStreamingMode(Channel channel) =>
channel.StreamingMode switch
{
StreamingMode.TransportStream => "MPEG-TS (Legacy)",
StreamingMode.TransportStreamHybrid => "MPEG-TS",
StreamingMode.HttpLiveStreamingDirect => "HLS Direct",
StreamingMode.HttpLiveStreamingSegmenter => "HLS Segmenter",
_ => throw new ArgumentOutOfRangeException()
};
}
@@ -0,0 +1,5 @@
using ErsatzTV.Core.Api.Channels;
namespace ErsatzTV.Application.Channels;
public record GetAllChannelsForApi : IRequest<List<ChannelResponseModel>>;
@@ -0,0 +1,21 @@
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using static ErsatzTV.Application.Channels.Mapper;
namespace ErsatzTV.Application.Channels;
public class GetAllChannelsForApiHandler : IRequestHandler<GetAllChannelsForApi, List<ChannelResponseModel>>
{
private readonly IChannelRepository _channelRepository;
public GetAllChannelsForApiHandler(IChannelRepository channelRepository) => _channelRepository = channelRepository;
public async Task<List<ChannelResponseModel>> Handle(
GetAllChannelsForApi request,
CancellationToken cancellationToken)
{
IEnumerable<Channel> channels = Optional(await _channelRepository.GetAll()).Flatten();
return channels.Map(ProjectToResponseModel).ToList();
}
}
@@ -0,0 +1,3 @@
namespace ErsatzTV.Application.Channels;
public record GetChannelByNumber(string ChannelNumber) : IRequest<Option<ChannelViewModel>>;
@@ -0,0 +1,14 @@
using ErsatzTV.Core.Interfaces.Repositories;
using static ErsatzTV.Application.Channels.Mapper;
namespace ErsatzTV.Application.Channels;
public class GetChannelByNumberHandler : IRequestHandler<GetChannelByNumber, Option<ChannelViewModel>>
{
private readonly IChannelRepository _channelRepository;
public GetChannelByNumberHandler(IChannelRepository channelRepository) => _channelRepository = channelRepository;
public Task<Option<ChannelViewModel>> Handle(GetChannelByNumber request, CancellationToken cancellationToken) =>
_channelRepository.GetByNumber(request.ChannelNumber).MapT(ProjectToViewModel);
}
@@ -3,13 +3,13 @@ using ErsatzTV.Application.Playouts;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Configuration;
public class
UpdatePlayoutDaysToBuildHandler : MediatR.IRequestHandler<UpdatePlayoutDaysToBuild, Either<BaseError, Unit>>
public class UpdatePlayoutDaysToBuildHandler : IRequestHandler<UpdatePlayoutDaysToBuild, Either<BaseError, Unit>>
{
private readonly IConfigElementRepository _configElementRepository;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
@@ -37,16 +37,16 @@ public class
private async Task<Unit> ApplyUpdate(TvContext dbContext, int daysToBuild)
{
await _configElementRepository.Upsert(ConfigElementKey.PlayoutDaysToBuild, daysToBuild);
// build all playouts to proper number of days
// continue all playouts to proper number of days
List<Playout> playouts = await dbContext.Playouts
.Include(p => p.Channel)
.ToListAsync();
foreach (int playoutId in playouts.OrderBy(p => decimal.Parse(p.Channel.Number)).Map(p => p.Id))
{
await _workerChannel.WriteAsync(new BuildPlayout(playoutId));
await _workerChannel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Continue));
}
return Unit.Default;
}
@@ -7,8 +7,8 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Bugsnag" Version="3.0.0" />
<PackageReference Include="CliWrap" Version="3.4.1" />
<PackageReference Include="Bugsnag" Version="3.0.1" />
<PackageReference Include="CliWrap" Version="3.4.2" />
<PackageReference Include="MediatR" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="6.0.0" />
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.1.46">
@@ -7,20 +7,18 @@ namespace ErsatzTV.Application.FFmpegProfiles;
public record CreateFFmpegProfile(
string Name,
int ThreadCount,
bool Transcode,
HardwareAccelerationKind HardwareAcceleration,
VaapiDriver VaapiDriver,
string VaapiDevice,
int ResolutionId,
bool NormalizeVideo,
string VideoCodec,
FFmpegProfileVideoFormat VideoFormat,
int VideoBitrate,
int VideoBufferSize,
string AudioCodec,
FFmpegProfileAudioFormat AudioFormat,
int AudioBitrate,
int AudioBufferSize,
bool NormalizeLoudness,
int AudioChannels,
int AudioSampleRate,
bool NormalizeAudio,
bool NormalizeFramerate) : IRequest<Either<BaseError, CreateFFmpegProfileResult>>;
bool NormalizeFramerate,
bool DeinterlaceVideo) : IRequest<Either<BaseError, CreateFFmpegProfileResult>>;
@@ -18,9 +18,9 @@ public class CreateFFmpegProfileHandler :
CreateFFmpegProfile request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, FFmpegProfile> validation = await Validate(dbContext, request);
return await LanguageExtensions.Apply(validation, profile => PersistFFmpegProfile(dbContext, profile));
return await validation.Apply(profile => PersistFFmpegProfile(dbContext, profile));
}
private static async Task<CreateFFmpegProfileResult> PersistFFmpegProfile(
@@ -39,23 +39,21 @@ public class CreateFFmpegProfileHandler :
{
Name = name,
ThreadCount = threadCount,
Transcode = request.Transcode,
HardwareAcceleration = request.HardwareAcceleration,
VaapiDriver = request.VaapiDriver,
VaapiDevice = request.VaapiDevice,
ResolutionId = resolutionId,
NormalizeVideo = request.NormalizeVideo,
VideoCodec = request.VideoCodec,
VideoFormat = request.VideoFormat,
VideoBitrate = request.VideoBitrate,
VideoBufferSize = request.VideoBufferSize,
AudioCodec = request.AudioCodec,
AudioFormat = request.AudioFormat,
AudioBitrate = request.AudioBitrate,
AudioBufferSize = request.AudioBufferSize,
NormalizeLoudness = request.NormalizeLoudness,
AudioChannels = request.AudioChannels,
AudioSampleRate = request.AudioSampleRate,
NormalizeAudio = request.NormalizeAudio,
NormalizeFramerate = request.NormalizeFramerate
NormalizeFramerate = request.NormalizeFramerate,
DeinterlaceVideo = request.DeinterlaceVideo
});
private static Validation<BaseError, string> ValidateName(CreateFFmpegProfile createFFmpegProfile) =>
@@ -8,20 +8,18 @@ public record UpdateFFmpegProfile(
int FFmpegProfileId,
string Name,
int ThreadCount,
bool Transcode,
HardwareAccelerationKind HardwareAcceleration,
VaapiDriver VaapiDriver,
string VaapiDevice,
int ResolutionId,
bool NormalizeVideo,
string VideoCodec,
FFmpegProfileVideoFormat VideoFormat,
int VideoBitrate,
int VideoBufferSize,
string AudioCodec,
FFmpegProfileAudioFormat AudioFormat,
int AudioBitrate,
int AudioBufferSize,
bool NormalizeLoudness,
int AudioChannels,
int AudioSampleRate,
bool NormalizeAudio,
bool NormalizeFramerate) : IRequest<Either<BaseError, UpdateFFmpegProfileResult>>;
bool NormalizeFramerate,
bool DeinterlaceVideo) : IRequest<Either<BaseError, UpdateFFmpegProfileResult>>;
@@ -18,9 +18,9 @@ public class
UpdateFFmpegProfile request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, FFmpegProfile> validation = await Validate(dbContext, request);
return await LanguageExtensions.Apply(validation, p => ApplyUpdateRequest(dbContext, p, request));
return await validation.Apply(p => ApplyUpdateRequest(dbContext, p, request));
}
private async Task<UpdateFFmpegProfileResult> ApplyUpdateRequest(
@@ -30,23 +30,21 @@ public class
{
p.Name = update.Name;
p.ThreadCount = update.ThreadCount;
p.Transcode = update.Transcode;
p.HardwareAcceleration = update.HardwareAcceleration;
p.VaapiDriver = update.VaapiDriver;
p.VaapiDevice = update.VaapiDevice;
p.ResolutionId = update.ResolutionId;
p.NormalizeVideo = update.Transcode && update.NormalizeVideo;
p.VideoCodec = update.VideoCodec;
p.VideoFormat = update.VideoFormat;
p.VideoBitrate = update.VideoBitrate;
p.VideoBufferSize = update.VideoBufferSize;
p.AudioCodec = update.AudioCodec;
p.AudioFormat = update.AudioFormat;
p.AudioBitrate = update.AudioBitrate;
p.AudioBufferSize = update.AudioBufferSize;
p.NormalizeLoudness = update.Transcode && update.NormalizeLoudness;
p.NormalizeLoudness = update.NormalizeLoudness;
p.AudioChannels = update.AudioChannels;
p.AudioSampleRate = update.AudioSampleRate;
p.NormalizeAudio = update.Transcode && update.NormalizeAudio;
p.NormalizeFramerate = update.Transcode && update.NormalizeFramerate;
p.NormalizeFramerate = update.NormalizeFramerate;
p.DeinterlaceVideo = update.DeinterlaceVideo;
await dbContext.SaveChangesAsync();
return new UpdateFFmpegProfileResult(p.Id);
}
@@ -6,7 +6,7 @@ using ErsatzTV.Core.Interfaces.Repositories;
namespace ErsatzTV.Application.FFmpegProfiles;
public class UpdateFFmpegSettingsHandler : MediatR.IRequestHandler<UpdateFFmpegSettings, Either<BaseError, Unit>>
public class UpdateFFmpegSettingsHandler : IRequestHandler<UpdateFFmpegSettings, Either<BaseError, Unit>>
{
private readonly IConfigElementRepository _configElementRepository;
private readonly ILocalFileSystem _localFileSystem;
@@ -81,13 +81,9 @@ public class UpdateFFmpegSettingsHandler : MediatR.IRequestHandler<UpdateFFmpegS
Directory.CreateDirectory(FileSystemLayout.FFmpegReportsFolder);
}
await _configElementRepository.Upsert(
ConfigElementKey.FFmpegUseLegacyTranscoder,
request.Settings.UseLegacyTranscoder.ToString());
await _configElementRepository.Upsert(
ConfigElementKey.FFmpegPreferredLanguageCode,
request.Settings.PreferredLanguageCode);
request.Settings.PreferredAudioLanguageCode);
if (request.Settings.GlobalWatermarkId is not null)
{
@@ -8,20 +8,18 @@ public record FFmpegProfileViewModel(
int Id,
string Name,
int ThreadCount,
bool Transcode,
HardwareAccelerationKind HardwareAcceleration,
VaapiDriver VaapiDriver,
string VaapiDevice,
ResolutionViewModel Resolution,
bool NormalizeVideo,
string VideoCodec,
FFmpegProfileVideoFormat VideoFormat,
int VideoBitrate,
int VideoBufferSize,
string AudioCodec,
FFmpegProfileAudioFormat AudioFormat,
int AudioBitrate,
int AudioBufferSize,
bool NormalizeLoudness,
int AudioChannels,
int AudioSampleRate,
bool NormalizeAudio,
bool NormalizeFramerate);
bool NormalizeFramerate,
bool DeinterlaceVideo);
@@ -5,12 +5,11 @@ public class FFmpegSettingsViewModel
public string FFmpegPath { get; set; }
public string FFprobePath { get; set; }
public int DefaultFFmpegProfileId { get; set; }
public string PreferredLanguageCode { get; set; }
public string PreferredAudioLanguageCode { get; set; }
public bool SaveReports { get; set; }
public int? GlobalWatermarkId { get; set; }
public int? GlobalFallbackFillerId { get; set; }
public int HlsSegmenterIdleTimeout { get; set; }
public int WorkAheadSegmenterLimit { get; set; }
public int InitialSegmentCount { get; set; }
public bool UseLegacyTranscoder { get; set; }
}
@@ -10,23 +10,21 @@ internal static class Mapper
profile.Id,
profile.Name,
profile.ThreadCount,
profile.Transcode,
profile.HardwareAcceleration,
profile.VaapiDriver,
profile.VaapiDevice,
Project(profile.Resolution),
profile.NormalizeVideo,
profile.VideoCodec,
profile.VideoFormat,
profile.VideoBitrate,
profile.VideoBufferSize,
profile.AudioCodec,
profile.AudioFormat,
profile.AudioBitrate,
profile.AudioBufferSize,
profile.NormalizeLoudness,
profile.AudioChannels,
profile.AudioSampleRate,
profile.NormalizeAudio,
profile.NormalizeVideo && profile.NormalizeFramerate);
profile.NormalizeFramerate,
profile.DeinterlaceVideo == true);
private static ResolutionViewModel Project(Resolution resolution) =>
new(resolution.Id, resolution.Name, resolution.Width, resolution.Height);
@@ -16,7 +16,7 @@ public class GetFFmpegProfileByIdHandler : IRequestHandler<GetFFmpegProfileById,
GetFFmpegProfileById request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
return await dbContext.FFmpegProfiles
.Include(p => p.Resolution)
.SelectOneAsync(p => p.Id, p => p.Id == request.Id)
@@ -20,7 +20,7 @@ public class GetFFmpegSettingsHandler : IRequestHandler<GetFFmpegSettings, FFmpe
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegDefaultProfileId);
Option<bool> saveReports =
await _configElementRepository.GetValue<bool>(ConfigElementKey.FFmpegSaveReports);
Option<string> preferredLanguageCode =
Option<string> preferredAudioLanguageCode =
await _configElementRepository.GetValue<string>(ConfigElementKey.FFmpegPreferredLanguageCode);
Option<int> watermark =
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegGlobalWatermarkId);
@@ -32,8 +32,6 @@ public class GetFFmpegSettingsHandler : IRequestHandler<GetFFmpegSettings, FFmpe
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegWorkAheadSegmenters);
Option<int> initialSegmentCount =
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegInitialSegmentCount);
Option<bool> useLegacyTranscoder =
await _configElementRepository.GetValue<bool>(ConfigElementKey.FFmpegUseLegacyTranscoder);
var result = new FFmpegSettingsViewModel
{
@@ -41,11 +39,10 @@ public class GetFFmpegSettingsHandler : IRequestHandler<GetFFmpegSettings, FFmpe
FFprobePath = await ffprobePath.IfNoneAsync(string.Empty),
DefaultFFmpegProfileId = await defaultFFmpegProfileId.IfNoneAsync(0),
SaveReports = await saveReports.IfNoneAsync(false),
PreferredLanguageCode = await preferredLanguageCode.IfNoneAsync("eng"),
PreferredAudioLanguageCode = await preferredAudioLanguageCode.IfNoneAsync("eng"),
HlsSegmenterIdleTimeout = await hlsSegmenterIdleTimeout.IfNoneAsync(60),
WorkAheadSegmenterLimit = await workAheadSegmenterLimit.IfNoneAsync(1),
InitialSegmentCount = await initialSegmentCount.IfNoneAsync(1),
UseLegacyTranscoder = await useLegacyTranscoder.IfNoneAsync(false)
};
foreach (int watermarkId in watermark)
@@ -1,5 +1,9 @@
using ErsatzTV.Core;
using CliWrap;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Repositories;
using Winista.Mime;
namespace ErsatzTV.Application.Images;
@@ -9,12 +13,32 @@ public class
{
private static readonly MimeTypes MimeTypes = new();
private readonly IImageCache _imageCache;
private readonly IFFmpegProcessService _ffmpegProcessService;
private readonly IConfigElementRepository _configElementRepository;
public GetCachedImagePathHandler(IImageCache imageCache) => _imageCache = imageCache;
public GetCachedImagePathHandler(
IImageCache imageCache,
IFFmpegProcessService ffmpegProcessService,
IConfigElementRepository configElementRepository)
{
_imageCache = imageCache;
_ffmpegProcessService = ffmpegProcessService;
_configElementRepository = configElementRepository;
}
public async Task<Either<BaseError, CachedImagePathViewModel>> Handle(
GetCachedImagePath request,
CancellationToken cancellationToken)
{
Validation<BaseError, string> validation = await Validate();
return await validation.Match(
ffmpegPath => Handle(ffmpegPath, request),
error => Task.FromResult<Either<BaseError, CachedImagePathViewModel>>(error.Join()));
}
private async Task<Either<BaseError, CachedImagePathViewModel>> Handle(
string ffmpegPath,
GetCachedImagePath request)
{
try
{
@@ -24,23 +48,41 @@ public class
request.FileName,
request.ArtworkKind,
Optional(request.MaxHeight));
if (cachePath == null)
{
return BaseError.New("Failed to generate cache path for image");
}
if (!File.Exists(cachePath))
{
if (request.MaxHeight.HasValue)
{
string originalPath = _imageCache.GetPathForImage(request.FileName, request.ArtworkKind, None);
byte[] contents = await File.ReadAllBytesAsync(originalPath, cancellationToken);
Either<BaseError, byte[]> resizeResult =
await _imageCache.ResizeImage(contents, request.MaxHeight.Value);
resizeResult.IfRight(result => contents = result);
string baseFolder = Path.GetDirectoryName(cachePath);
if (baseFolder != null && !Directory.Exists(baseFolder))
{
Directory.CreateDirectory(baseFolder);
}
await File.WriteAllBytesAsync(cachePath, contents, cancellationToken);
// ffmpeg needs the extension to determine the output codec
string withExtension = cachePath + ".jpg";
string originalPath = _imageCache.GetPathForImage(request.FileName, request.ArtworkKind, None);
Command process = _ffmpegProcessService.ResizeImage(
ffmpegPath,
originalPath,
withExtension,
request.MaxHeight.Value);
CommandResult resize = await process.ExecuteAsync();
if (resize.ExitCode != 0)
{
return BaseError.New($"Failed to resize image; exit code {resize.ExitCode}");
}
File.Move(withExtension, cachePath);
mimeType = new MimeType("image/jpeg");
}
@@ -61,4 +103,12 @@ public class
return BaseError.New(ex.Message);
}
}
private async Task<Validation<BaseError, string>> Validate() =>
await ValidateFFmpegPath();
private Task<Validation<BaseError, string>> ValidateFFmpegPath() =>
_configElementRepository.GetValue<string>(ConfigElementKey.FFmpegPath)
.FilterT(File.Exists)
.Map(ffmpegPath => ffmpegPath.ToValidation<BaseError>("FFmpeg path does not exist on the file system"));
}
@@ -0,0 +1,5 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.Maintenance;
public record EmptyTrash : IRequest<Either<BaseError, Unit>>;
@@ -0,0 +1,55 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Search;
using ErsatzTV.Infrastructure.Search;
namespace ErsatzTV.Application.Maintenance;
public class EmptyTrashHandler : IRequestHandler<EmptyTrash, Either<BaseError, Unit>>
{
private readonly IMediaItemRepository _mediaItemRepository;
private readonly ISearchIndex _searchIndex;
public EmptyTrashHandler(
IMediaItemRepository mediaItemRepository,
ISearchIndex searchIndex)
{
_mediaItemRepository = mediaItemRepository;
_searchIndex = searchIndex;
}
public async Task<Either<BaseError, Unit>> Handle(
EmptyTrash request,
CancellationToken cancellationToken)
{
string[] types =
{
SearchIndex.MovieType,
SearchIndex.ShowType,
SearchIndex.SeasonType,
SearchIndex.EpisodeType,
SearchIndex.MusicVideoType,
SearchIndex.OtherVideoType,
SearchIndex.SongType,
SearchIndex.ArtistType
};
var ids = new List<int>();
foreach (string type in types)
{
SearchResult result = await _searchIndex.Search($"type:{type} AND (state:FileNotFound)", 0, 0);
ids.AddRange(result.Items.Map(i => i.Id));
}
Either<BaseError, Unit> deleteResult = await _mediaItemRepository.DeleteItems(ids);
if (deleteResult.IsRight)
{
await _searchIndex.RemoveItems(ids);
_searchIndex.Commit();
}
return deleteResult;
}
}
@@ -3,6 +3,7 @@ using ErsatzTV.Application.Playouts;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
@@ -10,7 +11,7 @@ using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.MediaCollections;
public class AddArtistToCollectionHandler :
MediatR.IRequestHandler<AddArtistToCollection, Either<BaseError, Unit>>
IRequestHandler<AddArtistToCollection, Either<BaseError, Unit>>
{
private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
@@ -30,9 +31,9 @@ public class AddArtistToCollectionHandler :
AddArtistToCollection request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Parameters> validation = await Validate(dbContext, request);
return await LanguageExtensions.Apply(validation, parameters => ApplyAddArtistRequest(dbContext, parameters));
return await validation.Apply(parameters => ApplyAddArtistRequest(dbContext, parameters));
}
private async Task<Unit> ApplyAddArtistRequest(TvContext dbContext, Parameters parameters)
@@ -40,11 +41,11 @@ public class AddArtistToCollectionHandler :
parameters.Collection.MediaItems.Add(parameters.Artist);
if (await dbContext.SaveChangesAsync() > 0)
{
// rebuild all playouts that use this collection
// refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(parameters.Collection.Id))
.PlayoutIdsUsingCollection(parameters.Collection.Id))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, true));
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh));
}
}
@@ -3,6 +3,7 @@ using ErsatzTV.Application.Playouts;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
@@ -30,9 +31,9 @@ public class AddEpisodeToCollectionHandler :
AddEpisodeToCollection request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Parameters> validation = await Validate(dbContext, request);
return await LanguageExtensions.Apply(validation, parameters => ApplyAddTelevisionEpisodeRequest(dbContext, parameters));
return await validation.Apply(parameters => ApplyAddTelevisionEpisodeRequest(dbContext, parameters));
}
private async Task<Unit> ApplyAddTelevisionEpisodeRequest(TvContext dbContext, Parameters parameters)
@@ -40,11 +41,11 @@ public class AddEpisodeToCollectionHandler :
parameters.Collection.MediaItems.Add(parameters.Episode);
if (await dbContext.SaveChangesAsync() > 0)
{
// rebuild all playouts that use this collection
// refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(parameters.Collection.Id))
.PlayoutIdsUsingCollection(parameters.Collection.Id))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, true));
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh));
}
}
@@ -3,6 +3,7 @@ using ErsatzTV.Application.Playouts;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
@@ -36,10 +37,9 @@ public class AddItemsToCollectionHandler :
AddItemsToCollection request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Collection> validation = await Validate(dbContext, request);
return await LanguageExtensions.Apply(validation, c => ApplyAddItemsRequest(dbContext, c, request));
return await validation.Apply(c => ApplyAddItemsRequest(dbContext, c, request));
}
private async Task<Unit> ApplyAddItemsRequest(TvContext dbContext, Collection collection, AddItemsToCollection request)
@@ -63,11 +63,11 @@ public class AddItemsToCollectionHandler :
if (await dbContext.SaveChangesAsync() > 0)
{
// rebuild all playouts that use this collection
// refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(request.CollectionId))
.PlayoutIdsUsingCollection(request.CollectionId))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, true));
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh));
}
}
@@ -3,6 +3,7 @@ using ErsatzTV.Application.Playouts;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
@@ -30,9 +31,9 @@ public class AddMovieToCollectionHandler :
AddMovieToCollection request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Parameters> validation = await Validate(dbContext, request);
return await LanguageExtensions.Apply(validation, parameters => ApplyAddMovieRequest(dbContext, parameters));
return await validation.Apply(parameters => ApplyAddMovieRequest(dbContext, parameters));
}
private async Task<Unit> ApplyAddMovieRequest(TvContext dbContext, Parameters parameters)
@@ -40,11 +41,11 @@ public class AddMovieToCollectionHandler :
parameters.Collection.MediaItems.Add(parameters.Movie);
if (await dbContext.SaveChangesAsync() > 0)
{
// rebuild all playouts that use this collection
// refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(parameters.Collection.Id))
.PlayoutIdsUsingCollection(parameters.Collection.Id))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, true));
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh));
}
}
@@ -3,6 +3,7 @@ using ErsatzTV.Application.Playouts;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
@@ -30,9 +31,9 @@ public class AddMusicVideoToCollectionHandler :
AddMusicVideoToCollection request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Parameters> validation = await Validate(dbContext, request);
return await LanguageExtensions.Apply(validation, parameters => ApplyAddMusicVideoRequest(dbContext, parameters));
return await validation.Apply(parameters => ApplyAddMusicVideoRequest(dbContext, parameters));
}
private async Task<Unit> ApplyAddMusicVideoRequest(TvContext dbContext, Parameters parameters)
@@ -40,11 +41,11 @@ public class AddMusicVideoToCollectionHandler :
parameters.Collection.MediaItems.Add(parameters.MusicVideo);
if (await dbContext.SaveChangesAsync() > 0)
{
// rebuild all playouts that use this collection
// refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(parameters.Collection.Id))
.PlayoutIdsUsingCollection(parameters.Collection.Id))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, true));
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh));
}
}
@@ -3,6 +3,7 @@ using ErsatzTV.Application.Playouts;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
@@ -30,9 +31,9 @@ public class AddOtherVideoToCollectionHandler :
AddOtherVideoToCollection request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Parameters> validation = await Validate(dbContext, request);
return await LanguageExtensions.Apply(validation, parameters => ApplyAddOtherVideoRequest(dbContext, parameters));
return await validation.Apply(parameters => ApplyAddOtherVideoRequest(dbContext, parameters));
}
private async Task<Unit> ApplyAddOtherVideoRequest(TvContext dbContext, Parameters parameters)
@@ -40,11 +41,11 @@ public class AddOtherVideoToCollectionHandler :
parameters.Collection.MediaItems.Add(parameters.OtherVideo);
if (await dbContext.SaveChangesAsync() > 0)
{
// rebuild all playouts that use this collection
// refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(parameters.Collection.Id))
.PlayoutIdsUsingCollection(parameters.Collection.Id))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, true));
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh));
}
}
@@ -3,6 +3,7 @@ using ErsatzTV.Application.Playouts;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
@@ -30,9 +31,9 @@ public class AddSeasonToCollectionHandler :
AddSeasonToCollection request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Parameters> validation = await Validate(dbContext, request);
return await LanguageExtensions.Apply(validation, parameters => ApplyAddSeasonRequest(dbContext, parameters));
return await validation.Apply(parameters => ApplyAddSeasonRequest(dbContext, parameters));
}
private async Task<Unit> ApplyAddSeasonRequest(TvContext dbContext, Parameters parameters)
@@ -40,11 +41,11 @@ public class AddSeasonToCollectionHandler :
parameters.Collection.MediaItems.Add(parameters.Season);
if (await dbContext.SaveChangesAsync() > 0)
{
// rebuild all playouts that use this collection
// refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(parameters.Collection.Id))
.PlayoutIdsUsingCollection(parameters.Collection.Id))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, true));
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh));
}
}
@@ -3,6 +3,7 @@ using ErsatzTV.Application.Playouts;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
@@ -30,9 +31,9 @@ public class AddShowToCollectionHandler :
AddShowToCollection request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Parameters> validation = await Validate(dbContext, request);
return await LanguageExtensions.Apply(validation, parameters => ApplyAddShowRequest(dbContext, parameters));
return await validation.Apply(parameters => ApplyAddShowRequest(dbContext, parameters));
}
private async Task<Unit> ApplyAddShowRequest(TvContext dbContext, Parameters parameters)
@@ -40,11 +41,11 @@ public class AddShowToCollectionHandler :
parameters.Collection.MediaItems.Add(parameters.Show);
if (await dbContext.SaveChangesAsync() > 0)
{
// rebuild all playouts that use this collection
// refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(parameters.Collection.Id))
.PlayoutIdsUsingCollection(parameters.Collection.Id))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, true));
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh));
}
}
@@ -3,6 +3,7 @@ using ErsatzTV.Application.Playouts;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
@@ -40,11 +41,11 @@ public class AddSongToCollectionHandler :
parameters.Collection.MediaItems.Add(parameters.Song);
if (await dbContext.SaveChangesAsync() > 0)
{
// rebuild all playouts that use this collection
// refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(parameters.Collection.Id))
.PlayoutIdsUsingCollection(parameters.Collection.Id))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, true));
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh));
}
}
@@ -3,14 +3,14 @@ using ErsatzTV.Application.Playouts;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.MediaCollections;
public class RemoveItemsFromCollectionHandler :
MediatR.IRequestHandler<RemoveItemsFromCollection, Either<BaseError, Unit>>
public class RemoveItemsFromCollectionHandler : IRequestHandler<RemoveItemsFromCollection, Either<BaseError, Unit>>
{
private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
@@ -30,9 +30,9 @@ public class RemoveItemsFromCollectionHandler :
RemoveItemsFromCollection request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Collection> validation = await Validate(dbContext, request);
return await LanguageExtensions.Apply(validation, c => ApplyRemoveItemsRequest(dbContext, request, c));
return await validation.Apply(c => ApplyRemoveItemsRequest(dbContext, request, c));
}
private async Task<Unit> ApplyRemoveItemsRequest(
@@ -48,10 +48,10 @@ public class RemoveItemsFromCollectionHandler :
if (itemsToRemove.Any() && await dbContext.SaveChangesAsync() > 0)
{
// rebuild all playouts that use this collection
// refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository.PlayoutIdsUsingCollection(collection.Id))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, true));
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh));
}
}
@@ -3,14 +3,14 @@ using ErsatzTV.Application.Playouts;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.MediaCollections;
public class UpdateCollectionCustomOrderHandler :
MediatR.IRequestHandler<UpdateCollectionCustomOrder, Either<BaseError, Unit>>
public class UpdateCollectionCustomOrderHandler : IRequestHandler<UpdateCollectionCustomOrder, Either<BaseError, Unit>>
{
private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
@@ -30,9 +30,9 @@ public class UpdateCollectionCustomOrderHandler :
UpdateCollectionCustomOrder request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Collection> validation = await Validate(dbContext, request);
return await LanguageExtensions.Apply(validation, c => ApplyUpdateRequest(dbContext, c, request));
return await validation.Apply(c => ApplyUpdateRequest(dbContext, c, request));
}
private async Task<Unit> ApplyUpdateRequest(
@@ -53,11 +53,11 @@ public class UpdateCollectionCustomOrderHandler :
if (await dbContext.SaveChangesAsync() > 0)
{
// rebuild all playouts that use this collection
// refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(request.CollectionId))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, true));
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh));
}
}
@@ -3,13 +3,14 @@ using ErsatzTV.Application.Playouts;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.MediaCollections;
public class UpdateCollectionHandler : MediatR.IRequestHandler<UpdateCollection, Either<BaseError, Unit>>
public class UpdateCollectionHandler : IRequestHandler<UpdateCollection, Either<BaseError, Unit>>
{
private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
@@ -29,9 +30,9 @@ public class UpdateCollectionHandler : MediatR.IRequestHandler<UpdateCollection,
UpdateCollection request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Collection> validation = await Validate(dbContext, request);
return await LanguageExtensions.Apply(validation, c => ApplyUpdateRequest(dbContext, c, request));
return await validation.Apply(c => ApplyUpdateRequest(dbContext, c, request));
}
private async Task<Unit> ApplyUpdateRequest(TvContext dbContext, Collection c, UpdateCollection request)
@@ -44,11 +45,11 @@ public class UpdateCollectionHandler : MediatR.IRequestHandler<UpdateCollection,
if (await dbContext.SaveChangesAsync() > 0 && request.UseCustomPlaybackOrder.IsSome)
{
// rebuild all playouts that use this collection
// refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository.PlayoutIdsUsingCollection(
request.CollectionId))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, true));
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh));
}
}
@@ -3,13 +3,14 @@ using ErsatzTV.Application.Playouts;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.MediaCollections;
public class UpdateMultiCollectionHandler : MediatR.IRequestHandler<UpdateMultiCollection, Either<BaseError, Unit>>
public class UpdateMultiCollectionHandler : IRequestHandler<UpdateMultiCollection, Either<BaseError, Unit>>
{
private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
@@ -29,15 +30,15 @@ public class UpdateMultiCollectionHandler : MediatR.IRequestHandler<UpdateMultiC
UpdateMultiCollection request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, MultiCollection> validation = await Validate(dbContext, request);
return await LanguageExtensions.Apply(validation, c => ApplyUpdateRequest(dbContext, c, request));
return await validation.Apply(c => ApplyUpdateRequest(dbContext, c, request));
}
private async Task<Unit> ApplyUpdateRequest(TvContext dbContext, MultiCollection c, UpdateMultiCollection request)
{
c.Name = request.Name;
// save name first so playouts don't get rebuilt for a name change
await dbContext.SaveChangesAsync();
@@ -45,22 +46,23 @@ public class UpdateMultiCollectionHandler : MediatR.IRequestHandler<UpdateMultiC
.Filter(i => i.CollectionId.HasValue)
// ReSharper disable once PossibleInvalidOperationException
.Filter(i => c.MultiCollectionItems.All(i2 => i2.CollectionId != i.CollectionId.Value))
.Map(i => new MultiCollectionItem
{
// ReSharper disable once PossibleInvalidOperationException
CollectionId = i.CollectionId.Value,
MultiCollectionId = c.Id,
ScheduleAsGroup = i.ScheduleAsGroup,
PlaybackOrder = i.PlaybackOrder
})
.Map(
i => new MultiCollectionItem
{
// ReSharper disable once PossibleInvalidOperationException
CollectionId = i.CollectionId.Value,
MultiCollectionId = c.Id,
ScheduleAsGroup = i.ScheduleAsGroup,
PlaybackOrder = i.PlaybackOrder
})
.ToList();
var toRemove = c.MultiCollectionItems
.Filter(i => request.Items.All(i2 => i2.CollectionId != i.CollectionId))
.ToList();
// remove items that are no longer present
c.MultiCollectionItems.RemoveAll(toRemove.Contains);
// update existing items
foreach (MultiCollectionItem item in c.MultiCollectionItems)
{
@@ -79,22 +81,23 @@ public class UpdateMultiCollectionHandler : MediatR.IRequestHandler<UpdateMultiC
.Filter(i => i.SmartCollectionId.HasValue)
// ReSharper disable once PossibleInvalidOperationException
.Filter(i => c.MultiCollectionSmartItems.All(i2 => i2.SmartCollectionId != i.SmartCollectionId.Value))
.Map(i => new MultiCollectionSmartItem
{
// ReSharper disable once PossibleInvalidOperationException
SmartCollectionId = i.SmartCollectionId.Value,
MultiCollectionId = c.Id,
ScheduleAsGroup = i.ScheduleAsGroup,
PlaybackOrder = i.PlaybackOrder
})
.Map(
i => new MultiCollectionSmartItem
{
// ReSharper disable once PossibleInvalidOperationException
SmartCollectionId = i.SmartCollectionId.Value,
MultiCollectionId = c.Id,
ScheduleAsGroup = i.ScheduleAsGroup,
PlaybackOrder = i.PlaybackOrder
})
.ToList();
var toRemoveSmart = c.MultiCollectionSmartItems
.Filter(i => request.Items.All(i2 => i2.SmartCollectionId != i.SmartCollectionId))
.ToList();
// remove items that are no longer present
c.MultiCollectionSmartItems.RemoveAll(toRemoveSmart.Contains);
// update existing items
foreach (MultiCollectionSmartItem item in c.MultiCollectionSmartItems)
{
@@ -112,11 +115,11 @@ public class UpdateMultiCollectionHandler : MediatR.IRequestHandler<UpdateMultiC
// rebuild playouts
if (await dbContext.SaveChangesAsync() > 0)
{
// rebuild all playouts that use this collection
// refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository.PlayoutIdsUsingMultiCollection(
request.MultiCollectionId))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, true));
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh));
}
}
@@ -138,7 +141,9 @@ public class UpdateMultiCollectionHandler : MediatR.IRequestHandler<UpdateMultiC
.SelectOneAsync(c => c.Id, c => c.Id == updateCollection.MultiCollectionId)
.Map(o => o.ToValidation<BaseError>("MultiCollection does not exist."));
private static async Task<Validation<BaseError, string>> ValidateName(TvContext dbContext, UpdateMultiCollection updateMultiCollection)
private static async Task<Validation<BaseError, string>> ValidateName(
TvContext dbContext,
UpdateMultiCollection updateMultiCollection)
{
List<string> allNames = await dbContext.MultiCollections
.Filter(mc => mc.Id != updateMultiCollection.MultiCollectionId)
@@ -3,13 +3,14 @@ using ErsatzTV.Application.Playouts;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.MediaCollections;
public class UpdateSmartCollectionHandler : MediatR.IRequestHandler<UpdateSmartCollection, Either<BaseError, Unit>>
public class UpdateSmartCollectionHandler : IRequestHandler<UpdateSmartCollection, Either<BaseError, Unit>>
{
private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
@@ -29,9 +30,9 @@ public class UpdateSmartCollectionHandler : MediatR.IRequestHandler<UpdateSmartC
UpdateSmartCollection request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, SmartCollection> validation = await Validate(dbContext, request);
return await LanguageExtensions.Apply(validation, c => ApplyUpdateRequest(dbContext, c, request));
return await validation.Apply(c => ApplyUpdateRequest(dbContext, c, request));
}
private async Task<Unit> ApplyUpdateRequest(TvContext dbContext, SmartCollection c, UpdateSmartCollection request)
@@ -41,10 +42,10 @@ public class UpdateSmartCollectionHandler : MediatR.IRequestHandler<UpdateSmartC
// rebuild playouts
if (await dbContext.SaveChangesAsync() > 0)
{
// rebuild all playouts that use this smart collection
// refresh all playouts that use this smart collection
foreach (int playoutId in await _mediaCollectionRepository.PlayoutIdsUsingSmartCollection(request.Id))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, true));
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh));
}
}
@@ -1,6 +1,7 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Scheduling;
namespace ErsatzTV.Application.Playouts;
public record BuildPlayout(int PlayoutId, bool Rebuild = false) : MediatR.IRequest<Either<BaseError, Unit>>,
public record BuildPlayout(int PlayoutId, PlayoutBuildMode Mode) : IRequest<Either<BaseError, Unit>>,
IBackgroundServiceRequest;
@@ -1,6 +1,7 @@
using Bugsnag;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Scheduling;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
@@ -13,12 +14,18 @@ public class BuildPlayoutHandler : MediatR.IRequestHandler<BuildPlayout, Either<
private readonly IClient _client;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly IPlayoutBuilder _playoutBuilder;
private readonly IFFmpegSegmenterService _ffmpegSegmenterService;
public BuildPlayoutHandler(IClient client, IDbContextFactory<TvContext> dbContextFactory, IPlayoutBuilder playoutBuilder)
public BuildPlayoutHandler(
IClient client,
IDbContextFactory<TvContext> dbContextFactory,
IPlayoutBuilder playoutBuilder,
IFFmpegSegmenterService ffmpegSegmenterService)
{
_client = client;
_dbContextFactory = dbContextFactory;
_playoutBuilder = playoutBuilder;
_ffmpegSegmenterService = ffmpegSegmenterService;
}
public async Task<Either<BaseError, Unit>> Handle(BuildPlayout request, CancellationToken cancellationToken)
@@ -32,8 +39,11 @@ public class BuildPlayoutHandler : MediatR.IRequestHandler<BuildPlayout, Either<
{
try
{
await _playoutBuilder.BuildPlayoutItems(playout, request.Rebuild);
await dbContext.SaveChangesAsync();
await _playoutBuilder.Build(playout, request.Mode);
if (await dbContext.SaveChangesAsync() > 0)
{
_ffmpegSegmenterService.PlayoutUpdated(playout.Channel.Number);
}
}
catch (Exception ex)
{
@@ -42,7 +52,6 @@ public class BuildPlayoutHandler : MediatR.IRequestHandler<BuildPlayout, Either<
return Unit.Default;
}
private static Task<Validation<BaseError, Playout>> Validate(TvContext dbContext, BuildPlayout request) =>
PlayoutMustExist(dbContext, request);
@@ -1,6 +1,7 @@
using System.Threading.Channels;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
@@ -25,17 +26,16 @@ public class CreatePlayoutHandler : IRequestHandler<CreatePlayout, Either<BaseEr
CreatePlayout request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Playout> validation = await Validate(dbContext, request);
return await LanguageExtensions.Apply(validation, playout => PersistPlayout(dbContext, playout));
return await validation.Apply(playout => PersistPlayout(dbContext, playout));
}
private async Task<CreatePlayoutResponse> PersistPlayout(TvContext dbContext, Playout playout)
{
await dbContext.Playouts.AddAsync(playout);
await dbContext.SaveChangesAsync();
await _channel.WriteAsync(new BuildPlayout(playout.Id));
await _channel.WriteAsync(new BuildPlayout(playout.Id, PlayoutBuildMode.Reset));
return new CreatePlayoutResponse(playout.Id);
}
@@ -9,10 +9,11 @@ using Microsoft.Extensions.Logging;
namespace ErsatzTV.Application.Plex;
public class
SynchronizePlexMediaSourcesHandler : IRequestHandler<SynchronizePlexMediaSources,
Either<BaseError, List<PlexMediaSource>>>
public class SynchronizePlexMediaSourcesHandler : IRequestHandler<SynchronizePlexMediaSources,
Either<BaseError, List<PlexMediaSource>>>
{
private const string LocalhostUri = "http://localhost:32400";
private readonly ChannelWriter<IPlexBackgroundServiceRequest> _channel;
private readonly IEntityLocker _entityLocker;
private readonly ILogger<SynchronizePlexMediaSourcesHandler> _logger;
@@ -72,6 +73,18 @@ public class
private async Task SynchronizeServer(List<PlexMediaSource> allExisting, PlexMediaSource server)
{
if (server.Connections.All(c => c.Uri != LocalhostUri))
{
var localhost = new PlexConnection
{
PlexMediaSource = server,
PlexMediaSourceId = server.Id,
Uri = LocalhostUri
};
server.Connections.Add(localhost);
}
Option<PlexMediaSource> maybeExisting =
allExisting.Find(s => s.ClientIdentifier == server.ClientIdentifier);
@@ -98,7 +111,11 @@ public class
private async Task FindConnectionToActivate(PlexMediaSource server)
{
var prioritized = server.Connections.OrderBy(pc => pc.IsActive ? 0 : 1).ToList();
var prioritized = server.Connections
.OrderByDescending(pc => pc.Uri == LocalhostUri)
.ThenByDescending(pc => pc.IsActive)
.ToList();
foreach (PlexConnection connection in server.Connections)
{
connection.IsActive = false;
@@ -23,4 +23,5 @@ public record AddProgramScheduleItem(
int? MidRollFillerId,
int? PostRollFillerId,
int? TailFillerId,
int? FallbackFillerId) : IRequest<Either<BaseError, ProgramScheduleItemViewModel>>, IProgramScheduleItemRequest;
int? FallbackFillerId,
int? WatermarkId) : IRequest<Either<BaseError, ProgramScheduleItemViewModel>>, IProgramScheduleItemRequest;
@@ -2,6 +2,7 @@
using ErsatzTV.Application.Playouts;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.ProgramSchedules.Mapper;
@@ -26,9 +27,9 @@ public class AddProgramScheduleItemHandler : ProgramScheduleItemCommandBase,
AddProgramScheduleItem request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, ProgramSchedule> validation = await Validate(dbContext, request);
return await LanguageExtensions.Apply(validation, ps => PersistItem(dbContext, request, ps));
return await validation.Apply(ps => PersistItem(dbContext, request, ps));
}
private async Task<ProgramScheduleItemViewModel> PersistItem(
@@ -43,10 +44,10 @@ public class AddProgramScheduleItemHandler : ProgramScheduleItemCommandBase,
await dbContext.SaveChangesAsync();
// rebuild any playouts that use this schedule
// refresh any playouts that use this schedule
foreach (Playout playout in programSchedule.Playouts)
{
await _channel.WriteAsync(new BuildPlayout(playout.Id, true));
await _channel.WriteAsync(new BuildPlayout(playout.Id, PlayoutBuildMode.Refresh));
}
return ProjectToViewModel(item);
@@ -22,4 +22,5 @@ public interface IProgramScheduleItemRequest
int? PostRollFillerId { get; }
int? TailFillerId { get; }
int? FallbackFillerId { get; }
int? WatermarkId { get; }
}
@@ -176,7 +176,8 @@ public abstract class ProgramScheduleItemCommandBase
MidRollFillerId = item.MidRollFillerId,
PostRollFillerId = item.PostRollFillerId,
TailFillerId = item.TailFillerId,
FallbackFillerId = item.FallbackFillerId
FallbackFillerId = item.FallbackFillerId,
WatermarkId = item.WatermarkId
},
PlayoutMode.One => new ProgramScheduleItemOne
{
@@ -195,7 +196,8 @@ public abstract class ProgramScheduleItemCommandBase
MidRollFillerId = item.MidRollFillerId,
PostRollFillerId = item.PostRollFillerId,
TailFillerId = item.TailFillerId,
FallbackFillerId = item.FallbackFillerId
FallbackFillerId = item.FallbackFillerId,
WatermarkId = item.WatermarkId
},
PlayoutMode.Multiple => new ProgramScheduleItemMultiple
{
@@ -215,7 +217,8 @@ public abstract class ProgramScheduleItemCommandBase
MidRollFillerId = item.MidRollFillerId,
PostRollFillerId = item.PostRollFillerId,
TailFillerId = item.TailFillerId,
FallbackFillerId = item.FallbackFillerId
FallbackFillerId = item.FallbackFillerId,
WatermarkId = item.WatermarkId
},
PlayoutMode.Duration => new ProgramScheduleItemDuration
{
@@ -236,7 +239,8 @@ public abstract class ProgramScheduleItemCommandBase
MidRollFillerId = item.MidRollFillerId,
PostRollFillerId = item.PostRollFillerId,
TailFillerId = item.TailFillerId,
FallbackFillerId = item.FallbackFillerId
FallbackFillerId = item.FallbackFillerId,
WatermarkId = item.WatermarkId
},
_ => throw new NotSupportedException($"Unsupported playout mode {item.PlayoutMode}")
};
@@ -23,7 +23,8 @@ public record ReplaceProgramScheduleItem(
int? MidRollFillerId,
int? PostRollFillerId,
int? TailFillerId,
int? FallbackFillerId) : IProgramScheduleItemRequest;
int? FallbackFillerId,
int? WatermarkId) : IProgramScheduleItemRequest;
public record ReplaceProgramScheduleItems
(int ProgramScheduleId, List<ReplaceProgramScheduleItem> Items) : IRequest<
@@ -2,6 +2,7 @@
using ErsatzTV.Application.Playouts;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.ProgramSchedules.Mapper;
@@ -26,9 +27,9 @@ public class ReplaceProgramScheduleItemsHandler : ProgramScheduleItemCommandBase
ReplaceProgramScheduleItems request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, ProgramSchedule> validation = await Validate(dbContext, request);
return await LanguageExtensions.Apply(validation, ps => PersistItems(dbContext, request, ps));
return await validation.Apply(ps => PersistItems(dbContext, request, ps));
}
private async Task<IEnumerable<ProgramScheduleItemViewModel>> PersistItems(
@@ -41,10 +42,10 @@ public class ReplaceProgramScheduleItemsHandler : ProgramScheduleItemCommandBase
await dbContext.SaveChangesAsync();
// rebuild any playouts that use this schedule
// refresh any playouts that use this schedule
foreach (Playout playout in programSchedule.Playouts)
{
await _channel.WriteAsync(new BuildPlayout(playout.Id, true));
await _channel.WriteAsync(new BuildPlayout(playout.Id, PlayoutBuildMode.Refresh));
}
return programSchedule.Items.Map(ProjectToViewModel);
@@ -2,6 +2,7 @@
using ErsatzTV.Application.Playouts;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
@@ -27,9 +28,8 @@ public class UpdateProgramScheduleHandler :
CancellationToken cancellationToken)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, ProgramSchedule> validation = await Validate(dbContext, request);
return await LanguageExtensions.Apply(validation, ps => ApplyUpdateRequest(dbContext, ps, request));
return await validation.Apply(ps => ApplyUpdateRequest(dbContext, ps, request));
}
private async Task<UpdateProgramScheduleResult> ApplyUpdateRequest(
@@ -37,8 +37,8 @@ public class UpdateProgramScheduleHandler :
ProgramSchedule programSchedule,
UpdateProgramSchedule request)
{
// we need to rebuild playouts if the playback order or keep multi-episodes has been modified
bool needToRebuildPlayout =
// we need to refresh playouts if the playback order or keep multi-episodes has been modified
bool needToRefreshPlayout =
programSchedule.KeepMultiPartEpisodesTogether != request.KeepMultiPartEpisodesTogether ||
programSchedule.TreatCollectionsAsShows != request.TreatCollectionsAsShows ||
programSchedule.ShuffleScheduleItems != request.ShuffleScheduleItems;
@@ -51,7 +51,7 @@ public class UpdateProgramScheduleHandler :
await dbContext.SaveChangesAsync();
if (needToRebuildPlayout)
if (needToRefreshPlayout)
{
List<int> playoutIds = await dbContext.Playouts
.Filter(p => p.ProgramScheduleId == programSchedule.Id)
@@ -60,7 +60,7 @@ public class UpdateProgramScheduleHandler :
foreach (int playoutId in playoutIds)
{
await _channel.WriteAsync(new BuildPlayout(playoutId, true));
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh));
}
}
@@ -57,6 +57,9 @@ internal static class Mapper
: null,
duration.FallbackFiller != null
? Filler.Mapper.ProjectToViewModel(duration.FallbackFiller)
: null,
duration.Watermark != null
? Watermarks.Mapper.ProjectToViewModel(duration.Watermark)
: null),
ProgramScheduleItemFlood flood =>
new ProgramScheduleItemFloodViewModel(
@@ -98,6 +101,9 @@ internal static class Mapper
: null,
flood.FallbackFiller != null
? Filler.Mapper.ProjectToViewModel(flood.FallbackFiller)
: null,
flood.Watermark != null
? Watermarks.Mapper.ProjectToViewModel(flood.Watermark)
: null),
ProgramScheduleItemMultiple multiple =>
new ProgramScheduleItemMultipleViewModel(
@@ -140,6 +146,9 @@ internal static class Mapper
: null,
multiple.FallbackFiller != null
? Filler.Mapper.ProjectToViewModel(multiple.FallbackFiller)
: null,
multiple.Watermark != null
? Watermarks.Mapper.ProjectToViewModel(multiple.Watermark)
: null),
ProgramScheduleItemOne one =>
new ProgramScheduleItemOneViewModel(
@@ -181,6 +190,9 @@ internal static class Mapper
: null,
one.FallbackFiller != null
? Filler.Mapper.ProjectToViewModel(one.FallbackFiller)
: null,
one.Watermark != null
? Watermarks.Mapper.ProjectToViewModel(one.Watermark)
: null),
_ => throw new NotSupportedException(
$"Unsupported program schedule item type {programScheduleItem.GetType().Name}")
@@ -1,6 +1,7 @@
using ErsatzTV.Application.Filler;
using ErsatzTV.Application.MediaCollections;
using ErsatzTV.Application.MediaItems;
using ErsatzTV.Application.Watermarks;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.ProgramSchedules;
@@ -26,7 +27,8 @@ public record ProgramScheduleItemDurationViewModel : ProgramScheduleItemViewMode
FillerPresetViewModel midRollFiller,
FillerPresetViewModel postRollFiller,
FillerPresetViewModel tailFiller,
FillerPresetViewModel fallbackFiller) : base(
FillerPresetViewModel fallbackFiller,
WatermarkViewModel watermark) : base(
id,
index,
startType,
@@ -44,7 +46,8 @@ public record ProgramScheduleItemDurationViewModel : ProgramScheduleItemViewMode
midRollFiller,
postRollFiller,
tailFiller,
fallbackFiller)
fallbackFiller,
watermark)
{
PlayoutDuration = playoutDuration;
TailMode = tailMode;
@@ -1,6 +1,7 @@
using ErsatzTV.Application.Filler;
using ErsatzTV.Application.MediaCollections;
using ErsatzTV.Application.MediaItems;
using ErsatzTV.Application.Watermarks;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.ProgramSchedules;
@@ -24,7 +25,8 @@ public record ProgramScheduleItemFloodViewModel : ProgramScheduleItemViewModel
FillerPresetViewModel midRollFiller,
FillerPresetViewModel postRollFiller,
FillerPresetViewModel tailFiller,
FillerPresetViewModel fallbackFiller) : base(
FillerPresetViewModel fallbackFiller,
WatermarkViewModel watermark) : base(
id,
index,
startType,
@@ -42,7 +44,8 @@ public record ProgramScheduleItemFloodViewModel : ProgramScheduleItemViewModel
midRollFiller,
postRollFiller,
tailFiller,
fallbackFiller)
fallbackFiller,
watermark)
{
}
}
@@ -1,6 +1,7 @@
using ErsatzTV.Application.Filler;
using ErsatzTV.Application.MediaCollections;
using ErsatzTV.Application.MediaItems;
using ErsatzTV.Application.Watermarks;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.ProgramSchedules;
@@ -25,7 +26,8 @@ public record ProgramScheduleItemMultipleViewModel : ProgramScheduleItemViewMode
FillerPresetViewModel midRollFiller,
FillerPresetViewModel postRollFiller,
FillerPresetViewModel tailFiller,
FillerPresetViewModel fallbackFiller) : base(
FillerPresetViewModel fallbackFiller,
WatermarkViewModel watermark) : base(
id,
index,
startType,
@@ -43,7 +45,8 @@ public record ProgramScheduleItemMultipleViewModel : ProgramScheduleItemViewMode
midRollFiller,
postRollFiller,
tailFiller,
fallbackFiller) =>
fallbackFiller,
watermark) =>
Count = count;
public int Count { get; }
@@ -1,6 +1,7 @@
using ErsatzTV.Application.Filler;
using ErsatzTV.Application.MediaCollections;
using ErsatzTV.Application.MediaItems;
using ErsatzTV.Application.Watermarks;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.ProgramSchedules;
@@ -24,7 +25,8 @@ public record ProgramScheduleItemOneViewModel : ProgramScheduleItemViewModel
FillerPresetViewModel midRollFiller,
FillerPresetViewModel postRollFiller,
FillerPresetViewModel tailFiller,
FillerPresetViewModel fallbackFiller) : base(
FillerPresetViewModel fallbackFiller,
WatermarkViewModel watermark) : base(
id,
index,
startType,
@@ -42,7 +44,8 @@ public record ProgramScheduleItemOneViewModel : ProgramScheduleItemViewModel
midRollFiller,
postRollFiller,
tailFiller,
fallbackFiller)
fallbackFiller,
watermark)
{
}
}
@@ -1,6 +1,7 @@
using ErsatzTV.Application.Filler;
using ErsatzTV.Application.MediaCollections;
using ErsatzTV.Application.MediaItems;
using ErsatzTV.Application.Watermarks;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.ProgramSchedules;
@@ -23,7 +24,8 @@ public abstract record ProgramScheduleItemViewModel(
FillerPresetViewModel MidRollFiller,
FillerPresetViewModel PostRollFiller,
FillerPresetViewModel TailFiller,
FillerPresetViewModel FallbackFiller)
FillerPresetViewModel FallbackFiller,
WatermarkViewModel Watermark)
{
public string Name => CollectionType switch
{
@@ -49,6 +49,7 @@ public class GetProgramScheduleItemsHandler :
.Include(i => i.PostRollFiller)
.Include(i => i.TailFiller)
.Include(i => i.FallbackFiller)
.Include(i => i.Watermark)
.ToListAsync(cancellationToken)
.Map(
programScheduleItems => programScheduleItems.Map(ProjectToViewModel)
@@ -1,10 +1,9 @@
using System.Diagnostics;
using System.Text;
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;
using ErsatzTV.Core.FFmpeg;
@@ -31,6 +30,7 @@ public class HlsSessionWorker : IHlsSessionWorker
private DateTimeOffset _playlistStart;
private Option<int> _targetFramerate;
private string _channelNumber;
private bool _firstProcess;
public HlsSessionWorker(
IHlsPlaylistFilter hlsPlaylistFilter,
@@ -71,6 +71,8 @@ public class HlsSessionWorker : IHlsSessionWorker
}
}
public void PlayoutUpdated() => _firstProcess = true;
public async Task Run(string channelNumber, TimeSpan idleTimeout, CancellationToken incomingCancellationToken)
{
var cts = CancellationTokenSource.CreateLinkedTokenSource(incomingCancellationToken);
@@ -106,8 +108,10 @@ public class HlsSessionWorker : IHlsSessionWorker
_transcodedUntil = DateTimeOffset.Now;
_playlistStart = _transcodedUntil;
_firstProcess = true;
bool initialWorkAhead = Volatile.Read(ref _workAheadCount) < await GetWorkAheadLimit();
if (!await Transcode(true, !initialWorkAhead, cancellationToken))
if (!await Transcode(!initialWorkAhead, cancellationToken))
{
return;
}
@@ -128,7 +132,7 @@ public class HlsSessionWorker : IHlsSessionWorker
bool realtime = transcodedBuffer >= TimeSpan.FromSeconds(30);
bool subsequentWorkAhead =
!realtime && Volatile.Read(ref _workAheadCount) < await GetWorkAheadLimit();
if (!await Transcode(false, !subsequentWorkAhead, cancellationToken))
if (!await Transcode(!subsequentWorkAhead, cancellationToken))
{
return;
}
@@ -150,7 +154,6 @@ public class HlsSessionWorker : IHlsSessionWorker
}
private async Task<bool> Transcode(
bool firstProcess,
bool realtime,
CancellationToken cancellationToken)
{
@@ -178,8 +181,8 @@ public class HlsSessionWorker : IHlsSessionWorker
var request = new GetPlayoutItemProcessByChannelNumber(
_channelNumber,
"segmenter",
firstProcess ? DateTimeOffset.Now : _transcodedUntil.AddSeconds(1),
!firstProcess,
_firstProcess ? DateTimeOffset.Now : _transcodedUntil.AddSeconds(1),
!_firstProcess,
realtime,
ptsOffset,
_targetFramerate);
@@ -204,27 +207,77 @@ public class HlsSessionWorker : IHlsSessionWorker
{
await TrimAndDelete(cancellationToken);
using Process process = processModel.Process;
Command process = processModel.Process;
_logger.LogInformation(
"ffmpeg hls arguments {FFmpegArguments}",
string.Join(" ", process.StartInfo.ArgumentList));
_logger.LogInformation("ffmpeg hls arguments {FFmpegArguments}", process.Arguments);
try
{
await Cli.Wrap(process.StartInfo.FileName)
.WithArguments(process.StartInfo.ArgumentList)
BufferedCommandResult commandResult = await process
.WithValidation(CommandResultValidation.None)
.ExecuteAsync(cancellationToken);
.ExecuteBufferedAsync(Encoding.UTF8, cancellationToken);
if (commandResult.ExitCode == 0)
{
_logger.LogInformation("HLS process has completed for channel {Channel}", _channelNumber);
_transcodedUntil = processModel.Until;
_firstProcess = false;
return true;
}
else
{
// detect the non-zero exit code and transcode the ffmpeg error message instead
string errorMessage = commandResult.StandardError;
if (string.IsNullOrWhiteSpace(errorMessage))
{
errorMessage = $"Unknown FFMPEG error; exit code {commandResult.ExitCode}";
}
_logger.LogError(
"HLS process for channel {Channel} has terminated unsuccessfully with exit code {ExitCode}: {StandardError}",
_channelNumber,
commandResult.ExitCode,
commandResult.StandardError);
Either<BaseError, PlayoutItemProcessModel> maybeOfflineProcess = await mediator.Send(
new GetErrorProcess(
_channelNumber,
"segmenter",
realtime,
ptsOffset,
processModel.MaybeDuration,
processModel.Until,
errorMessage),
cancellationToken);
foreach (PlayoutItemProcessModel errorProcessModel in maybeOfflineProcess.RightAsEnumerable())
{
Command errorProcess = errorProcessModel.Process;
_logger.LogInformation(
"ffmpeg hls error arguments {FFmpegArguments}",
errorProcess.Arguments);
commandResult = await errorProcess
.WithValidation(CommandResultValidation.None)
.ExecuteBufferedAsync(Encoding.UTF8, cancellationToken);
if (commandResult.ExitCode == 0)
{
_firstProcess = false;
return true;
}
}
return false;
}
}
catch (TaskCanceledException)
{
_logger.LogInformation("Terminating HLS process for channel {Channel}", _channelNumber);
return false;
}
_logger.LogInformation("HLS process has completed for channel {Channel}", _channelNumber);
_transcodedUntil = processModel.Until;
}
}
catch (Exception ex)
@@ -248,7 +301,7 @@ public class HlsSessionWorker : IHlsSessionWorker
Interlocked.Decrement(ref _workAheadCount);
}
return true;
return false;
}
private async Task TrimAndDelete(CancellationToken cancellationToken)
@@ -1,5 +1,8 @@
using System.Diagnostics;
using CliWrap;
namespace ErsatzTV.Application.Streaming;
public record PlayoutItemProcessModel(Process Process, DateTimeOffset Until);
public record PlayoutItemProcessModel(
Command Process,
Option<TimeSpan> MaybeDuration,
DateTimeOffset Until);
@@ -17,9 +17,9 @@ public abstract class FFmpegProcessHandler<T> : IRequestHandler<T, Either<BaseEr
public async Task<Either<BaseError, PlayoutItemProcessModel>> Handle(T request, CancellationToken cancellationToken)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Tuple<Channel, string>> validation = await Validate(dbContext, request);
Validation<BaseError, Tuple<Channel, string, string>> validation = await Validate(dbContext, request);
return await validation.Match(
tuple => GetProcess(dbContext, request, tuple.Item1, tuple.Item2, cancellationToken),
tuple => GetProcess(dbContext, request, tuple.Item1, tuple.Item2, tuple.Item3, cancellationToken),
error => Task.FromResult<Either<BaseError, PlayoutItemProcessModel>>(error.Join()));
}
@@ -28,13 +28,15 @@ public abstract class FFmpegProcessHandler<T> : IRequestHandler<T, Either<BaseEr
T request,
Channel channel,
string ffmpegPath,
string ffprobePath,
CancellationToken cancellationToken);
private static async Task<Validation<BaseError, Tuple<Channel, string>>> Validate(
private static async Task<Validation<BaseError, Tuple<Channel, string, string>>> Validate(
TvContext dbContext,
T request) =>
(await ChannelMustExist(dbContext, request), await FFmpegPathMustExist(dbContext))
.Apply((channel, ffmpegPath) => Tuple(channel, ffmpegPath));
(await ChannelMustExist(dbContext, request), await FFmpegPathMustExist(dbContext),
await FFprobePathMustExist(dbContext))
.Apply((channel, ffmpegPath, ffprobePath) => Tuple(channel, ffmpegPath, ffprobePath));
private static Task<Validation<BaseError, Channel>> ChannelMustExist(TvContext dbContext, T request) =>
dbContext.Channels
@@ -63,4 +65,9 @@ public abstract class FFmpegProcessHandler<T> : IRequestHandler<T, Either<BaseEr
dbContext.ConfigElements.GetValue<string>(ConfigElementKey.FFmpegPath)
.FilterT(File.Exists)
.Map(maybePath => maybePath.ToValidation<BaseError>("FFmpeg path does not exist on filesystem"));
private static Task<Validation<BaseError, string>> FFprobePathMustExist(TvContext dbContext) =>
dbContext.ConfigElements.GetValue<string>(ConfigElementKey.FFprobePath)
.FilterT(File.Exists)
.Map(maybePath => maybePath.ToValidation<BaseError>("FFprobe path does not exist on filesystem"));
}
@@ -1,7 +1,6 @@
using System.Diagnostics;
using CliWrap;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
@@ -11,14 +10,14 @@ namespace ErsatzTV.Application.Streaming;
public class GetConcatProcessByChannelNumberHandler : FFmpegProcessHandler<GetConcatProcessByChannelNumber>
{
private readonly IFFmpegProcessServiceFactory _ffmpegProcessServiceFactory;
private readonly IFFmpegProcessService _ffmpegProcessService;
public GetConcatProcessByChannelNumberHandler(
IDbContextFactory<TvContext> dbContextFactory,
IFFmpegProcessServiceFactory ffmpegProcessServiceFactory)
IFFmpegProcessService ffmpegProcessService)
: base(dbContextFactory)
{
_ffmpegProcessServiceFactory = ffmpegProcessServiceFactory;
_ffmpegProcessService = ffmpegProcessService;
}
protected override async Task<Either<BaseError, PlayoutItemProcessModel>> GetProcess(
@@ -26,20 +25,20 @@ public class GetConcatProcessByChannelNumberHandler : FFmpegProcessHandler<GetCo
GetConcatProcessByChannelNumber request,
Channel channel,
string ffmpegPath,
string ffprobePath,
CancellationToken cancellationToken)
{
bool saveReports = await dbContext.ConfigElements
.GetValue<bool>(ConfigElementKey.FFmpegSaveReports)
.Map(result => result.IfNone(false));
IFFmpegProcessService ffmpegProcessService = await _ffmpegProcessServiceFactory.GetService();
Process process = ffmpegProcessService.ConcatChannel(
Command process = _ffmpegProcessService.ConcatChannel(
ffmpegPath,
saveReports,
channel,
request.Scheme,
request.Host);
return new PlayoutItemProcessModel(process, DateTimeOffset.MaxValue);
return new PlayoutItemProcessModel(process, Option<TimeSpan>.None, DateTimeOffset.MaxValue);
}
}
@@ -0,0 +1,16 @@
namespace ErsatzTV.Application.Streaming;
public record GetErrorProcess(
string ChannelNumber,
string Mode,
bool HlsRealtime,
long PtsOffset,
Option<TimeSpan> MaybeDuration,
DateTimeOffset Until,
string ErrorMessage) : FFmpegProcessRequest(
ChannelNumber,
Mode,
DateTimeOffset.Now,
true,
HlsRealtime,
PtsOffset);
@@ -0,0 +1,40 @@
using CliWrap;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Streaming;
public class GetErrorProcessHandler : FFmpegProcessHandler<GetErrorProcess>
{
private readonly IFFmpegProcessService _ffmpegProcessService;
public GetErrorProcessHandler(
IDbContextFactory<TvContext> dbContextFactory,
IFFmpegProcessService ffmpegProcessService)
: base(dbContextFactory)
{
_ffmpegProcessService = ffmpegProcessService;
}
protected override async Task<Either<BaseError, PlayoutItemProcessModel>> GetProcess(
TvContext dbContext,
GetErrorProcess request,
Channel channel,
string ffmpegPath,
string ffprobePath,
CancellationToken cancellationToken)
{
Command process = await _ffmpegProcessService.ForError(
ffmpegPath,
channel,
request.MaybeDuration,
request.ErrorMessage,
request.HlsRealtime,
request.PtsOffset);
return new PlayoutItemProcessModel(process, request.MaybeDuration, request.Until);
}
}
@@ -25,10 +25,10 @@ public class GetHlsPlaylistByChannelNumberHandler :
GetHlsPlaylistByChannelNumber request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
DateTimeOffset now = DateTimeOffset.Now;
Validation<BaseError, Parameters> validation = await Validate(dbContext, request, now);
return await LanguageExtensions.Apply(validation, parameters => GetPlaylist(dbContext, request, parameters, now));
return await validation.Apply(parameters => GetPlaylist(dbContext, request, parameters, now));
}
private Task<string> GetPlaylist(
@@ -1,10 +1,9 @@
using System.Diagnostics;
using CliWrap;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.Emby;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Jellyfin;
@@ -25,14 +24,14 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
private readonly ITelevisionRepository _televisionRepository;
private readonly IArtistRepository _artistRepository;
private readonly IJellyfinPathReplacementService _jellyfinPathReplacementService;
private readonly IFFmpegProcessServiceFactory _ffmpegProcessServiceFactory;
private readonly IFFmpegProcessService _ffmpegProcessService;
private readonly ILocalFileSystem _localFileSystem;
private readonly IPlexPathReplacementService _plexPathReplacementService;
private readonly ISongVideoGenerator _songVideoGenerator;
public GetPlayoutItemProcessByChannelNumberHandler(
IDbContextFactory<TvContext> dbContextFactory,
IFFmpegProcessServiceFactory ffmpegProcessServiceFactory,
IFFmpegProcessService ffmpegProcessService,
ILocalFileSystem localFileSystem,
IPlexPathReplacementService plexPathReplacementService,
IJellyfinPathReplacementService jellyfinPathReplacementService,
@@ -43,7 +42,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
ISongVideoGenerator songVideoGenerator)
: base(dbContextFactory)
{
_ffmpegProcessServiceFactory = ffmpegProcessServiceFactory;
_ffmpegProcessService = ffmpegProcessService;
_localFileSystem = localFileSystem;
_plexPathReplacementService = plexPathReplacementService;
_jellyfinPathReplacementService = jellyfinPathReplacementService;
@@ -59,6 +58,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
GetPlayoutItemProcessByChannelNumber request,
Channel channel,
string ffmpegPath,
string ffprobePath,
CancellationToken cancellationToken)
{
DateTimeOffset now = request.Now;
@@ -97,6 +97,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as Song).SongMetadata)
.ThenInclude(sm => sm.Artwork)
.Include(i => i.Watermark)
.ForChannelAndTime(channel.Id, now)
.Map(o => o.ToEither<BaseError>(new UnableToLocatePlayoutItem()))
.BindT(ValidatePlayoutItemPath);
@@ -106,8 +107,6 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
maybePlayoutItem = await CheckForFallbackFiller(dbContext, channel, now);
}
IFFmpegProcessService ffmpegProcessService = await _ffmpegProcessServiceFactory.GetService();
foreach (PlayoutItemWithPath playoutItemWithPath in maybePlayoutItem.RightToSeq())
{
MediaVersion version = playoutItemWithPath.PlayoutItem.MediaItem.GetHeadVersion();
@@ -129,8 +128,10 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
(videoPath, videoVersion) = await _songVideoGenerator.GenerateSongVideo(
song,
channel,
Optional(playoutItemWithPath.PlayoutItem.Watermark),
maybeGlobalWatermark,
ffmpegPath,
ffprobePath,
cancellationToken);
}
@@ -138,8 +139,9 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
.GetValue<bool>(ConfigElementKey.FFmpegSaveReports)
.Map(result => result.IfNone(false));
Process process = await ffmpegProcessService.ForPlayoutItem(
Command process = await _ffmpegProcessService.ForPlayoutItem(
ffmpegPath,
ffprobePath,
saveReports,
channel,
videoVersion,
@@ -149,6 +151,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
playoutItemWithPath.PlayoutItem.StartOffset,
playoutItemWithPath.PlayoutItem.FinishOffset,
request.StartAtZero ? playoutItemWithPath.PlayoutItem.StartOffset : now,
Optional(playoutItemWithPath.PlayoutItem.Watermark),
maybeGlobalWatermark,
channel.FFmpegProfile.VaapiDriver,
channel.FFmpegProfile.VaapiDevice,
@@ -159,92 +162,58 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
request.PtsOffset,
request.TargetFramerate);
var result = new PlayoutItemProcessModel(process, playoutItemWithPath.PlayoutItem.FinishOffset);
var result = new PlayoutItemProcessModel(
process,
playoutItemWithPath.PlayoutItem.FinishOffset - (request.StartAtZero ? playoutItemWithPath.PlayoutItem.StartOffset : now),
playoutItemWithPath.PlayoutItem.FinishOffset);
return Right<BaseError, PlayoutItemProcessModel>(result);
}
foreach (BaseError error in maybePlayoutItem.LeftToSeq())
{
var offlineTranscodeMessage =
$"offline image is unavailable because transcoding is disabled in ffmpeg profile '{channel.FFmpegProfile.Name}'";
Option<TimeSpan> maybeDuration = await Optional(channel.FFmpegProfile.Transcode)
.Where(transcode => transcode)
.Match(
_ => dbContext.PlayoutItems
Option<TimeSpan> maybeDuration = await dbContext.PlayoutItems
.Filter(pi => pi.Playout.ChannelId == channel.Id)
.Filter(pi => pi.Start > now.UtcDateTime)
.OrderBy(pi => pi.Start)
.FirstOrDefaultAsync()
.FirstOrDefaultAsync(cancellationToken)
.Map(Optional)
.MapT(pi => pi.StartOffset - now),
() => Option<TimeSpan>.None.AsTask());
.MapT(pi => pi.StartOffset - now);
DateTimeOffset finish = maybeDuration.Match(d => now.Add(d), () => now);
switch (error)
{
case UnableToLocatePlayoutItem:
if (channel.FFmpegProfile.Transcode)
{
Process errorProcess = await ffmpegProcessService.ForError(
ffmpegPath,
channel,
maybeDuration,
"Channel is Offline",
request.HlsRealtime,
request.PtsOffset);
Command offlineProcess = await _ffmpegProcessService.ForError(
ffmpegPath,
channel,
maybeDuration,
"Channel is Offline",
request.HlsRealtime,
request.PtsOffset);
return new PlayoutItemProcessModel(errorProcess, finish);
}
else
{
var message =
$"Unable to locate playout item for channel {channel.Number}; {offlineTranscodeMessage}";
return BaseError.New(message);
}
return new PlayoutItemProcessModel(offlineProcess, maybeDuration, finish);
case PlayoutItemDoesNotExistOnDisk:
if (channel.FFmpegProfile.Transcode)
{
Process errorProcess = await ffmpegProcessService.ForError(
ffmpegPath,
channel,
maybeDuration,
error.Value,
request.HlsRealtime,
request.PtsOffset);
Command doesNotExistProcess = await _ffmpegProcessService.ForError(
ffmpegPath,
channel,
maybeDuration,
error.Value,
request.HlsRealtime,
request.PtsOffset);
return new PlayoutItemProcessModel(errorProcess, finish);
}
else
{
var message =
$"Playout item does not exist on disk for channel {channel.Number}; {offlineTranscodeMessage}";
return BaseError.New(message);
}
return new PlayoutItemProcessModel(doesNotExistProcess, maybeDuration, finish);
default:
if (channel.FFmpegProfile.Transcode)
{
Process errorProcess = await ffmpegProcessService.ForError(
ffmpegPath,
channel,
maybeDuration,
"Channel is Offline",
request.HlsRealtime,
request.PtsOffset);
Command errorProcess = await _ffmpegProcessService.ForError(
ffmpegPath,
channel,
maybeDuration,
"Channel is Offline",
request.HlsRealtime,
request.PtsOffset);
return new PlayoutItemProcessModel(errorProcess, finish);
}
else
{
var message =
$"Unexpected error locating playout item for channel {channel.Number}; {offlineTranscodeMessage}";
return BaseError.New(message);
}
return new PlayoutItemProcessModel(errorProcess, maybeDuration, finish);
}
}
@@ -282,17 +251,13 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
// TODO: shuffle? does it really matter since we loop anyway
MediaItem item = items[new Random().Next(items.Count)];
Option<TimeSpan> maybeDuration = await Optional(channel.FFmpegProfile.Transcode)
.Where(transcode => transcode)
.Match(
_ => dbContext.PlayoutItems
Option<TimeSpan> maybeDuration = await dbContext.PlayoutItems
.Filter(pi => pi.Playout.ChannelId == channel.Id)
.Filter(pi => pi.Start > now.UtcDateTime)
.OrderBy(pi => pi.Start)
.FirstOrDefaultAsync()
.Map(Optional)
.MapT(pi => pi.StartOffset - now),
() => Option<TimeSpan>.None.AsTask());
.MapT(pi => pi.StartOffset - now);
MediaVersion version = item.GetHeadVersion();
@@ -1,7 +1,6 @@
using System.Diagnostics;
using CliWrap;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
@@ -11,14 +10,14 @@ namespace ErsatzTV.Application.Streaming;
public class GetWrappedProcessByChannelNumberHandler : FFmpegProcessHandler<GetWrappedProcessByChannelNumber>
{
private readonly IFFmpegProcessServiceFactory _ffmpegProcessServiceFactory;
private readonly IFFmpegProcessService _ffmpegProcessService;
public GetWrappedProcessByChannelNumberHandler(
IDbContextFactory<TvContext> dbContextFactory,
IFFmpegProcessServiceFactory ffmpegProcessServiceFactory)
IFFmpegProcessService ffmpegProcessService)
: base(dbContextFactory)
{
_ffmpegProcessServiceFactory = ffmpegProcessServiceFactory;
_ffmpegProcessService = ffmpegProcessService;
}
protected override async Task<Either<BaseError, PlayoutItemProcessModel>> GetProcess(
@@ -26,20 +25,20 @@ public class GetWrappedProcessByChannelNumberHandler : FFmpegProcessHandler<GetW
GetWrappedProcessByChannelNumber request,
Channel channel,
string ffmpegPath,
string ffprobePath,
CancellationToken cancellationToken)
{
bool saveReports = await dbContext.ConfigElements
.GetValue<bool>(ConfigElementKey.FFmpegSaveReports)
.Map(result => result.IfNone(false));
IFFmpegProcessService ffmpegProcessService = await _ffmpegProcessServiceFactory.GetService();
Process process = ffmpegProcessService.WrapSegmenter(
Command process = _ffmpegProcessService.WrapSegmenter(
ffmpegPath,
saveReports,
channel,
request.Scheme,
request.Host);
return new PlayoutItemProcessModel(process, DateTimeOffset.MaxValue);
return new PlayoutItemProcessModel(process, Option<TimeSpan>.None, DateTimeOffset.MaxValue);
}
}
@@ -7,10 +7,11 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Bugsnag" Version="3.0.0" />
<PackageReference Include="CliWrap" Version="3.4.1" />
<PackageReference Include="FluentAssertions" Version="6.5.1" />
<PackageReference Include="Bugsnag" Version="3.0.1" />
<PackageReference Include="CliWrap" Version="3.4.2" />
<PackageReference Include="FluentAssertions" Version="6.6.0" />
<PackageReference Include="LanguageExt.Core" Version="4.0.4" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="6.0.1" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="6.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="6.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="6.0.1" />
@@ -21,7 +22,7 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Moq" Version="4.17.2" />
<PackageReference Include="NUnit" Version="3.13.2" />
<PackageReference Include="NUnit" Version="3.13.3" />
<PackageReference Include="NUnit3TestAdapter" Version="4.2.1" />
<PackageReference Include="Serilog" Version="2.10.0" />
<PackageReference Include="Serilog.Extensions.Logging" Version="3.1.0" />
@@ -36,6 +37,9 @@
<Content Include="Resources\ErsatzTV.png">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
<Content Include="Resources\test.sup">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project>
@@ -9,13 +9,15 @@ namespace ErsatzTV.Core.Tests.FFmpeg;
[TestFixture]
public class FFmpegPlaybackSettingsCalculatorTests
{
private static readonly MediaVersion TestVersion = new() { SampleAspectRatio = "1:1", Width = 1920, Height = 1080 };
[TestFixture]
public class CalculateSettings
{
private readonly FFmpegPlaybackSettingsCalculator _calculator;
public CalculateSettings() => _calculator = new FFmpegPlaybackSettingsCalculator();
[Test]
public void Should_Not_GenPts_ForHlsSegmenter()
{
@@ -24,7 +26,7 @@ public class FFmpegPlaybackSettingsCalculatorTests
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.HttpLiveStreamingSegmenter,
ffmpegProfile,
new MediaVersion(),
TestVersion,
new MediaStream(),
new MediaStream(),
DateTimeOffset.Now,
@@ -47,7 +49,7 @@ public class FFmpegPlaybackSettingsCalculatorTests
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream,
ffmpegProfile,
new MediaVersion(),
TestVersion,
new MediaStream(),
new MediaStream(),
DateTimeOffset.Now,
@@ -68,7 +70,7 @@ public class FFmpegPlaybackSettingsCalculatorTests
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.HttpLiveStreamingSegmenter,
ffmpegProfile,
new MediaVersion(),
TestVersion,
new MediaStream(),
new MediaStream(),
DateTimeOffset.Now,
@@ -89,7 +91,7 @@ public class FFmpegPlaybackSettingsCalculatorTests
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream,
ffmpegProfile,
new MediaVersion(),
TestVersion,
new MediaStream(),
new MediaStream(),
DateTimeOffset.Now,
@@ -112,7 +114,7 @@ public class FFmpegPlaybackSettingsCalculatorTests
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.HttpLiveStreamingDirect,
ffmpegProfile,
new MediaVersion(),
TestVersion,
new MediaStream(),
new MediaStream(),
DateTimeOffset.Now,
@@ -135,7 +137,7 @@ public class FFmpegPlaybackSettingsCalculatorTests
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream,
ffmpegProfile,
new MediaVersion(),
TestVersion,
new MediaStream(),
new MediaStream(),
DateTimeOffset.Now,
@@ -156,7 +158,7 @@ public class FFmpegPlaybackSettingsCalculatorTests
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.HttpLiveStreamingDirect,
ffmpegProfile,
new MediaVersion(),
TestVersion,
new MediaStream(),
new MediaStream(),
DateTimeOffset.Now,
@@ -179,7 +181,7 @@ public class FFmpegPlaybackSettingsCalculatorTests
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream,
ffmpegProfile,
new MediaVersion(),
TestVersion,
new MediaStream(),
new MediaStream(),
now,
@@ -203,7 +205,7 @@ public class FFmpegPlaybackSettingsCalculatorTests
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.HttpLiveStreamingDirect,
ffmpegProfile,
new MediaVersion(),
TestVersion,
new MediaStream(),
new MediaStream(),
now,
@@ -217,33 +219,11 @@ public class FFmpegPlaybackSettingsCalculatorTests
actual.StreamSeek.IfNone(TimeSpan.Zero).Should().Be(TimeSpan.FromMinutes(5));
}
[Test]
public void ShouldNot_SetScaledSize_When_NotNormalizingVideo_ForTransportStream()
{
FFmpegProfile ffmpegProfile = TestProfile() with { NormalizeVideo = false };
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream,
ffmpegProfile,
new MediaVersion(),
new MediaStream(),
new MediaStream(),
DateTimeOffset.Now,
DateTimeOffset.Now,
TimeSpan.Zero,
TimeSpan.Zero,
false,
None);
actual.ScaledSize.IsNone.Should().BeTrue();
}
[Test]
public void ShouldNot_SetScaledSize_When_ContentIsCorrectSize_ForTransportStream()
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
NormalizeVideo = true,
Resolution = new Resolution { Width = 1920, Height = 1080 }
};
@@ -271,7 +251,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
NormalizeVideo = true,
Resolution = new Resolution { Width = 1920, Height = 1080 }
};
@@ -299,7 +278,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
NormalizeVideo = true,
Resolution = new Resolution { Width = 1920, Height = 1080 }
};
@@ -328,8 +306,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
Transcode = true,
NormalizeVideo = true,
Resolution = new Resolution { Width = 1920, Height = 1080 }
};
@@ -358,8 +334,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
Transcode = true,
NormalizeVideo = true,
Resolution = new Resolution { Width = 1280, Height = 720 }
};
@@ -389,8 +363,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
Transcode = true,
NormalizeVideo = true,
Resolution = new Resolution { Width = 1920, Height = 1080 }
};
@@ -414,45 +386,14 @@ public class FFmpegPlaybackSettingsCalculatorTests
actual.PadToDesiredResolution.Should().BeFalse();
}
[Test]
public void Should_NotPadToDesiredResolution_When_NotNormalizingVideo()
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
Transcode = true,
NormalizeVideo = false,
Resolution = new Resolution { Width = 1920, Height = 1080 }
};
// not anamorphic
var version = new MediaVersion { Width = 1918, Height = 1080, SampleAspectRatio = "1:1" };
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream,
ffmpegProfile,
version,
new MediaStream(),
new MediaStream(),
DateTimeOffset.Now,
DateTimeOffset.Now,
TimeSpan.Zero,
TimeSpan.Zero,
false,
None);
actual.ScaledSize.IsNone.Should().BeTrue();
actual.PadToDesiredResolution.Should().BeFalse();
}
[Test]
public void Should_SetDesiredVideoCodec_When_ContentIsPadded_ForTransportStream()
public void Should_SetDesiredVideoFormat_When_ContentIsPadded_ForTransportStream()
{
var ffmpegProfile = new FFmpegProfile
{
Transcode = true,
NormalizeVideo = true,
Resolution = new Resolution { Width = 1920, Height = 1080 },
VideoCodec = "testCodec"
VideoFormat = FFmpegProfileVideoFormat.H264
};
// not anamorphic
@@ -473,19 +414,17 @@ public class FFmpegPlaybackSettingsCalculatorTests
actual.ScaledSize.IsNone.Should().BeTrue();
actual.PadToDesiredResolution.Should().BeTrue();
actual.VideoCodec.Should().Be("testCodec");
actual.VideoFormat.Should().Be(FFmpegProfileVideoFormat.H264);
}
[Test]
public void
Should_SetDesiredVideoCodec_When_ContentIsCorrectSize_And_NormalizingVideo_ForTransportStream()
Should_SetDesiredVideoFormat_When_ContentIsCorrectSize_ForTransportStream()
{
var ffmpegProfile = new FFmpegProfile
{
Transcode = true,
NormalizeVideo = true,
Resolution = new Resolution { Width = 1920, Height = 1080 },
VideoCodec = "testCodec"
VideoFormat = FFmpegProfileVideoFormat.H264
};
// not anamorphic
@@ -507,19 +446,16 @@ public class FFmpegPlaybackSettingsCalculatorTests
actual.ScaledSize.IsNone.Should().BeTrue();
actual.PadToDesiredResolution.Should().BeFalse();
actual.VideoCodec.Should().Be("testCodec");
actual.VideoFormat.Should().Be(FFmpegProfileVideoFormat.H264);
}
[Test]
public void
Should_SetCopyVideoCodec_When_ContentIsCorrectSize_And_NormalizingVideo_ForHttpLiveStreaming()
public void Should_SetCopyVideoFormat_When_ContentIsCorrectSize_ForHttpLiveStreamingDirect()
{
var ffmpegProfile = new FFmpegProfile
{
Transcode = true,
NormalizeVideo = true,
Resolution = new Resolution { Width = 1920, Height = 1080 },
VideoCodec = "testCodec"
VideoFormat = FFmpegProfileVideoFormat.H264
};
// not anamorphic
@@ -541,18 +477,16 @@ public class FFmpegPlaybackSettingsCalculatorTests
actual.ScaledSize.IsNone.Should().BeTrue();
actual.PadToDesiredResolution.Should().BeFalse();
actual.VideoCodec.Should().Be("copy");
actual.VideoFormat.Should().Be(FFmpegProfileVideoFormat.Copy);
}
[Test]
public void Should_SetCopyVideoCodec_When_ContentIsCorrectSize_And_CorrectCodec_ForTransportStream()
public void Should_NotSetCopyVideoFormat_When_ContentIsCorrectSize_And_CorrectFormat_ForTransportStream()
{
var ffmpegProfile = new FFmpegProfile
{
Transcode = true,
NormalizeVideo = true,
Resolution = new Resolution { Width = 1920, Height = 1080 },
VideoCodec = "libx264"
VideoFormat = FFmpegProfileVideoFormat.H264
};
// not anamorphic
@@ -563,7 +497,7 @@ public class FFmpegPlaybackSettingsCalculatorTests
StreamingMode.TransportStream,
ffmpegProfile,
version,
new MediaStream { Codec = "libx264" },
new MediaStream { Codec = "h264" },
new MediaStream(),
DateTimeOffset.Now,
DateTimeOffset.Now,
@@ -574,79 +508,7 @@ public class FFmpegPlaybackSettingsCalculatorTests
actual.ScaledSize.IsNone.Should().BeTrue();
actual.PadToDesiredResolution.Should().BeFalse();
actual.VideoCodec.Should().Be("copy");
}
[Test]
public void
Should_SetCopyVideoCodec_When_ContentIsCorrectSize_And_NotNormalizingVideo_ForTransportStream()
{
var ffmpegProfile = new FFmpegProfile
{
Transcode = true,
NormalizeVideo = false,
Resolution = new Resolution { Width = 1920, Height = 1080 },
VideoCodec = "libx264"
};
// not anamorphic
var version = new MediaVersion
{ Width = 1920, Height = 1080, SampleAspectRatio = "1:1" };
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream,
ffmpegProfile,
version,
new MediaStream { Codec = "mpeg2video" },
new MediaStream(),
DateTimeOffset.Now,
DateTimeOffset.Now,
TimeSpan.Zero,
TimeSpan.Zero,
false,
None);
actual.ScaledSize.IsNone.Should().BeTrue();
actual.PadToDesiredResolution.Should().BeFalse();
actual.VideoCodec.Should().Be("copy");
}
[Test]
public void
Should_SetCopyVideoCodec_AndCopyAudioCodec_When_NotTranscoding_ForTransportStream()
{
var ffmpegProfile = new FFmpegProfile
{
Transcode = false,
NormalizeVideo = true,
NormalizeAudio = true,
NormalizeLoudness = true,
Resolution = new Resolution { Width = 1920, Height = 1080 },
VideoCodec = "libx264"
};
// not anamorphic
var version = new MediaVersion
{ Width = 1920, Height = 1080, SampleAspectRatio = "1:1" };
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream,
ffmpegProfile,
version,
new MediaStream { Codec = "mpeg2video" },
new MediaStream(),
DateTimeOffset.Now,
DateTimeOffset.Now,
TimeSpan.Zero,
TimeSpan.Zero,
false,
None);
actual.ScaledSize.IsNone.Should().BeTrue();
actual.PadToDesiredResolution.Should().BeFalse();
actual.VideoCodec.Should().Be("copy");
actual.NormalizeLoudness.Should().BeFalse();
actual.AudioCodec.Should().Be("copy");
actual.VideoFormat.Should().Be(FFmpegProfileVideoFormat.H264);
}
[Test]
@@ -654,8 +516,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
{
var ffmpegProfile = new FFmpegProfile
{
Transcode = true,
NormalizeVideo = true,
Resolution = new Resolution { Width = 1920, Height = 1080 },
VideoBitrate = 2525
};
@@ -682,12 +542,10 @@ public class FFmpegPlaybackSettingsCalculatorTests
}
[Test]
public void Should_SetVideoBitrate_When_ContentIsCorrectSize_And_NormalizingVideo_ForTransportStream()
public void Should_SetVideoBitrate_When_ContentIsCorrectSize_ForTransportStream()
{
var ffmpegProfile = new FFmpegProfile
{
Transcode = true,
NormalizeVideo = true,
Resolution = new Resolution { Width = 1920, Height = 1080 },
VideoBitrate = 2525
};
@@ -719,8 +577,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
{
var ffmpegProfile = new FFmpegProfile
{
Transcode = true,
NormalizeVideo = true,
Resolution = new Resolution { Width = 1920, Height = 1080 },
VideoBufferSize = 2525
};
@@ -747,13 +603,10 @@ public class FFmpegPlaybackSettingsCalculatorTests
}
[Test]
public void
Should_SetVideoBufferSize_When_ContentIsCorrectSize_And_NormalizingVideo_ForTransportStream()
public void Should_SetVideoBufferSize_When_ContentIsCorrectSize_ForTransportStream()
{
var ffmpegProfile = new FFmpegProfile
{
Transcode = true,
NormalizeVideo = true,
Resolution = new Resolution { Width = 1920, Height = 1080 },
VideoBufferSize = 2525
};
@@ -781,21 +634,17 @@ public class FFmpegPlaybackSettingsCalculatorTests
}
[Test]
public void Should_SetDesiredAudioCodec_When_NormalizingAudio_With_CorrectCodec_ForTransportStream()
public void Should_SetDesiredAudioFormat_With_CorrectFormat_ForTransportStream()
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
Transcode = true,
NormalizeAudio = true,
AudioCodec = "aac"
AudioFormat = FFmpegProfileAudioFormat.Aac
};
var version = new MediaVersion();
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream,
ffmpegProfile,
version,
TestVersion,
new MediaStream(),
new MediaStream { Codec = "aac" },
DateTimeOffset.Now,
@@ -805,24 +654,21 @@ public class FFmpegPlaybackSettingsCalculatorTests
false,
None);
actual.AudioCodec.Should().Be("aac");
actual.AudioFormat.Should().Be(FFmpegProfileAudioFormat.Aac);
}
[Test]
public void Should_SetCopyAudioCodec_When_NotNormalizingAudio_ForTransportStream()
public void Should_SetDesiredAudioFormat_ForTransportStream()
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
NormalizeAudio = false,
AudioCodec = "aac"
AudioFormat = FFmpegProfileAudioFormat.Aac
};
var version = new MediaVersion();
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream,
ffmpegProfile,
version,
TestVersion,
new MediaStream(),
new MediaStream { Codec = "ac3" },
DateTimeOffset.Now,
@@ -832,53 +678,21 @@ public class FFmpegPlaybackSettingsCalculatorTests
false,
None);
actual.AudioCodec.Should().Be("copy");
actual.AudioFormat.Should().Be(FFmpegProfileAudioFormat.Aac);
}
[Test]
public void Should_SetDesiredAudioCodec_When_NormalizingAudio_ForTransportStream()
public void Should_SetCopyAudioFormat_ForHttpLiveStreamingDirect()
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
Transcode = true,
NormalizeAudio = true,
AudioCodec = "aac"
AudioFormat = FFmpegProfileAudioFormat.Aac
};
var version = new MediaVersion();
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream,
ffmpegProfile,
version,
new MediaStream(),
new MediaStream { Codec = "ac3" },
DateTimeOffset.Now,
DateTimeOffset.Now,
TimeSpan.Zero,
TimeSpan.Zero,
false,
None);
actual.AudioCodec.Should().Be("aac");
}
[Test]
public void Should_SetCopyAudioCodec_When_NormalizingAudio_ForHttpLiveStreaming()
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
Transcode = true,
NormalizeAudio = true,
AudioCodec = "aac"
};
var version = new MediaVersion();
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.HttpLiveStreamingDirect,
ffmpegProfile,
version,
TestVersion,
new MediaStream(),
new MediaStream { Codec = "ac3" },
DateTimeOffset.Now,
@@ -888,26 +702,22 @@ public class FFmpegPlaybackSettingsCalculatorTests
false,
None);
actual.AudioCodec.Should().Be("copy");
actual.AudioFormat.Should().Be(FFmpegProfileAudioFormat.Copy);
}
[Test]
public void Should_SetAudioBitrate_When_NormalizingAudio_With_CorrectCodec_ForTransportStream()
public void Should_SetAudioBitrate_With_CorrectFormat_ForTransportStream()
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
Transcode = true,
NormalizeAudio = true,
AudioBitrate = 2424,
AudioCodec = "ac3"
AudioFormat = FFmpegProfileAudioFormat.Ac3
};
var version = new MediaVersion();
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream,
ffmpegProfile,
version,
TestVersion,
new MediaStream(),
new MediaStream { Codec = "ac3" },
DateTimeOffset.Now,
@@ -921,22 +731,18 @@ public class FFmpegPlaybackSettingsCalculatorTests
}
[Test]
public void Should_SetAudioBufferSize_When_NormalizingAudio_With_CorrectCodec_ForTransportStream()
public void Should_SetAudioBufferSize_With_CorrectFormat_ForTransportStream()
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
Transcode = true,
NormalizeAudio = true,
AudioBufferSize = 2424,
AudioCodec = "ac3"
AudioFormat = FFmpegProfileAudioFormat.Ac3
};
var version = new MediaVersion();
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream,
ffmpegProfile,
version,
TestVersion,
new MediaStream(),
new MediaStream { Codec = "ac3" },
DateTimeOffset.Now,
@@ -950,22 +756,18 @@ public class FFmpegPlaybackSettingsCalculatorTests
}
[Test]
public void Should_SetAudioChannels_When_NormalizingAudio_With_CorrectCodec_ForTransportStream()
public void Should_SetAudioChannels_With_CorrectFormat_ForTransportStream()
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
Transcode = true,
NormalizeAudio = true,
AudioCodec = "ac3",
AudioFormat = FFmpegProfileAudioFormat.Ac3,
AudioChannels = 6
};
var version = new MediaVersion();
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream,
ffmpegProfile,
version,
TestVersion,
new MediaStream(),
new MediaStream { Codec = "ac3" },
DateTimeOffset.Now,
@@ -979,22 +781,18 @@ public class FFmpegPlaybackSettingsCalculatorTests
}
[Test]
public void Should_SetAudioSampleRate_When_NormalizingAudio_With_CorrectCodec_ForTransportStream()
public void Should_SetAudioSampleRate_With_CorrectFormat_ForTransportStream()
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
Transcode = true,
NormalizeAudio = true,
AudioCodec = "ac3",
AudioFormat = FFmpegProfileAudioFormat.Ac3,
AudioSampleRate = 48
};
var version = new MediaVersion();
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream,
ffmpegProfile,
version,
TestVersion,
new MediaStream(),
new MediaStream { Codec = "ac3" },
DateTimeOffset.Now,
@@ -1008,21 +806,17 @@ public class FFmpegPlaybackSettingsCalculatorTests
}
[Test]
public void Should_SetAudioChannels_When_NormalizingAudio_ForTransportStream()
public void Should_SetAudioChannels_ForTransportStream()
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
Transcode = true,
NormalizeAudio = true,
AudioChannels = 6
};
var version = new MediaVersion();
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream,
ffmpegProfile,
version,
TestVersion,
new MediaStream(),
new MediaStream { Codec = "ac3" },
DateTimeOffset.Now,
@@ -1036,21 +830,17 @@ public class FFmpegPlaybackSettingsCalculatorTests
}
[Test]
public void Should_SetAudioSampleRate_When_NormalizingAudio_ForTransportStream()
public void Should_SetAudioSampleRate_ForTransportStream()
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
Transcode = true,
NormalizeAudio = true,
AudioSampleRate = 48
};
var version = new MediaVersion();
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream,
ffmpegProfile,
version,
TestVersion,
new MediaStream(),
new MediaStream { Codec = "ac3" },
DateTimeOffset.Now,
@@ -1064,17 +854,15 @@ public class FFmpegPlaybackSettingsCalculatorTests
}
[Test]
public void Should_SetAudioDuration_When_NormalizingAudio_With_CorrectCodec_ForTransportStream()
public void Should_SetAudioDuration_With_CorrectFormat_ForTransportStream()
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
Transcode = true,
NormalizeAudio = true,
AudioSampleRate = 48,
AudioCodec = "ac3"
AudioFormat = FFmpegProfileAudioFormat.Ac3
};
var version = new MediaVersion { Duration = TimeSpan.FromMinutes(5) }; // not pulled from here
var version = new MediaVersion { SampleAspectRatio = "1:1", Width = 1920, Height = 1080, Duration = TimeSpan.FromMinutes(5) }; // not pulled from here
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream,
@@ -1093,21 +881,17 @@ public class FFmpegPlaybackSettingsCalculatorTests
}
[Test]
public void Should_SetNormalizeLoudness_When_NormalizingAudio_ForTransportStream()
public void Should_SetNormalizeLoudness_ForTransportStream()
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
Transcode = true,
NormalizeAudio = true,
NormalizeLoudness = true
};
var version = new MediaVersion();
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream,
ffmpegProfile,
version,
TestVersion,
new MediaStream(),
new MediaStream { Codec = "ac3" },
DateTimeOffset.Now,
@@ -1119,34 +903,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
actual.NormalizeLoudness.Should().BeTrue();
}
[Test]
public void Should_NotSetNormalizeLoudness_When_NotNormalizingAudio_ForTransportStream()
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
Transcode = true,
NormalizeAudio = false,
NormalizeLoudness = true
};
var version = new MediaVersion();
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream,
ffmpegProfile,
version,
new MediaStream(),
new MediaStream { Codec = "ac3" },
DateTimeOffset.Now,
DateTimeOffset.Now,
TimeSpan.Zero,
TimeSpan.Zero,
false,
None);
actual.NormalizeLoudness.Should().BeFalse();
}
}
[TestFixture]
@@ -1165,7 +921,7 @@ public class FFmpegPlaybackSettingsCalculatorTests
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream,
ffmpegProfile,
new MediaVersion(),
TestVersion,
new MediaStream(),
new MediaStream(),
DateTimeOffset.Now,
+92 -64
View File
@@ -11,6 +11,7 @@ using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Metadata;
using FluentAssertions;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Moq;
using NUnit.Framework;
@@ -64,6 +65,12 @@ public class TranscodingTests
// TODO: animated vs static
}
public enum Subtitle
{
None,
Picture
}
private class TestData
{
public static Watermark[] Watermarks =
@@ -72,6 +79,12 @@ public class TranscodingTests
Watermark.PermanentOpaque,
Watermark.PermanentTransparent
};
public static Subtitle[] Subtitles =
{
Subtitle.None,
Subtitle.Picture
};
public static Padding[] Paddings =
{
@@ -118,21 +131,15 @@ public class TranscodingTests
new() { Width = 1280, Height = 720 }
};
public static string[] SoftwareCodecs =
{
"libx264",
"libx265"
};
public static HardwareAccelerationKind[] NoAcceleration =
{
HardwareAccelerationKind.None
};
public static string[] NvidiaCodecs =
public static FFmpegProfileVideoFormat[] VideoFormats =
{
"h264_nvenc",
"hevc_nvenc"
FFmpegProfileVideoFormat.H264,
FFmpegProfileVideoFormat.Hevc
};
public static HardwareAccelerationKind[] NvidiaAcceleration =
@@ -140,34 +147,16 @@ public class TranscodingTests
HardwareAccelerationKind.Nvenc
};
public static string[] VaapiCodecs =
{
"h264_vaapi",
"hevc_vaapi"
};
public static HardwareAccelerationKind[] VaapiAcceleration =
{
HardwareAccelerationKind.Vaapi
};
public static string[] VideoToolboxCodecs =
{
"h264_videotoolbox",
"hevc_videotoolbox"
};
public static HardwareAccelerationKind[] VideoToolboxAcceleration =
{
HardwareAccelerationKind.VideoToolbox
};
public static string[] QsvCodecs =
{
"h264_qsv",
"hevc_qsv"
};
public static HardwareAccelerationKind[] QsvAcceleration =
{
HardwareAccelerationKind.Qsv
@@ -176,27 +165,17 @@ public class TranscodingTests
[Test, Combinatorial]
public async Task Transcode(
[ValueSource(typeof(TestData), nameof(TestData.InputFormats))]
InputFormat inputFormat,
[ValueSource(typeof(TestData), nameof(TestData.Resolutions))]
Resolution profileResolution,
[ValueSource(typeof(TestData), nameof(TestData.Paddings))]
Padding padding,
[ValueSource(typeof(TestData), nameof(TestData.VideoScanKinds))]
VideoScanKind videoScanKind,
[ValueSource(typeof(TestData), nameof(TestData.Watermarks))]
Watermark watermark,
// [ValueSource(typeof(TestData), nameof(TestData.SoftwareCodecs))] string profileCodec,
// [ValueSource(typeof(TestData), nameof(TestData.NoAcceleration))] HardwareAccelerationKind profileAcceleration)
[ValueSource(typeof(TestData), nameof(TestData.NvidiaCodecs))]
string profileCodec,
[ValueSource(typeof(TestData), nameof(TestData.NvidiaAcceleration))]
HardwareAccelerationKind profileAcceleration)
// [ValueSource(typeof(TestData), nameof(TestData.VaapiCodecs))] string profileCodec,
[ValueSource(typeof(TestData), nameof(TestData.InputFormats))] InputFormat inputFormat,
[ValueSource(typeof(TestData), nameof(TestData.Resolutions))] Resolution profileResolution,
[ValueSource(typeof(TestData), nameof(TestData.Paddings))] Padding padding,
[ValueSource(typeof(TestData), nameof(TestData.VideoScanKinds))] VideoScanKind videoScanKind,
[ValueSource(typeof(TestData), nameof(TestData.Watermarks))] Watermark watermark,
[ValueSource(typeof(TestData), nameof(TestData.Subtitles))] Subtitle subtitle,
[ValueSource(typeof(TestData), nameof(TestData.VideoFormats))] FFmpegProfileVideoFormat profileVideoFormat,
// [ValueSource(typeof(TestData), nameof(TestData.NoAcceleration))] HardwareAccelerationKind profileAcceleration)
[ValueSource(typeof(TestData), nameof(TestData.NvidiaAcceleration))] HardwareAccelerationKind profileAcceleration)
// [ValueSource(typeof(TestData), nameof(TestData.VaapiAcceleration))] HardwareAccelerationKind profileAcceleration)
// [ValueSource(typeof(TestData), nameof(TestData.QsvCodecs))] string profileCodec,
// [ValueSource(typeof(TestData), nameof(TestData.QsvAcceleration))] HardwareAccelerationKind profileAcceleration)
// [ValueSource(typeof(TestData), nameof(TestData.VideoToolboxCodecs))] string profileCodec,
// [ValueSource(typeof(TestData), nameof(TestData.VideoToolboxAcceleration))] HardwareAccelerationKind profileAcceleration)
{
if (inputFormat.Encoder is "mpeg1video" or "msmpeg4v2" or "msmpeg4v3")
@@ -209,7 +188,7 @@ public class TranscodingTests
}
string name = GetStringSha256Hash(
$"{inputFormat.Encoder}_{inputFormat.PixelFormat}_{videoScanKind}_{padding}_{profileResolution}_{profileCodec}_{profileAcceleration}");
$"{inputFormat.Encoder}_{inputFormat.PixelFormat}_{videoScanKind}_{padding}_{watermark}_{subtitle}_{profileResolution}_{profileVideoFormat}_{profileAcceleration}");
string file = Path.Combine(TestContext.CurrentContext.TestDirectory, $"{name}.mkv");
if (!File.Exists(file))
@@ -237,6 +216,46 @@ public class TranscodingTests
// ReSharper disable once MethodHasAsyncOverload
p1.WaitForExit();
p1.ExitCode.Should().Be(0);
switch (subtitle)
{
case Subtitle.Picture:
string sourceFile = Path.GetTempFileName() + ".mkv";
File.Move(file, sourceFile, true);
string tempFileName = Path.GetTempFileName() + ".mkv";
string subPath = Path.Combine(TestContext.CurrentContext.TestDirectory, "Resources", "test.sup");
var p2 = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = ExecutableName("mkvmerge"),
Arguments = $"-o {tempFileName} {sourceFile} {subPath}"
}
};
p2.Start();
await p2.WaitForExitAsync();
// ReSharper disable once MethodHasAsyncOverload
p2.WaitForExit();
if (p2.ExitCode != 0)
{
if (File.Exists(sourceFile))
{
File.Delete(sourceFile);
}
if (File.Exists(file))
{
File.Delete(file);
}
}
p2.ExitCode.Should().Be(0);
File.Move(tempFileName, file, true);
break;
}
}
var imageCache = new Mock<IImageCache>();
@@ -255,6 +274,7 @@ public class TranscodingTests
imageCache.Object,
new Mock<ITempFilePool>().Object,
new Mock<IClient>().Object,
new MemoryCache(new MemoryCacheOptions()),
LoggerFactory.CreateLogger<FFmpegProcessService>());
var service = new FFmpegLibraryProcessService(
@@ -351,8 +371,15 @@ public class TranscodingTests
break;
}
using Process process = await service.ForPlayoutItem(
ChannelSubtitleMode subtitleMode = subtitle switch
{
Subtitle.Picture => ChannelSubtitleMode.Any,
_ => ChannelSubtitleMode.None
};
Command process = await service.ForPlayoutItem(
ExecutableName("ffmpeg"),
ExecutableName("ffprobe"),
false,
new Channel(Guid.NewGuid())
{
@@ -360,10 +387,12 @@ public class TranscodingTests
FFmpegProfile = FFmpegProfile.New("test", profileResolution) with
{
HardwareAcceleration = profileAcceleration,
VideoCodec = profileCodec,
AudioCodec = "aac"
VideoFormat = profileVideoFormat,
AudioFormat = FFmpegProfileAudioFormat.Aac,
DeinterlaceVideo = true
},
StreamingMode = StreamingMode.TransportStream
StreamingMode = StreamingMode.TransportStream,
SubtitleMode = subtitleMode
},
v,
v,
@@ -372,6 +401,7 @@ public class TranscodingTests
now,
now + TimeSpan.FromSeconds(5),
now,
Option<ChannelWatermark>.None,
channelWatermark,
VaapiDriver.Default,
"/dev/dri/renderD128",
@@ -396,16 +426,14 @@ public class TranscodingTests
var timeoutSignal = new CancellationTokenSource(TimeSpan.FromSeconds(30));
try
{
result = await Cli.Wrap(process.StartInfo.FileName)
.WithArguments(process.StartInfo.ArgumentList)
.WithValidation(CommandResultValidation.None)
result = await process
.WithStandardOutputPipe(PipeTarget.ToStream(Stream.Null))
.WithStandardErrorPipe(PipeTarget.ToStringBuilder(sb))
.ExecuteAsync(timeoutSignal.Token);
}
catch (OperationCanceledException)
{
IEnumerable<string> quotedArgs = process.StartInfo.ArgumentList.Map(a => $"\'{a}\'");
Assert.Fail($"Transcode failure (timeout): ffmpeg {string.Join(" ", quotedArgs)}");
Assert.Fail($"Transcode failure (timeout): ffmpeg {process.Arguments}");
return;
}
@@ -414,22 +442,19 @@ public class TranscodingTests
if (profileAcceleration != HardwareAccelerationKind.None && isUnsupported)
{
var quotedArgs = process.StartInfo.ArgumentList.Map(a => $"\'{a}\'").ToList();
result.ExitCode.Should().Be(1, $"Error message with successful exit code? {string.Join(" ", quotedArgs)}");
Assert.Warn($"Unsupported on this hardware: ffmpeg {string.Join(" ", quotedArgs)}");
result.ExitCode.Should().Be(1, $"Error message with successful exit code? {process.Arguments}");
Assert.Warn($"Unsupported on this hardware: ffmpeg {process.Arguments}");
}
else if (error.Contains("Impossible to convert between"))
{
IEnumerable<string> quotedArgs = process.StartInfo.ArgumentList.Map(a => $"\'{a}\'");
Assert.Fail($"Transcode failure: ffmpeg {string.Join(" ", quotedArgs)}");
Assert.Fail($"Transcode failure: ffmpeg {process.Arguments}");
}
else
{
var quotedArgs = process.StartInfo.ArgumentList.Map(a => $"\'{a}\'").ToList();
result.ExitCode.Should().Be(0, error + Environment.NewLine + string.Join(" ", quotedArgs));
result.ExitCode.Should().Be(0, error + Environment.NewLine + process.Arguments);
if (result.ExitCode == 0)
{
Console.WriteLine(string.Join(" ", quotedArgs));
Console.WriteLine(process.Arguments);
}
}
}
@@ -454,6 +479,9 @@ public class TranscodingTests
public Task<Option<MediaStream>> SelectAudioStream(Channel channel, MediaVersion version) =>
Optional(version.Streams.First(s => s.MediaStreamKind == MediaStreamKind.Audio)).AsTask();
public Task<Option<MediaStream>> SelectSubtitleStream(Channel channel, MediaVersion version) =>
Optional(version.Streams.Find(s => s.MediaStreamKind == MediaStreamKind.Subtitle)).AsTask();
}
private static string ExecutableName(string baseName) =>
Binary file not shown.
File diff suppressed because it is too large Load Diff
@@ -6,6 +6,7 @@ using FluentAssertions;
using Microsoft.Extensions.Logging;
using Moq;
using NUnit.Framework;
using Serilog;
namespace ErsatzTV.Core.Tests.Scheduling;
@@ -223,7 +224,7 @@ public class PlayoutModeSchedulerBaseTests : SchedulerTestBase
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
List<PlayoutItem> playoutItems = PlayoutModeSchedulerBase<ProgramScheduleItem>
List<PlayoutItem> playoutItems = Scheduler()
.AddFiller(
startState,
CollectionEnumerators(scheduleItem, enumerator),
@@ -274,7 +275,7 @@ public class PlayoutModeSchedulerBaseTests : SchedulerTestBase
// too lazy to make another enumerator for the filler that we don't want
enumerators.Add(CollectionKey.ForFillerPreset(scheduleItem.MidRollFiller), enumerator);
List<PlayoutItem> playoutItems = PlayoutModeSchedulerBase<ProgramScheduleItem>
List<PlayoutItem> playoutItems = Scheduler()
.AddFiller(
startState,
enumerators,
@@ -284,6 +285,106 @@ public class PlayoutModeSchedulerBaseTests : SchedulerTestBase
playoutItems.Count.Should().Be(1);
}
[Test]
public void Should_Schedule_Mid_Roll_Count_Filler_Correctly()
{
Collection collectionOne = TwoItemCollection(1, 2, TimeSpan.FromHours(1));
Collection collectionTwo = TwoItemCollection(3, 4, TimeSpan.FromMinutes(5));
var scheduleItem = new ProgramScheduleItemOne
{
Id = 1,
Index = 1,
Collection = collectionOne,
CollectionId = collectionOne.Id,
StartTime = null,
PlaybackOrder = PlaybackOrder.Chronological,
TailFiller = null,
FallbackFiller = null,
MidRollFiller = new FillerPreset
{
FillerKind = FillerKind.MidRoll,
FillerMode = FillerMode.Count,
PadToNearestMinute = 60, // this should be ignored
Count = 1
}
};
var scheduleItemsEnumerator = new OrderedScheduleItemsEnumerator(
new List<ProgramScheduleItem> { scheduleItem },
new CollectionEnumeratorState());
var enumerator = new ChronologicalMediaCollectionEnumerator(
collectionOne.MediaItems,
new CollectionEnumeratorState());
var fillerEnumerator = new ChronologicalMediaCollectionEnumerator(
collectionTwo.MediaItems,
new CollectionEnumeratorState());
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
Dictionary<CollectionKey, IMediaCollectionEnumerator> enumerators = CollectionEnumerators(
scheduleItem,
enumerator);
enumerators.Add(CollectionKey.ForFillerPreset(scheduleItem.MidRollFiller), fillerEnumerator);
List<PlayoutItem> playoutItems = Scheduler()
.AddFiller(
startState,
enumerators,
scheduleItem,
new PlayoutItem
{
MediaItemId = 1,
Start = startState.CurrentTime.UtcDateTime,
Finish = startState.CurrentTime.AddHours(1).UtcDateTime
},
new List<MediaChapter>
{
new() { StartTime = TimeSpan.Zero, EndTime = TimeSpan.FromMinutes(6) },
new() { StartTime = TimeSpan.FromMinutes(6), EndTime = TimeSpan.FromMinutes(60) }
});
playoutItems.Count.Should().Be(3);
playoutItems[0].MediaItemId.Should().Be(1);
playoutItems[0].StartOffset.Should().Be(startState.CurrentTime);
playoutItems[1].MediaItemId.Should().Be(3);
playoutItems[1].StartOffset.Should().Be(startState.CurrentTime + TimeSpan.FromMinutes(6));
playoutItems[2].MediaItemId.Should().Be(1);
playoutItems[2].StartOffset.Should().Be(startState.CurrentTime + TimeSpan.FromMinutes(11));
}
}
[TestFixture]
public class GetStartTimeAfter
{
[Test]
public void Should_Compare_Time_As_Local_Time()
{
var enumerator = new Mock<IScheduleItemsEnumerator>();
var state = new PlayoutBuilderState(
enumerator.Object,
None,
None,
false,
false,
0,
DateTime.Today.AddHours(6).ToUniversalTime());
var scheduleItem = new ProgramScheduleItemOne
{
StartTime = TimeSpan.FromHours(6)
};
DateTimeOffset result =
PlayoutModeSchedulerBase<ProgramScheduleItem>.GetStartTimeAfter(state, scheduleItem);
result.Should().Be(DateTime.Today.AddHours(6));
}
}
private static Movie TestMovie(int id, TimeSpan duration, DateTime aired) =>
@@ -296,4 +397,34 @@ public class PlayoutModeSchedulerBaseTests : SchedulerTestBase
new() { Duration = duration }
}
};
private static PlayoutModeSchedulerBase<ProgramScheduleItem> Scheduler() =>
new TestScheduler();
private class TestScheduler : PlayoutModeSchedulerBase<ProgramScheduleItem>
{
private static readonly ILoggerFactory LoggerFactory;
static TestScheduler()
{
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Debug()
.WriteTo.Console()
.CreateLogger();
LoggerFactory = new LoggerFactory().AddSerilog(Log.Logger);
}
public TestScheduler() : base(LoggerFactory.CreateLogger<TestScheduler>())
{
}
public override Tuple<PlayoutBuilderState, List<PlayoutItem>> Schedule(
PlayoutBuilderState playoutBuilderState,
Dictionary<CollectionKey, IMediaCollectionEnumerator> collectionEnumerators,
ProgramScheduleItem scheduleItem,
ProgramScheduleItem nextScheduleItem,
DateTimeOffset hardStop) =>
throw new NotSupportedException();
}
}
@@ -0,0 +1,9 @@
namespace ErsatzTV.Core.Api.Channels;
public record ChannelResponseModel(
int Id,
string Number,
string Name,
string FFmpegProfile,
string Language,
string StreamingMode);
+4 -2
View File
@@ -4,7 +4,7 @@ namespace ErsatzTV.Core.Domain;
public class Channel
{
public static string NumberValidator = @"^[0-9]+(\.[0-9])?$";
public static string NumberValidator = @"^[0-9]+(\.[0-9]{1,2})?$";
public Channel(Guid uniqueId) => UniqueId = uniqueId;
public int Id { get; set; }
@@ -22,5 +22,7 @@ public class Channel
public StreamingMode StreamingMode { get; set; }
public List<Playout> Playouts { get; set; }
public List<Artwork> Artwork { get; set; }
public string PreferredLanguageCode { get; set; }
public string PreferredAudioLanguageCode { get; set; }
public string PreferredSubtitleLanguageCode { get; set; }
public ChannelSubtitleMode SubtitleMode { get; set; }
}
@@ -0,0 +1,9 @@
namespace ErsatzTV.Core.Domain;
public enum ChannelSubtitleMode
{
None = 0,
Forced = 1,
Default = 2,
Any = 3
}
-1
View File
@@ -17,7 +17,6 @@ public class ConfigElementKey
public static ConfigElementKey FFmpegSegmenterTimeout => new("ffmpeg.segmenter.timeout_seconds");
public static ConfigElementKey FFmpegWorkAheadSegmenters => new("ffmpeg.segmenter.work_ahead_limit");
public static ConfigElementKey FFmpegInitialSegmentCount => new("ffmpeg.segmenter.initial_segment_count");
public static ConfigElementKey FFmpegUseLegacyTranscoder => new("ffmpeg.use_legacy_transcoder");
public static ConfigElementKey SearchIndexVersion => new("search_index.version");
public static ConfigElementKey HDHRTunerCount => new("hdhr.tuner_count");
public static ConfigElementKey ChannelsPageSize => new("pages.channels.page_size");
+7 -10
View File
@@ -7,35 +7,32 @@ public record FFmpegProfile
public int Id { get; set; }
public string Name { get; set; }
public int ThreadCount { get; set; }
public bool Transcode { get; set; }
public HardwareAccelerationKind HardwareAcceleration { get; set; }
public VaapiDriver VaapiDriver { get; set; }
public string VaapiDevice { get; set; }
public int ResolutionId { get; set; }
public Resolution Resolution { get; set; }
public string VideoCodec { get; set; }
public bool NormalizeVideo { get; set; }
public FFmpegProfileVideoFormat VideoFormat { get; set; }
public int VideoBitrate { get; set; }
public int VideoBufferSize { get; set; }
public string AudioCodec { get; set; }
public FFmpegProfileAudioFormat AudioFormat { get; set; }
public int AudioBitrate { get; set; }
public int AudioBufferSize { get; set; }
public bool NormalizeLoudness { get; set; }
public int AudioChannels { get; set; }
public int AudioSampleRate { get; set; }
public bool NormalizeAudio { get; set; }
public bool NormalizeFramerate { get; set; }
public bool? DeinterlaceVideo { get; set; }
public static FFmpegProfile New(string name, Resolution resolution) =>
new()
{
Name = name,
ThreadCount = 0,
Transcode = true,
ResolutionId = resolution.Id,
Resolution = resolution,
VideoCodec = "libx264",
AudioCodec = "ac3",
VideoFormat = FFmpegProfileVideoFormat.H264,
AudioFormat = FFmpegProfileAudioFormat.Ac3,
VideoBitrate = 2000,
VideoBufferSize = 4000,
AudioBitrate = 192,
@@ -43,8 +40,8 @@ public record FFmpegProfile
NormalizeLoudness = true,
AudioChannels = 2,
AudioSampleRate = 48,
NormalizeVideo = true,
NormalizeAudio = true,
DeinterlaceVideo = true,
NormalizeFramerate = false,
HardwareAcceleration = HardwareAccelerationKind.None
};
}
@@ -0,0 +1,11 @@
namespace ErsatzTV.Core.Domain;
public enum FFmpegProfileAudioFormat
{
None = 0,
Aac = 1,
Ac3 = 2,
Copy = 99
}
@@ -0,0 +1,12 @@
namespace ErsatzTV.Core.Domain;
public enum FFmpegProfileVideoFormat
{
None = 0,
H264 = 1,
Hevc = 2,
Mpeg2Video = 3,
Copy = 99
}
+4 -1
View File
@@ -3,7 +3,7 @@ using ErsatzTV.Core.Domain.Filler;
namespace ErsatzTV.Core.Domain;
[DebuggerDisplay("{MediaItemId} - {Start} - {Finish}")]
[DebuggerDisplay("{MediaItemId} - {StartOffset} - {FinishOffset}")]
public class PlayoutItem
{
public int Id { get; set; }
@@ -20,9 +20,12 @@ public class PlayoutItem
public TimeSpan InPoint { get; set; }
public TimeSpan OutPoint { get; set; }
public string ChapterTitle { get; set; }
public ChannelWatermark Watermark { get; set; }
public int? WatermarkId { get; set; }
public DateTimeOffset StartOffset => new DateTimeOffset(Start, TimeSpan.Zero).ToLocalTime();
public DateTimeOffset FinishOffset => new DateTimeOffset(Finish, TimeSpan.Zero).ToLocalTime();
public DateTimeOffset? GuideFinishOffset => GuideFinish.HasValue
? new DateTimeOffset(GuideFinish.Value, TimeSpan.Zero).ToLocalTime()
: null;
@@ -1,12 +1,26 @@
namespace ErsatzTV.Core.Domain;
using Destructurama.Attributed;
namespace ErsatzTV.Core.Domain;
public class PlayoutProgramScheduleAnchor
{
public int Id { get; set; }
public int PlayoutId { get; set; }
[NotLogged]
public Playout Playout { get; set; }
public int ProgramScheduleId { get; set; }
[NotLogged]
public ProgramSchedule ProgramSchedule { get; set; }
public DateTime? AnchorDate { get; set; }
public DateTimeOffset? AnchorDateOffset => AnchorDate.HasValue
? new DateTimeOffset(AnchorDate.Value, TimeSpan.Zero).ToLocalTime()
: null;
public ProgramScheduleItemCollectionType CollectionType { get; set; }
public int? CollectionId { get; set; }
public Collection Collection { get; set; }
@@ -32,4 +32,6 @@ public abstract class ProgramScheduleItem
public FillerPreset TailFiller { get; set; }
public int? FallbackFillerId { get; set; }
public FillerPreset FallbackFiller { get; set; }
public ChannelWatermark Watermark { get; set; }
public int? WatermarkId { get; set; }
}
+3 -1
View File
@@ -7,10 +7,12 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Bugsnag" Version="3.0.0" />
<PackageReference Include="Bugsnag" Version="3.0.1" />
<PackageReference Include="Destructurama.Attributed" Version="3.0.0" />
<PackageReference Include="Flurl" Version="3.0.4" />
<PackageReference Include="LanguageExt.Core" Version="4.0.4" />
<PackageReference Include="MediatR" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="6.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="6.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="6.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="6.0.1" />
@@ -22,7 +22,7 @@ public class FFmpegComplexFilterBuilder
private Option<int> _watermarkIndex;
private string _pixelFormat;
private string _videoDecoder;
private string _videoEncoder;
private FFmpegProfileVideoFormat _videoFormat;
private Option<string> _subtitle;
private bool _boxBlur;
@@ -131,9 +131,9 @@ public class FFmpegComplexFilterBuilder
return this;
}
public FFmpegComplexFilterBuilder WithVideoEncoder(string videoEncoder)
public FFmpegComplexFilterBuilder WithVideoFormat(FFmpegProfileVideoFormat videoFormat)
{
_videoEncoder = videoEncoder;
_videoFormat = videoFormat;
return this;
}
@@ -379,21 +379,21 @@ public class FFmpegComplexFilterBuilder
string outputPixelFormat = null;
if (!usesSoftwareFilters && string.IsNullOrWhiteSpace(watermarkOverlay))
{
switch (acceleration, _videoEncoder, _pixelFormat)
switch (acceleration, _videoFormat, _pixelFormat)
{
case (HardwareAccelerationKind.Nvenc, "h264_nvenc", "yuv420p10le"):
case (HardwareAccelerationKind.Nvenc, FFmpegProfileVideoFormat.H264, "yuv420p10le"):
outputPixelFormat = "yuv420p";
break;
case (HardwareAccelerationKind.Nvenc, "h264_nvenc", "yuv444p10le"):
case (HardwareAccelerationKind.Nvenc, FFmpegProfileVideoFormat.H264, "yuv444p10le"):
outputPixelFormat = "yuv444p";
break;
}
}
string outputFormat = (_videoEncoder, _pixelFormat) switch
string outputFormat = (acceleration, _videoFormat, _pixelFormat) switch
{
("hevc_nvenc", "yuv420p10le") => "p010le",
("h264_nvenc", "yuv420p10le") => "p010le",
(HardwareAccelerationKind.Nvenc, FFmpegProfileVideoFormat.Hevc, "yuv420p10le") => "p010le",
(HardwareAccelerationKind.Nvenc, FFmpegProfileVideoFormat.H264, "yuv420p10le") => "p010le",
_ => null
};
@@ -485,7 +485,7 @@ public class FFmpegComplexFilterBuilder
// vaapi downsample 10bit hevc to 8bit h264
if (acceleration == HardwareAccelerationKind.Vaapi && !videoFilterQueue.Any() &&
_pixelFormat == "yuv420p10le" && _videoEncoder.StartsWith("h264"))
_pixelFormat == "yuv420p10le" && _videoFormat == FFmpegProfileVideoFormat.H264)
{
videoFilterQueue.Add("scale_vaapi=format=nv12");
}
@@ -1,5 +1,4 @@
using System.Diagnostics;
using System.Text;
using CliWrap;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Interfaces.FFmpeg;
@@ -32,8 +31,9 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
_logger = logger;
}
public async Task<Process> ForPlayoutItem(
public async Task<Command> ForPlayoutItem(
string ffmpegPath,
string ffprobePath,
bool saveReports,
Channel channel,
MediaVersion videoVersion,
@@ -43,6 +43,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
DateTimeOffset start,
DateTimeOffset finish,
DateTimeOffset now,
Option<ChannelWatermark> playoutItemWatermark,
Option<ChannelWatermark> globalWatermark,
VaapiDriver vaapiDriver,
string vaapiDevice,
@@ -55,6 +56,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
{
MediaStream videoStream = await _ffmpegStreamSelector.SelectVideoStream(channel, videoVersion);
Option<MediaStream> maybeAudioStream = await _ffmpegStreamSelector.SelectAudioStream(channel, audioVersion);
Option<MediaStream> maybeSubtitleStream =
await _ffmpegStreamSelector.SelectSubtitleStream(channel, videoVersion);
FFmpegPlaybackSettings playbackSettings = _playbackSettingsCalculator.CalculateSettings(
channel.StreamingMode,
@@ -70,7 +73,14 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
targetFramerate);
Option<WatermarkOptions> watermarkOptions =
await _ffmpegProcessService.GetWatermarkOptions(channel, globalWatermark, videoVersion, None, None);
await _ffmpegProcessService.GetWatermarkOptions(
ffprobePath,
channel,
playoutItemWatermark,
globalWatermark,
videoVersion,
None,
None);
Option<List<FadePoint>> maybeFadePoints = watermarkOptions
.Map(o => o.Watermark)
@@ -85,9 +95,17 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
playbackSettings.StreamSeek,
wm.FrequencyMinutes,
wm.DurationSeconds));
string audioFormat = playbackSettings.AudioFormat switch
{
FFmpegProfileAudioFormat.Aac => AudioFormat.Aac,
FFmpegProfileAudioFormat.Ac3 => AudioFormat.Ac3,
FFmpegProfileAudioFormat.Copy => AudioFormat.Copy,
_ => throw new ArgumentOutOfRangeException($"unexpected audio format {playbackSettings.VideoFormat}")
};
var audioState = new AudioState(
playbackSettings.AudioCodec,
audioFormat,
playbackSettings.AudioChannels,
playbackSettings.AudioBitrate,
playbackSettings.AudioBufferSize,
@@ -112,16 +130,32 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
return new AudioInputFile(audioPath, new List<AudioStream> { ffmpegAudioStream }, audioState);
});
var watermarkInputFile = GetWatermarkInputFile(watermarkOptions, maybeFadePoints);
Option<SubtitleInputFile> subtitleInputFile = maybeSubtitleStream.Map(
subtitleStream =>
{
var ffmpegSubtitleStream = new ErsatzTV.FFmpeg.MediaStream(
subtitleStream.Index,
subtitleStream.Codec,
StreamKind.Video);
return new SubtitleInputFile(
videoPath,
new List<ErsatzTV.FFmpeg.MediaStream> { ffmpegSubtitleStream },
false);
// TODO: figure out HLS direct
// channel.StreamingMode == StreamingMode.HttpLiveStreamingDirect);
});
Option<WatermarkInputFile> watermarkInputFile = GetWatermarkInputFile(watermarkOptions, maybeFadePoints);
// TODO: need formats for these codecs
string videoFormat = playbackSettings.VideoCodec switch
string videoFormat = playbackSettings.VideoFormat switch
{
"libx265" or "hevc_nvenc" or "hevc_qsv" or "hevc_vaapi" or "hevc_videotoolbox" => VideoFormat.Hevc,
"libx264" or "h264_nvenc" or "h264_qsv" or "h264_vaapi" or "h264_videotoolbox" => VideoFormat.H264,
"mpeg2video" => VideoFormat.Mpeg2Video,
"copy" => VideoFormat.Copy,
_ => throw new ArgumentOutOfRangeException($"unexpected video codec {playbackSettings.VideoCodec}")
FFmpegProfileVideoFormat.Hevc => VideoFormat.Hevc,
FFmpegProfileVideoFormat.H264 => VideoFormat.H264,
FFmpegProfileVideoFormat.Mpeg2Video => VideoFormat.Mpeg2Video,
FFmpegProfileVideoFormat.Copy => VideoFormat.Copy,
_ => throw new ArgumentOutOfRangeException($"unexpected video format {playbackSettings.VideoFormat}")
};
HardwareAccelerationMode hwAccel = playbackSettings.HardwareAcceleration switch
@@ -185,12 +219,13 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
videoInputFile,
audioInputFile,
watermarkInputFile,
subtitleInputFile,
FileSystemLayout.FFmpegReportsFolder,
_logger);
FFmpegPipeline pipeline = pipelineBuilder.Build(ffmpegState, desiredState);
return GetProcess(ffmpegPath, videoInputFile, audioInputFile, watermarkInputFile, None, pipeline);
return GetCommand(ffmpegPath, videoInputFile, audioInputFile, watermarkInputFile, None, pipeline);
}
private Option<WatermarkInputFile> GetWatermarkInputFile(
@@ -254,7 +289,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
return None;
}
public Task<Process> ForError(
public Task<Command> ForError(
string ffmpegPath,
Channel channel,
Option<TimeSpan> duration,
@@ -263,7 +298,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
long ptsOffset) =>
_ffmpegProcessService.ForError(ffmpegPath, channel, duration, errorMessage, hlsRealtime, ptsOffset);
public Process ConcatChannel(string ffmpegPath, bool saveReports, Channel channel, string scheme, string host)
public Command ConcatChannel(string ffmpegPath, bool saveReports, Channel channel, string scheme, string host)
{
var resolution = new FrameSize(channel.FFmpegProfile.Resolution.Width, channel.FFmpegProfile.Resolution.Height);
@@ -271,28 +306,55 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
$"http://localhost:{Settings.ListenPort}/ffmpeg/concat/{channel.Number}",
resolution);
var pipelineBuilder = new PipelineBuilder(None, None, None, FileSystemLayout.FFmpegReportsFolder, _logger);
var pipelineBuilder = new PipelineBuilder(
None,
None,
None,
None,
FileSystemLayout.FFmpegReportsFolder,
_logger);
FFmpegPipeline pipeline = pipelineBuilder.Concat(
concatInputFile,
FFmpegState.Concat(saveReports, channel.Name));
return GetProcess(ffmpegPath, None, None, None, concatInputFile, pipeline);
return GetCommand(ffmpegPath, None, None, None, concatInputFile, pipeline);
}
public Process WrapSegmenter(string ffmpegPath, bool saveReports, Channel channel, string scheme, string host) =>
public Command WrapSegmenter(string ffmpegPath, bool saveReports, Channel channel, string scheme, string host) =>
_ffmpegProcessService.WrapSegmenter(ffmpegPath, saveReports, channel, scheme, host);
public Process ConvertToPng(string ffmpegPath, string inputFile, string outputFile) =>
public Command ResizeImage(string ffmpegPath, string inputFile, string outputFile, int height)
{
var videoInputFile = new VideoInputFile(
inputFile,
new List<VideoStream> { new(0, string.Empty, None, FrameSize.Unknown, None, true) });
var pipelineBuilder = new PipelineBuilder(
videoInputFile,
None,
None,
None,
FileSystemLayout.FFmpegReportsFolder,
_logger);
FFmpegPipeline pipeline = pipelineBuilder.Resize(outputFile, new FrameSize(-1, height));
return GetCommand(ffmpegPath, videoInputFile, None, None, None, pipeline, false);
}
public Command ConvertToPng(string ffmpegPath, string inputFile, string outputFile) =>
_ffmpegProcessService.ConvertToPng(ffmpegPath, inputFile, outputFile);
public Process ExtractAttachedPicAsPng(string ffmpegPath, string inputFile, int streamIndex, string outputFile) =>
public Command ExtractAttachedPicAsPng(string ffmpegPath, string inputFile, int streamIndex, string outputFile) =>
_ffmpegProcessService.ExtractAttachedPicAsPng(ffmpegPath, inputFile, streamIndex, outputFile);
public Task<Either<BaseError, string>> GenerateSongImage(
string ffmpegPath,
string ffprobePath,
Option<string> subtitleFile,
Channel channel,
Option<ChannelWatermark> playoutItemWatermark,
Option<ChannelWatermark> globalWatermark,
MediaVersion videoVersion,
string videoPath,
@@ -305,8 +367,10 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
CancellationToken cancellationToken) =>
_ffmpegProcessService.GenerateSongImage(
ffmpegPath,
ffprobePath,
subtitleFile,
channel,
playoutItemWatermark,
globalWatermark,
videoVersion,
videoPath,
@@ -318,13 +382,14 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
watermarkWidthPercent,
cancellationToken);
private Process GetProcess(
private Command GetCommand(
string ffmpegPath,
Option<VideoInputFile> videoInputFile,
Option<AudioInputFile> audioInputFile,
Option<WatermarkInputFile> watermarkInputFile,
Option<ConcatInputFile> concatInputFile,
FFmpegPipeline pipeline)
FFmpegPipeline pipeline,
bool log = true)
{
IEnumerable<string> loggedSteps = pipeline.PipelineSteps.Map(ps => ps.GetType().Name);
IEnumerable<string> loggedVideoFilters =
@@ -332,12 +397,15 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
IEnumerable<string> loggedAudioFilters =
audioInputFile.Map(f => f.FilterSteps.Map(af => af.GetType().Name)).Flatten();
_logger.LogDebug(
"FFmpeg pipeline {PipelineSteps}, {AudioFilters}, {VideoFilters}",
loggedSteps,
loggedAudioFilters,
loggedVideoFilters
);
if (log)
{
_logger.LogDebug(
"FFmpeg pipeline {PipelineSteps}, {AudioFilters}, {VideoFilters}",
loggedSteps,
loggedAudioFilters,
loggedVideoFilters
);
}
IList<EnvironmentVariable> environmentVariables =
CommandGenerator.GenerateEnvironmentVariables(pipeline.PipelineSteps);
@@ -348,35 +416,16 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
concatInputFile,
pipeline.PipelineSteps);
var startInfo = new ProcessStartInfo
{
FileName = ffmpegPath,
RedirectStandardOutput = true,
RedirectStandardError = false,
UseShellExecute = false,
CreateNoWindow = true,
StandardOutputEncoding = Encoding.UTF8
};
if (environmentVariables.Any())
{
_logger.LogDebug("FFmpeg environment variables {EnvVars}", environmentVariables);
}
foreach ((string key, string value) in environmentVariables)
{
startInfo.EnvironmentVariables[key] = value;
}
foreach (string argument in arguments)
{
startInfo.ArgumentList.Add(argument);
}
return new Process
{
StartInfo = startInfo
};
return Cli.Wrap(ffmpegPath)
.WithArguments(arguments)
.WithValidation(CommandResultValidation.None)
.WithStandardErrorPipe(PipeTarget.ToStream(Stream.Null))
.WithEnvironmentVariables(environmentVariables.ToDictionary(e => e.Key, e => e.Value));
}
private static Option<string> VaapiDriverName(HardwareAccelerationMode accelerationMode, VaapiDriver driver)
@@ -13,7 +13,7 @@ public class FFmpegPlaybackSettings
public Option<TimeSpan> StreamSeek { get; set; }
public Option<IDisplaySize> ScaledSize { get; set; }
public bool PadToDesiredResolution { get; set; }
public string VideoCodec { get; set; }
public FFmpegProfileVideoFormat VideoFormat { get; set; }
public Option<int> VideoBitrate { get; set; }
public Option<int> VideoBufferSize { get; set; }
public Option<int> AudioBitrate { get; set; }
@@ -21,7 +21,7 @@ public class FFmpegPlaybackSettings
public Option<int> AudioChannels { get; set; }
public Option<int> AudioSampleRate { get; set; }
public Option<TimeSpan> AudioDuration { get; set; }
public string AudioCodec { get; set; }
public FFmpegProfileAudioFormat AudioFormat { get; set; }
public bool Deinterlace { get; set; }
public Option<int> VideoTrackTimeScale { get; set; }
public bool NormalizeLoudness { get; set; }
@@ -82,8 +82,8 @@ public class FFmpegPlaybackSettingsCalculator
switch (streamingMode)
{
case StreamingMode.HttpLiveStreamingDirect:
result.AudioCodec = "copy";
result.VideoCodec = "copy";
result.AudioFormat = FFmpegProfileAudioFormat.Copy;
result.VideoFormat = FFmpegProfileVideoFormat.Copy;
result.Deinterlace = false;
break;
case StreamingMode.TransportStreamHybrid:
@@ -98,91 +98,71 @@ public class FFmpegPlaybackSettingsCalculator
{
int fixedHeight = scaledSize.Height + scaledSize.Height % 2;
int fixedWidth = scaledSize.Width + scaledSize.Width % 2;
result.ScaledSize = Some((IDisplaySize) new DisplaySize(fixedWidth, fixedHeight));
result.ScaledSize = Some((IDisplaySize)new DisplaySize(fixedWidth, fixedHeight));
}
}
IDisplaySize sizeAfterScaling = result.ScaledSize.IfNone(videoVersion);
if (ffmpegProfile.Transcode && ffmpegProfile.NormalizeVideo && !sizeAfterScaling.IsSameSizeAs(ffmpegProfile.Resolution))
if (!sizeAfterScaling.IsSameSizeAs(ffmpegProfile.Resolution))
{
result.PadToDesiredResolution = true;
}
if (ffmpegProfile.Transcode && ffmpegProfile.NormalizeVideo)
if (ffmpegProfile.NormalizeFramerate)
{
if (ffmpegProfile.NormalizeFramerate)
{
result.FrameRate = targetFramerate;
}
result.VideoTrackTimeScale = 90000;
result.FrameRate = targetFramerate;
}
result.VideoTrackTimeScale = 90000;
foreach (MediaStream stream in videoStream.Where(s => s.AttachedPic == false))
{
if (result.ScaledSize.IsSome || result.PadToDesiredResolution ||
NeedToNormalizeVideoCodec(ffmpegProfile, stream))
{
result.VideoCodec = ffmpegProfile.VideoCodec;
result.VideoBitrate = ffmpegProfile.VideoBitrate;
result.VideoBufferSize = ffmpegProfile.VideoBufferSize;
result.VideoFormat = ffmpegProfile.VideoFormat;
result.VideoBitrate = ffmpegProfile.VideoBitrate;
result.VideoBufferSize = ffmpegProfile.VideoBufferSize;
result.VideoDecoder =
(result.HardwareAcceleration, stream.Codec, stream.PixelFormat) switch
{
(HardwareAccelerationKind.Nvenc, "h264", "yuv420p10le" or "yuv444p" or "yuv444p10le"
) =>
"h264",
(HardwareAccelerationKind.Nvenc, "hevc", "yuv444p" or "yuv444p10le") => "hevc",
(HardwareAccelerationKind.Nvenc, "h264", _) => "h264_cuvid",
(HardwareAccelerationKind.Nvenc, "hevc", _) => "hevc_cuvid",
(HardwareAccelerationKind.Nvenc, "mpeg2video", _) => "mpeg2_cuvid",
(HardwareAccelerationKind.Nvenc, "mpeg4", _) => "mpeg4_cuvid",
(HardwareAccelerationKind.Qsv, "h264", _) => "h264_qsv",
(HardwareAccelerationKind.Qsv, "hevc", _) => "hevc_qsv",
(HardwareAccelerationKind.Qsv, "mpeg2video", _) => "mpeg2_qsv",
// temp disable mpeg4 hardware decoding for all vaapi
// TODO: check for codec support
(HardwareAccelerationKind.Vaapi, "mpeg4", _) => "mpeg4",
_ => null
};
}
else
{
result.VideoCodec = "copy";
}
}
if (ffmpegProfile.Transcode && ffmpegProfile.NormalizeAudio)
{
result.AudioCodec = ffmpegProfile.AudioCodec;
result.AudioBitrate = ffmpegProfile.AudioBitrate;
result.AudioBufferSize = ffmpegProfile.AudioBufferSize;
audioStream.IfSome(
stream =>
result.VideoDecoder =
(result.HardwareAcceleration, stream.Codec, stream.PixelFormat) switch
{
if (stream.Channels != ffmpegProfile.AudioChannels)
{
result.AudioChannels = ffmpegProfile.AudioChannels;
}
});
(HardwareAccelerationKind.Nvenc, "h264", "yuv420p10le" or "yuv444p" or "yuv444p10le"
) =>
"h264",
(HardwareAccelerationKind.Nvenc, "hevc", "yuv444p" or "yuv444p10le") => "hevc",
(HardwareAccelerationKind.Nvenc, "h264", _) => "h264_cuvid",
(HardwareAccelerationKind.Nvenc, "hevc", _) => "hevc_cuvid",
(HardwareAccelerationKind.Nvenc, "mpeg2video", _) => "mpeg2_cuvid",
(HardwareAccelerationKind.Nvenc, "mpeg4", _) => "mpeg4_cuvid",
(HardwareAccelerationKind.Qsv, "h264", _) => "h264_qsv",
(HardwareAccelerationKind.Qsv, "hevc", _) => "hevc_qsv",
(HardwareAccelerationKind.Qsv, "mpeg2video", _) => "mpeg2_qsv",
result.AudioSampleRate = ffmpegProfile.AudioSampleRate;
result.AudioDuration = outPoint - inPoint;
result.NormalizeLoudness = ffmpegProfile.NormalizeLoudness;
}
else
{
result.AudioCodec = "copy";
// temp disable mpeg4 hardware decoding for all vaapi
// TODO: check for codec support
(HardwareAccelerationKind.Vaapi, "mpeg4", _) => "mpeg4",
_ => null
};
}
if (videoVersion.VideoScanKind == VideoScanKind.Interlaced)
{
result.Deinterlace = true;
}
result.AudioFormat = ffmpegProfile.AudioFormat;
result.AudioBitrate = ffmpegProfile.AudioBitrate;
result.AudioBufferSize = ffmpegProfile.AudioBufferSize;
audioStream.IfSome(
stream =>
{
if (stream.Channels != ffmpegProfile.AudioChannels)
{
result.AudioChannels = ffmpegProfile.AudioChannels;
}
});
result.AudioSampleRate = ffmpegProfile.AudioSampleRate;
result.AudioDuration = outPoint - inPoint;
result.NormalizeLoudness = ffmpegProfile.NormalizeLoudness;
result.Deinterlace = ffmpegProfile.DeinterlaceVideo == true &&
videoVersion.VideoScanKind == VideoScanKind.Interlaced;
break;
}
@@ -192,25 +172,17 @@ public class FFmpegPlaybackSettingsCalculator
public FFmpegPlaybackSettings CalculateErrorSettings(FFmpegProfile ffmpegProfile)
{
string softwareCodec = ffmpegProfile.VideoCodec switch
{
{ } c when c.Contains("hevc") || c.Contains("265") => "libx265",
{ } c when c.Contains("264") => "libx264",
{ } c when c.Contains("mpeg2") => "mpeg2video",
_ => "libx264"
};
return new FFmpegPlaybackSettings
{
HardwareAcceleration = HardwareAccelerationKind.None,
ThreadCount = ffmpegProfile.ThreadCount,
FormatFlags = CommonFormatFlags,
VideoCodec = softwareCodec,
AudioCodec = ffmpegProfile.AudioCodec,
VideoFormat = ffmpegProfile.VideoFormat,
AudioFormat = ffmpegProfile.AudioFormat
};
}
private static bool NeedToScale(FFmpegProfile ffmpegProfile, MediaVersion version) =>
ffmpegProfile.Transcode && ffmpegProfile.NormalizeVideo &&
IsIncorrectSize(ffmpegProfile.Resolution, version) ||
IsTooLarge(ffmpegProfile.Resolution, version) ||
IsOddSize(version);
@@ -227,9 +199,6 @@ public class FFmpegPlaybackSettingsCalculator
private static bool IsOddSize(MediaVersion version) =>
version.Height % 2 == 1 || version.Width % 2 == 1;
private static bool NeedToNormalizeVideoCodec(FFmpegProfile ffmpegProfile, MediaStream videoStream) =>
ffmpegProfile.Transcode && ffmpegProfile.NormalizeVideo && ffmpegProfile.VideoCodec != videoStream.Codec;
private static IDisplaySize CalculateScaledSize(FFmpegProfile ffmpegProfile, MediaVersion version)
{
IDisplaySize sarSize = SARSize(version);
+17 -11
View File
@@ -71,7 +71,10 @@ internal class FFmpegProcessBuilder
return this;
}
public FFmpegProcessBuilder WithHardwareAcceleration(HardwareAccelerationKind hwAccel, Option<string> pixelFormat, string encoder)
public FFmpegProcessBuilder WithHardwareAcceleration(
HardwareAccelerationKind hwAccel,
Option<string> pixelFormat,
FFmpegProfileVideoFormat videoFormat)
{
_hwAccel = hwAccel;
@@ -84,14 +87,14 @@ internal class FFmpegProcessBuilder
_arguments.Add("qsv=qsv:MFX_IMPL_hw_any");
break;
case HardwareAccelerationKind.Nvenc:
string outputFormat = (encoder, pixelFormat.IfNone("")) switch
string outputFormat = (videoFormat, pixelFormat.IfNone("")) switch
{
("hevc_nvenc", "yuv420p10le") => "p010le",
("h264_nvenc", "yuv420p10le") => "p010le",
(FFmpegProfileVideoFormat.Hevc, "yuv420p10le") => "p010le",
(FFmpegProfileVideoFormat.H264, "yuv420p10le") => "p010le",
// ("hevc_nvenc", "yuv444p10le") => "p016le",
_ => "cuda"
};
_arguments.Add("-hwaccel");
_arguments.Add("cuda");
_arguments.Add("-hwaccel_output_format");
@@ -462,14 +465,17 @@ internal class FFmpegProcessBuilder
return this;
}
public FFmpegProcessBuilder WithPlaybackArgs(FFmpegPlaybackSettings playbackSettings)
public FFmpegProcessBuilder WithPlaybackArgs(
FFmpegPlaybackSettings playbackSettings,
string videoCodec,
string audioCodec)
{
var arguments = new List<string>
{
"-c:v", playbackSettings.VideoCodec,
"-c:v", videoCodec,
"-flags", "cgop",
// disable scene change detection except with mpeg2video
"-sc_threshold", playbackSettings.VideoCodec == "mpeg2video" ? "1000000000" : "0"
"-sc_threshold", playbackSettings.VideoFormat == FFmpegProfileVideoFormat.Mpeg2Video ? "1000000000" : "0"
};
if (!string.IsNullOrWhiteSpace(_outputPixelFormat))
@@ -512,7 +518,7 @@ internal class FFmpegProcessBuilder
arguments.AddRange(
new[]
{
"-c:a", playbackSettings.AudioCodec,
"-c:a", audioCodec,
"-movflags", "+faststart",
"-muxdelay", "0",
"-muxpreload", "0"
@@ -591,9 +597,9 @@ internal class FFmpegProcessBuilder
Option<MediaStream> maybeAudioStream,
string videoPath,
Option<string> audioPath,
string videoCodec)
FFmpegProfileVideoFormat videoFormat)
{
_complexFilterBuilder = _complexFilterBuilder.WithVideoEncoder(videoCodec);
_complexFilterBuilder = _complexFilterBuilder.WithVideoFormat(videoFormat);
int videoStreamIndex = videoStream.Index;
Option<int> maybeIndex = maybeAudioStream.Map(ms => ms.Index);
+162 -206
View File
@@ -1,21 +1,24 @@
using System.Diagnostics;
using System.Text;
using Bugsnag;
using CliWrap;
using CliWrap.Buffered;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.FFmpeg.State;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Core.FFmpeg;
public class FFmpegProcessService : IFFmpegProcessService
public class FFmpegProcessService
{
private readonly IFFmpegStreamSelector _ffmpegStreamSelector;
private readonly IImageCache _imageCache;
private readonly ITempFilePool _tempFilePool;
private readonly IClient _client;
private readonly IMemoryCache _memoryCache;
private readonly ILogger<FFmpegProcessService> _logger;
private readonly FFmpegPlaybackSettingsCalculator _playbackSettingsCalculator;
@@ -25,6 +28,7 @@ public class FFmpegProcessService : IFFmpegProcessService
IImageCache imageCache,
ITempFilePool tempFilePool,
IClient client,
IMemoryCache memoryCache,
ILogger<FFmpegProcessService> logger)
{
_playbackSettingsCalculator = ffmpegPlaybackSettingsService;
@@ -32,171 +36,11 @@ public class FFmpegProcessService : IFFmpegProcessService
_imageCache = imageCache;
_tempFilePool = tempFilePool;
_client = client;
_memoryCache = memoryCache;
_logger = logger;
}
public async Task<Process> ForPlayoutItem(
string ffmpegPath,
bool saveReports,
Channel channel,
MediaVersion videoVersion,
MediaVersion audioVersion,
string videoPath,
string audioPath,
DateTimeOffset start,
DateTimeOffset finish,
DateTimeOffset now,
Option<ChannelWatermark> globalWatermark,
VaapiDriver vaapiDriver,
string vaapiDevice,
bool hlsRealtime,
FillerKind fillerKind,
TimeSpan inPoint,
TimeSpan outPoint,
long ptsOffset,
Option<int> targetFramerate)
{
MediaStream videoStream = await _ffmpegStreamSelector.SelectVideoStream(channel, videoVersion);
Option<MediaStream> maybeAudioStream = await _ffmpegStreamSelector.SelectAudioStream(channel, audioVersion);
FFmpegPlaybackSettings playbackSettings = _playbackSettingsCalculator.CalculateSettings(
channel.StreamingMode,
channel.FFmpegProfile,
videoVersion,
videoStream,
maybeAudioStream,
start,
now,
inPoint,
outPoint,
hlsRealtime,
targetFramerate);
Option<WatermarkOptions> watermarkOptions =
await GetWatermarkOptions(channel, globalWatermark, videoVersion, None, None);
Option<List<FadePoint>> maybeFadePoints = watermarkOptions
.Map(o => o.Watermark)
.Flatten()
.Where(wm => wm.Mode == ChannelWatermarkMode.Intermittent)
.Map(
wm =>
WatermarkCalculator.CalculateFadePoints(
start,
inPoint,
outPoint,
playbackSettings.StreamSeek,
wm.FrequencyMinutes,
wm.DurationSeconds));
// foreach (List<FadePoint> fadePoints in maybeFadePoints)
// {
// foreach (FadePoint fadePoint in fadePoints)
// {
// _logger.LogDebug("Fade point filter: {FadePointFilter}", fadePoint.ToFilter());
// }
// }
FFmpegProcessBuilder builder = new FFmpegProcessBuilder(ffmpegPath, saveReports, _logger)
.WithThreads(playbackSettings.ThreadCount)
.WithVaapiDriver(vaapiDriver, vaapiDevice)
.WithHardwareAcceleration(
playbackSettings.HardwareAcceleration,
videoStream.PixelFormat,
playbackSettings.VideoCodec)
.WithQuiet()
.WithFormatFlags(playbackSettings.FormatFlags)
.WithRealtimeOutput(playbackSettings.RealtimeOutput)
.WithInputCodec(
playbackSettings.StreamSeek,
fillerKind == FillerKind.Fallback,
videoPath,
audioPath,
playbackSettings.VideoDecoder,
videoStream.Codec,
videoStream.PixelFormat,
playbackSettings.Deinterlace)
.WithWatermark(watermarkOptions, maybeFadePoints, channel.FFmpegProfile.Resolution)
.WithFrameRate(playbackSettings.FrameRate)
.WithVideoTrackTimeScale(playbackSettings.VideoTrackTimeScale)
.WithAlignedAudio(videoPath == audioPath ? playbackSettings.AudioDuration : Option<TimeSpan>.None)
.WithNormalizeLoudness(playbackSettings.NormalizeLoudness);
playbackSettings.ScaledSize.Match(
scaledSize =>
{
builder = builder.WithDeinterlace(playbackSettings.Deinterlace)
.WithScaling(scaledSize);
if (NeedToPad(channel.FFmpegProfile.Resolution, scaledSize))
{
builder = builder.WithBlackBars(channel.FFmpegProfile.Resolution);
}
builder = builder
.WithFilterComplex(
videoStream,
maybeAudioStream,
videoPath,
audioPath,
channel.FFmpegProfile.VideoCodec);
},
() =>
{
if (playbackSettings.PadToDesiredResolution)
{
builder = builder
.WithDeinterlace(playbackSettings.Deinterlace)
.WithBlackBars(channel.FFmpegProfile.Resolution)
.WithFilterComplex(
videoStream,
maybeAudioStream,
videoPath,
audioPath,
channel.FFmpegProfile.VideoCodec);
}
else if (playbackSettings.Deinterlace)
{
builder = builder.WithDeinterlace(playbackSettings.Deinterlace)
.WithAlignedAudio(playbackSettings.AudioDuration)
.WithFilterComplex(
videoStream,
maybeAudioStream,
videoPath,
audioPath,
channel.FFmpegProfile.VideoCodec);
}
else
{
builder = builder
.WithFilterComplex(
videoStream,
maybeAudioStream,
videoPath,
audioPath,
channel.FFmpegProfile.VideoCodec);
}
});
builder = builder.WithPlaybackArgs(playbackSettings)
.WithMetadata(channel, maybeAudioStream)
.WithDuration(finish - now);
return channel.StreamingMode switch
{
// HLS needs to segment and generate playlist
StreamingMode.HttpLiveStreamingSegmenter => builder.WithHls(
channel.Number,
videoVersion,
ptsOffset,
playbackSettings.VideoTrackTimeScale,
playbackSettings.FrameRate)
.Build(),
_ => builder.WithFormat("mpegts").WithInitialDiscontinuity().WithPipe().Build()
};
}
public async Task<Process> ForError(
public async Task<Command> ForError(
string ffmpegPath,
Channel channel,
Option<TimeSpan> duration,
@@ -225,6 +69,19 @@ public class FFmpegProcessService : IFFmpegProcessService
var videoStream = new MediaStream { Index = 0 };
var audioStream = new MediaStream { Index = 0 };
string videoCodec = playbackSettings.VideoFormat switch
{
FFmpegProfileVideoFormat.Hevc => "libx265",
FFmpegProfileVideoFormat.Mpeg2Video => "mpeg2video",
_ => "libx264"
};
string audioCodec = playbackSettings.AudioFormat switch
{
FFmpegProfileAudioFormat.Ac3 => "ac3",
_ => "aac"
};
FFmpegProcessBuilder builder = new FFmpegProcessBuilder(ffmpegPath, false, _logger)
.WithThreads(1)
.WithQuiet()
@@ -239,53 +96,41 @@ public class FFmpegProcessService : IFFmpegProcessService
audioStream,
Path.Combine(FileSystemLayout.ResourcesCacheFolder, "background.png"),
"fake-audio-path",
playbackSettings.VideoCodec)
playbackSettings.VideoFormat)
.WithPixfmt("yuv420p")
.WithPlaybackArgs(playbackSettings)
.WithPlaybackArgs(playbackSettings, videoCodec, audioCodec)
.WithMetadata(channel, None);
await duration.IfSomeAsync(d => builder = builder.WithDuration(d));
switch (channel.StreamingMode)
Process process = channel.StreamingMode switch
{
// HLS needs to segment and generate playlist
case StreamingMode.HttpLiveStreamingSegmenter:
return builder.WithHls(
StreamingMode.HttpLiveStreamingSegmenter =>
builder.WithHls(
channel.Number,
None,
ptsOffset,
playbackSettings.VideoTrackTimeScale,
playbackSettings.FrameRate)
.Build();
default:
return builder.WithFormat("mpegts")
.WithPipe()
.Build();
}
.Build(),
_ => builder.WithFormat("mpegts")
.WithPipe()
.Build()
};
return Cli.Wrap(process.StartInfo.FileName)
.WithArguments(process.StartInfo.ArgumentList)
.WithValidation(CommandResultValidation.None)
.WithEnvironmentVariables(process.StartInfo.Environment.ToDictionary(kvp => kvp.Key, kvp => kvp.Value))
.WithStandardErrorPipe(PipeTarget.ToStream(Stream.Null));
}
public Process ConcatChannel(string ffmpegPath, bool saveReports, Channel channel, string scheme, string host)
public Command WrapSegmenter(string ffmpegPath, bool saveReports, Channel channel, string scheme, string host)
{
FFmpegPlaybackSettings playbackSettings = _playbackSettingsCalculator.ConcatSettings;
return new FFmpegProcessBuilder(ffmpegPath, saveReports, _logger)
.WithThreads(1)
.WithQuiet()
.WithFormatFlags(playbackSettings.FormatFlags)
.WithRealtimeOutput(playbackSettings.RealtimeOutput)
.WithInfiniteLoop()
.WithConcat($"http://localhost:{Settings.ListenPort}/ffmpeg/concat/{channel.Number}")
.WithMetadata(channel, None)
.WithFormat("mpegts")
.WithPipe()
.Build();
}
public Process WrapSegmenter(string ffmpegPath, bool saveReports, Channel channel, string scheme, string host)
{
FFmpegPlaybackSettings playbackSettings = _playbackSettingsCalculator.ConcatSettings;
return new FFmpegProcessBuilder(ffmpegPath, saveReports, _logger)
Process process = new FFmpegProcessBuilder(ffmpegPath, saveReports, _logger)
.WithThreads(1)
.WithQuiet()
.WithFormatFlags(playbackSettings.FormatFlags)
@@ -297,33 +142,53 @@ public class FFmpegProcessService : IFFmpegProcessService
.WithFormat("mpegts")
.WithPipe()
.Build();
return Cli.Wrap(process.StartInfo.FileName)
.WithArguments(process.StartInfo.ArgumentList)
.WithValidation(CommandResultValidation.None)
.WithEnvironmentVariables(process.StartInfo.Environment.ToDictionary(kvp => kvp.Key, kvp => kvp.Value))
.WithStandardErrorPipe(PipeTarget.ToStream(Stream.Null));
}
public Process ConvertToPng(string ffmpegPath, string inputFile, string outputFile)
public Command ConvertToPng(string ffmpegPath, string inputFile, string outputFile)
{
return new FFmpegProcessBuilder(ffmpegPath, false, _logger)
Process process = new FFmpegProcessBuilder(ffmpegPath, false, _logger)
.WithThreads(1)
.WithQuiet()
.WithInput(inputFile)
.WithOutputFormat("apng", outputFile)
.Build();
return Cli.Wrap(process.StartInfo.FileName)
.WithArguments(process.StartInfo.ArgumentList)
.WithValidation(CommandResultValidation.None)
.WithEnvironmentVariables(process.StartInfo.Environment.ToDictionary(kvp => kvp.Key, kvp => kvp.Value))
.WithStandardErrorPipe(PipeTarget.ToStream(Stream.Null));
}
public Process ExtractAttachedPicAsPng(string ffmpegPath, string inputFile, int streamIndex, string outputFile)
public Command ExtractAttachedPicAsPng(string ffmpegPath, string inputFile, int streamIndex, string outputFile)
{
return new FFmpegProcessBuilder(ffmpegPath, false, _logger)
Process process = new FFmpegProcessBuilder(ffmpegPath, false, _logger)
.WithThreads(1)
.WithQuiet()
.WithInput(inputFile)
.WithMap($"0:{streamIndex}")
.WithOutputFormat("apng", outputFile)
.Build();
return Cli.Wrap(process.StartInfo.FileName)
.WithArguments(process.StartInfo.ArgumentList)
.WithValidation(CommandResultValidation.None)
.WithEnvironmentVariables(process.StartInfo.Environment.ToDictionary(kvp => kvp.Key, kvp => kvp.Value))
.WithStandardErrorPipe(PipeTarget.ToStream(Stream.Null));
}
public async Task<Either<BaseError, string>> GenerateSongImage(
string ffmpegPath,
string ffprobePath,
Option<string> subtitleFile,
Channel channel,
Option<ChannelWatermark> playoutItemWatermark,
Option<ChannelWatermark> globalWatermark,
MediaVersion videoVersion,
string videoPath,
@@ -356,7 +221,14 @@ public class FFmpegProcessService : IFFmpegProcessService
: None;
Option<WatermarkOptions> watermarkOptions =
await GetWatermarkOptions(channel, globalWatermark, videoVersion, watermarkOverride, watermarkPath);
await GetWatermarkOptions(
ffprobePath,
channel,
playoutItemWatermark,
globalWatermark,
videoVersion,
watermarkOverride,
watermarkPath);
FFmpegPlaybackSettings playbackSettings =
_playbackSettingsCalculator.CalculateErrorSettings(channel.FFmpegProfile);
@@ -398,7 +270,7 @@ public class FFmpegProcessService : IFFmpegProcessService
None,
videoPath,
None,
playbackSettings.VideoCodec)
playbackSettings.VideoFormat)
.WithOutputFormat("apng", outputFile)
.Build();
@@ -425,14 +297,15 @@ public class FFmpegProcessService : IFFmpegProcessService
displaySize.Width != target.Width || displaySize.Height != target.Height;
internal async Task<WatermarkOptions> GetWatermarkOptions(
string ffprobePath,
Channel channel,
Option<ChannelWatermark> playoutItemWatermark,
Option<ChannelWatermark> globalWatermark,
MediaVersion videoVersion,
Option<ChannelWatermark> watermarkOverride,
Option<string> watermarkPath)
{
if (channel.StreamingMode != StreamingMode.HttpLiveStreamingDirect && channel.FFmpegProfile.Transcode &&
channel.FFmpegProfile.NormalizeVideo)
if (channel.StreamingMode != StreamingMode.HttpLiveStreamingDirect)
{
if (videoVersion is CoverArtMediaVersion)
{
@@ -442,6 +315,38 @@ public class FFmpegProcessService : IFFmpegProcessService
0,
false);
}
// check for playout item watermark
foreach (ChannelWatermark watermark in playoutItemWatermark)
{
switch (watermark.ImageSource)
{
case ChannelWatermarkImageSource.Custom:
string customPath = _imageCache.GetPathForImage(
watermark.Image,
ArtworkKind.Watermark,
Option<int>.None);
return new WatermarkOptions(
await watermarkOverride.IfNoneAsync(watermark),
customPath,
None,
await IsAnimated(ffprobePath, customPath));
case ChannelWatermarkImageSource.ChannelLogo:
Option<string> maybeChannelPath = channel.Artwork
.Filter(a => a.ArtworkKind == ArtworkKind.Logo)
.HeadOrNone()
.Map(a => _imageCache.GetPathForImage(a.Path, ArtworkKind.Logo, Option<int>.None));
return new WatermarkOptions(
await watermarkOverride.IfNoneAsync(watermark),
maybeChannelPath,
None,
await maybeChannelPath.Match(
p => IsAnimated(ffprobePath, p),
() => Task.FromResult(false)));
default:
throw new NotSupportedException("Unsupported watermark image source");
}
}
// check for channel watermark
if (channel.Watermark != null)
@@ -457,7 +362,7 @@ public class FFmpegProcessService : IFFmpegProcessService
await watermarkOverride.IfNoneAsync(channel.Watermark),
customPath,
None,
await _imageCache.IsAnimated(customPath));
await IsAnimated(ffprobePath, customPath));
case ChannelWatermarkImageSource.ChannelLogo:
Option<string> maybeChannelPath = channel.Artwork
.Filter(a => a.ArtworkKind == ArtworkKind.Logo)
@@ -468,7 +373,7 @@ public class FFmpegProcessService : IFFmpegProcessService
maybeChannelPath,
None,
await maybeChannelPath.Match(
p => _imageCache.IsAnimated(p),
p => IsAnimated(ffprobePath, p),
() => Task.FromResult(false)));
default:
throw new NotSupportedException("Unsupported watermark image source");
@@ -489,7 +394,7 @@ public class FFmpegProcessService : IFFmpegProcessService
await watermarkOverride.IfNoneAsync(watermark),
customPath,
None,
await _imageCache.IsAnimated(customPath));
await IsAnimated(ffprobePath, customPath));
case ChannelWatermarkImageSource.ChannelLogo:
Option<string> maybeChannelPath = channel.Artwork
.Filter(a => a.ArtworkKind == ArtworkKind.Logo)
@@ -500,7 +405,7 @@ public class FFmpegProcessService : IFFmpegProcessService
maybeChannelPath,
None,
await maybeChannelPath.Match(
p => _imageCache.IsAnimated(p),
p => IsAnimated(ffprobePath, p),
() => Task.FromResult(false)));
default:
throw new NotSupportedException("Unsupported watermark image source");
@@ -510,4 +415,55 @@ public class FFmpegProcessService : IFFmpegProcessService
return new WatermarkOptions(None, None, None, false);
}
private async Task<bool> IsAnimated(string ffprobePath, string path)
{
try
{
var cacheKey = $"image.animated.{Path.GetFileName(path)}";
if (_memoryCache.TryGetValue(cacheKey, out bool animated))
{
return animated;
}
BufferedCommandResult result = await Cli.Wrap(ffprobePath)
.WithArguments(
new[]
{
"-loglevel", "error",
"-select_streams", "v:0",
"-count_frames",
"-show_entries", "stream=nb_read_frames",
"-print_format", "csv",
path
})
.WithValidation(CommandResultValidation.None)
.ExecuteBufferedAsync(Encoding.UTF8);
if (result.ExitCode == 0)
{
string output = result.StandardOutput;
output = output.Replace("stream,", string.Empty);
if (int.TryParse(output, out int frameCount))
{
bool isAnimated = frameCount > 1;
_memoryCache.Set(cacheKey, isAnimated, TimeSpan.FromDays(1));
return isAnimated;
}
}
else
{
_logger.LogWarning(
"Error checking frame count for file {File}l exit code {ExitCode}",
path,
result.ExitCode);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error checking frame count for file {File}", path);
}
return false;
}
}
@@ -1,28 +0,0 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Repositories;
using Microsoft.Extensions.DependencyInjection;
namespace ErsatzTV.Core.FFmpeg;
public class FFmpegProcessServiceFactory : IFFmpegProcessServiceFactory
{
private readonly IConfigElementRepository _configElementRepository;
private readonly IServiceProvider _serviceProvider;
public FFmpegProcessServiceFactory(IConfigElementRepository configElementRepository, IServiceProvider serviceProvider)
{
_configElementRepository = configElementRepository;
_serviceProvider = serviceProvider;
}
public async Task<IFFmpegProcessService> GetService()
{
Option<bool> useLegacyTranscoder =
await _configElementRepository.GetValue<bool>(ConfigElementKey.FFmpegUseLegacyTranscoder);
return await useLegacyTranscoder.IfNoneAsync(false)
? _serviceProvider.GetRequiredService<FFmpegProcessService>()
: _serviceProvider.GetRequiredService<FFmpegLibraryProcessService>();
}
}
+22 -2
View File
@@ -1,12 +1,17 @@
using System.Collections.Concurrent;
using ErsatzTV.Core.Interfaces.FFmpeg;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Core.FFmpeg;
public class FFmpegSegmenterService : IFFmpegSegmenterService
{
public FFmpegSegmenterService()
private readonly ILogger<FFmpegSegmenterService> _logger;
public FFmpegSegmenterService(ILogger<FFmpegSegmenterService> logger)
{
_logger = logger;
SessionWorkers = new ConcurrentDictionary<string, IHlsSessionWorker>();
}
@@ -19,4 +24,19 @@ public class FFmpegSegmenterService : IFFmpegSegmenterService
worker?.Touch();
}
}
}
public void PlayoutUpdated(string channelNumber)
{
if (SessionWorkers.TryGetValue(channelNumber, out IHlsSessionWorker worker))
{
if (worker != null)
{
_logger.LogInformation(
"Playout has been updated for channel {ChannelNumber}, HLS segmenter will skip ahead to catch up",
channelNumber);
worker.PlayoutUpdated();
}
}
}
}
+74 -8
View File
@@ -27,27 +27,27 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector
public async Task<Option<MediaStream>> SelectAudioStream(Channel channel, MediaVersion version)
{
if (channel.StreamingMode == StreamingMode.HttpLiveStreamingDirect &&
string.IsNullOrWhiteSpace(channel.PreferredLanguageCode))
string.IsNullOrWhiteSpace(channel.PreferredAudioLanguageCode))
{
_logger.LogDebug(
"Channel {Number} is HLS with no preferred language; using all audio streams",
"Channel {Number} is HLS Direct with no preferred audio language; using all audio streams",
channel.Number);
return None;
}
var audioStreams = version.Streams.Filter(s => s.MediaStreamKind == MediaStreamKind.Audio).ToList();
string language = (channel.PreferredLanguageCode ?? string.Empty).ToLowerInvariant();
string language = (channel.PreferredAudioLanguageCode ?? string.Empty).ToLowerInvariant();
if (string.IsNullOrWhiteSpace(language))
{
_logger.LogDebug("Channel {Number} has no preferred language code", channel.Number);
_logger.LogDebug("Channel {Number} has no preferred audio language code", channel.Number);
Option<string> maybeDefaultLanguage = await _configElementRepository.GetValue<string>(
ConfigElementKey.FFmpegPreferredLanguageCode);
maybeDefaultLanguage.Match(
lang => language = lang.ToLowerInvariant(),
() =>
{
_logger.LogDebug("FFmpeg has no preferred language code; falling back to {Code}", "eng");
_logger.LogDebug("FFmpeg has no preferred audio language code; falling back to {Code}", "eng");
language = "eng";
});
}
@@ -55,7 +55,7 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector
List<string> allCodes = await _searchRepository.GetAllLanguageCodes(new List<string> { language });
if (allCodes.Count > 1)
{
_logger.LogDebug("Preferred language has multiple codes {Codes}", allCodes);
_logger.LogDebug("Preferred audio language has multiple codes {Codes}", allCodes);
}
var correctLanguage = audioStreams.Filter(
@@ -67,7 +67,7 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector
if (correctLanguage.Any())
{
_logger.LogDebug(
"Found {Count} audio streams with preferred language code(s) {Code}; selecting stream with most channels",
"Found {Count} audio streams with preferred audio language code(s) {Code}; selecting stream with most channels",
correctLanguage.Count,
allCodes);
@@ -75,9 +75,75 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector
}
_logger.LogDebug(
"Unable to find audio stream with preferred language code(s) {Code}; selecting stream with most channels",
"Unable to find audio stream with preferred audio language code(s) {Code}; selecting stream with most channels",
allCodes);
return audioStreams.OrderByDescending(s => s.Channels).Head();
}
public async Task<Option<MediaStream>> SelectSubtitleStream(Channel channel, MediaVersion version)
{
if (channel.SubtitleMode == ChannelSubtitleMode.None)
{
return None;
}
if (channel.StreamingMode == StreamingMode.HttpLiveStreamingDirect &&
string.IsNullOrWhiteSpace(channel.PreferredSubtitleLanguageCode))
{
// _logger.LogDebug(
// "Channel {Number} is HLS Direct with no preferred subtitle language; using all subtitle streams",
// channel.Number);
return None;
}
var subtitleStreams = version.Streams
.Filter(s => s.MediaStreamKind == MediaStreamKind.Subtitle)
.Filter(s => s.Codec is "hdmv_pgs_subtitle" or "dvd_subtitle")
.ToList();
string language = (channel.PreferredSubtitleLanguageCode ?? string.Empty).ToLowerInvariant();
if (string.IsNullOrWhiteSpace(language))
{
_logger.LogDebug("Channel {Number} has no preferred subtitle language code", channel.Number);
}
else
{
// filter to preferred language
List<string> allCodes = await _searchRepository.GetAllLanguageCodes(new List<string> { language });
subtitleStreams = subtitleStreams
.Filter(
s => allCodes.Any(c => string.Equals(s.Language, c, StringComparison.InvariantCultureIgnoreCase)))
.ToList();
}
if (subtitleStreams.Count == 0)
{
return None;
}
switch (channel.SubtitleMode)
{
case ChannelSubtitleMode.Forced:
foreach (MediaStream stream in Optional(subtitleStreams.OrderBy(s => s.Index).Find(s => s.Forced)))
{
return stream;
}
break;
case ChannelSubtitleMode.Default:
foreach (MediaStream stream in Optional(subtitleStreams.OrderBy(s => s.Index).Find(s => s.Default)))
{
return stream;
}
break;
case ChannelSubtitleMode.Any:
foreach (MediaStream stream in subtitleStreams.OrderBy(s => s.Index).HeadOrNone())
{
return stream;
}
break;
}
return None;
}
}

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