Compare commits
42
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60965d0961 | ||
|
|
ff1a7b376f | ||
|
|
741b00fd52 | ||
|
|
7e55681916 | ||
|
|
210630d299 | ||
|
|
0ddbb898d6 | ||
|
|
d6bf579436 | ||
|
|
765df64555 | ||
|
|
8764fb93fa | ||
|
|
7d5c3e2384 | ||
|
|
1ee3446589 | ||
|
|
af39d93442 | ||
|
|
5d6a6d3a76 | ||
|
|
1f27aef11d | ||
|
|
ddb6d99cf9 | ||
|
|
d54766866e | ||
|
|
25bc500a2b | ||
|
|
c2eec2fc2d | ||
|
|
f9781a4c05 | ||
|
|
df45b93819 | ||
|
|
e697fd36e9 | ||
|
|
0308106c1b | ||
|
|
ba93e3eeea | ||
|
|
aa2c914d8a | ||
|
|
caa9bf82d5 | ||
|
|
e397035c5a | ||
|
|
d32f881c4e | ||
|
|
9b3c24559d | ||
|
|
7c75b169ec | ||
|
|
4f1952340f | ||
|
|
ac2de24f6e | ||
|
|
4b9781dad4 | ||
|
|
d88c179b63 | ||
|
|
809a623a95 | ||
|
|
b453dce57e | ||
|
|
edd31755c0 | ||
|
|
7de1a87bbf | ||
|
|
5731edc82e | ||
|
|
2f668e53dd | ||
|
|
abd223acd2 | ||
|
|
6a9075dc11 | ||
|
|
d5a03963c0 |
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -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
|
||||
@@ -43,3 +43,4 @@ scripts/generate-api-sdk/swagger.json
|
||||
|
||||
docker-compose.override.yml
|
||||
|
||||
ErsatzTV/wwwroot/v2/
|
||||
|
||||
+69
-1
@@ -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
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Asmichi.ChildProcess" Version="0.11.0" />
|
||||
<PackageReference Include="CliWrap" Version="3.4.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+6
-6
@@ -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<
|
||||
|
||||
+5
-4
@@ -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(
|
||||
|
||||
+44
-79
@@ -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,
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
Executable
BIN
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,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
|
||||
}
|
||||
@@ -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,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
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user