Compare commits

..
Author SHA1 Message Date
Jason Dove 940d9cd6b5 update changelog for release v0.6.2-beta [no ci] 2022-06-18 13:46:45 -05:00
Jason DoveandGitHub 197c166789 fix jellyfin admin id selection (#867) 2022-06-17 18:25:22 -05:00
Jason DoveandGitHub d114db091e use proper nvidia accel output format for 10-bit content (#865) 2022-06-17 11:33:10 -05:00
Jason DoveandGitHub 3204da8e43 adjust nvidia capabilities (#864)
* adjust nvidia capabilities logic

* fallback to software encoding for 10-bit h264

* cleanup

* more tweaks
2022-06-17 10:50:36 -05:00
Jason DoveandGitHub 100eb14408 fix epg sorting (#863)
* fix epg sorting

* update dependencies
2022-06-17 08:44:26 -05:00
Jason DoveandGitHub 025017ace5 regularly delete old segments (#856)
* regularly delete old segments

* cleanup
2022-06-15 21:12:07 -05:00
Jason DoveandGitHub 6a690c7c10 add more filler logging (#854) 2022-06-15 10:21:05 -05:00
Jason DoveandGitHub dd7f77751c detect nvidia capabilities (#853)
* fallback to software codecs for old nvidia cards

* update dependencies
2022-06-14 19:44:34 -05:00
Jason DoveandGitHub 0c13b8ef1a force amd64 for arm32v7 sdk build layer (#843) 2022-06-12 13:54:55 -05:00
Jason DoveandGitHub c6ca58ab97 build arm32v7 docker image (#842)
* build arm32v7 docker image

* fix
2022-06-12 13:42:27 -05:00
Jason DoveandGitHub 0846fc1d96 update workflow dependencies (#841) 2022-06-11 13:40:47 -05:00
Jason DoveandGitHub e41dd68ee0 fix automatic playout building (#840) 2022-06-11 13:11:04 -05:00
Jason DoveandGitHub 0a92996da8 fix repeating content (#838)
* fix repeating content

* update dependencies
2022-06-08 10:37:56 -05:00
Jason Dove 082bc6145c update changelog for release v0.6.1-beta [no ci] 2022-06-03 15:05:06 -05:00
Jason DoveandGitHub bf3f16451b music video credits tweaks (#834)
* fix song subtitles

* always use generated subtitles

* file not found/unavailable fixes
2022-06-03 14:44:52 -05:00
3cb37003cb UI rewrite - ffmpeg profiles (#823)
* ffmpeg profile functionality, sweetalert2

* add new files

* cleanup controller; remove unused classes

* apply formatting

* cleanup core project

* don't use bom

* whitespace

* remove generated css

* remove generated js/map

* Remove attempted linter fix, channels button, watermarks page. Fixed handlerror.

* Changed deleted confirmation to toast.

* Localized strings for language change. Modified Action icons to buttons and left default sizes. Changed Cancel to No where Yes is an option

* lint

Co-authored-by: Jason Dove <jason@jasondove.me>
2022-06-03 06:28:32 -05:00
Jason DoveandGitHub 9acfd2cd06 fix plex server identification (#833) 2022-06-03 05:53:41 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
3242e7ebb8 Bump HtmlSanitizer from 7.1.488 to 7.1.509 (#830)
Bumps [HtmlSanitizer](https://github.com/mganss/HtmlSanitizer) from 7.1.488 to 7.1.509.
- [Release notes](https://github.com/mganss/HtmlSanitizer/releases)
- [Commits](https://github.com/mganss/HtmlSanitizer/compare/v7.1.488...v7.1.509)

---
updated-dependencies:
- dependency-name: HtmlSanitizer
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-06-02 21:21:17 -05:00
Jason DoveandGitHub 7644d628e7 generate music video credits (#832) 2022-06-02 20:50:55 -05:00
Jason DoveandGitHub b4f19e6de4 fix jellyfin tv paging (#831) 2022-06-02 12:16:59 -05:00
Jason Dove 0388425763 update changelog for release v0.6.0-beta [no ci] 2022-06-01 18:01:07 -05:00
Jason DoveandGitHub ca5d303ac7 fix qsv encoder regression and memory errors (#828)
* fix qsv encoders; only use 64 extra hw frames

* update changelog
2022-05-31 06:07:50 -05:00
Jason DoveandGitHub 18e66a92ad add paging to media server show and collection calls (#827)
* add paging to media server show library calls

* add paging to media server season and episode library calls

* formatting

* add paging to media server collection calls

* add paging to media server collection item calls

* update changelog
2022-05-31 05:56:48 -05:00
Jason DoveandGitHub 7d0a56ab98 disable lower-power mode for qsv encoders (#826) 2022-05-29 20:44:27 -05:00
Jason Dove 5069792d12 update dependencies 2022-05-28 20:41:54 -05:00
Jason Dove c9789458b9 page media server movie libraries 2022-05-28 20:41:22 -05:00
Jason DoveandGitHub 777a0d09ed hls segmenter fixes (#824)
* fix pts warning when channel first starts streaming

* rework playlist filtering
2022-05-25 21:05:55 -05:00
Jason DoveandGitHub 4e2ebcc48a fix watermark opacity filter (#820) 2022-05-23 10:29:04 -05:00
Jason DoveandGitHub 90fe1d7709 fix hw accel health check for qsv in vaapi docker (#818) 2022-05-22 19:42:14 -05:00
Jason DoveandGitHub 1576dd026e enable qsv accel for vaapi docker images (#817) 2022-05-22 18:43:24 -05:00
Jason DoveandGitHub ee7a64eea9 fix other video libraries (#816)
* update depdendencies

* reset other video libraries
2022-05-22 12:29:35 -05:00
148 changed files with 12703 additions and 927 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
[*]
charset=utf-8-bom
charset=utf-8
end_of_line=lf
trim_trailing_whitespace=false
insert_final_newline=false
+6 -6
View File
@@ -41,18 +41,18 @@ jobs:
target: osx-arm64
steps:
- name: Get the sources
uses: actions/checkout@v2
uses: actions/checkout@v3
with:
fetch-depth: 0
submodules: true
- name: Setup .NET Core
uses: actions/setup-dotnet@v1
uses: actions/setup-dotnet@v2
with:
dotnet-version: 6.0.x
- name: Setup Node.js
uses: actions/setup-node@v2
uses: actions/setup-node@v3
with:
node-version: '14'
@@ -167,17 +167,17 @@ jobs:
target: win-x64
steps:
- name: Get the sources
uses: actions/checkout@v2
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Setup .NET Core
uses: actions/setup-dotnet@v1
uses: actions/setup-dotnet@v2
with:
dotnet-version: 6.0.x
- name: Setup Node.js
uses: actions/setup-node@v2
uses: actions/setup-node@v3
with:
node-version: '14'
+1 -1
View File
@@ -10,7 +10,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Get the sources
uses: actions/checkout@v2
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Extract Docker Tag
+26 -7
View File
@@ -39,32 +39,36 @@ jobs:
path: 'vaapi/'
suffix: '-vaapi'
qemu: false
- name: arm32v7
path: 'arm32v7/'
suffix: '-arm'
qemu: true
- name: arm64
path: 'arm64/'
suffix: '-arm64'
qemu: true
steps:
- name: Checkout
uses: actions/checkout@v2
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
uses: docker/setup-qemu-action@v2
if: ${{ matrix.qemu == true }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
uses: docker/setup-buildx-action@v2
id: docker-buildx
- name: Login to DockerHub
uses: docker/login-action@v1
uses: docker/login-action@v2
with:
username: ${{ secrets.docker_hub_username }}
password: ${{ secrets.docker_hub_access_token }}
- name: Build and push
uses: docker/build-push-action@v2
uses: docker/build-push-action@v3
with:
builder: ${{ steps.docker-buildx.outputs.name }}
context: .
@@ -75,10 +79,10 @@ jobs:
tags: |
jasongdove/ersatztv:${{ inputs.base_version }}${{ matrix.suffix }}
jasongdove/ersatztv:${{ inputs.tag_version }}${{ matrix.suffix }}
if: ${{ matrix.name != 'arm64' }}
if: ${{ matrix.name != 'arm64' && matrix.name != 'arm32v7' }}
- name: Build and push
uses: docker/build-push-action@v2
uses: docker/build-push-action@v3
with:
builder: ${{ steps.docker-buildx.outputs.name }}
context: .
@@ -91,3 +95,18 @@ jobs:
jasongdove/ersatztv:${{ inputs.base_version }}${{ matrix.suffix }}
jasongdove/ersatztv:${{ inputs.tag_version }}${{ matrix.suffix }}
if: ${{ matrix.name == 'arm64' }}
- name: Build and push
uses: docker/build-push-action@v3
with:
builder: ${{ steps.docker-buildx.outputs.name }}
context: .
file: ./docker/${{ matrix.path }}Dockerfile
push: true
platforms: 'linux/arm/v7'
build-args: |
INFO_VERSION=${{ inputs.info_version }}-docker${{ matrix.suffix }}
tags: |
jasongdove/ersatztv:${{ inputs.base_version }}${{ matrix.suffix }}
jasongdove/ersatztv:${{ inputs.tag_version }}${{ matrix.suffix }}
if: ${{ matrix.name == 'arm32v7' }}
+1 -1
View File
@@ -9,7 +9,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout master
uses: actions/checkout@v2
uses: actions/checkout@v3
- name: Deploy docs
uses: mhausenblas/mkdocs-deploy-gh-pages@master
+2 -2
View File
@@ -10,10 +10,10 @@ jobs:
os: [ windows-latest, ubuntu-latest, macos-latest ]
steps:
- name: Get the sources
uses: actions/checkout@v2
uses: actions/checkout@v3
- name: Setup .NET Core
uses: actions/setup-dotnet@v1
uses: actions/setup-dotnet@v2
with:
dotnet-version: 6.0.x
+1 -1
View File
@@ -8,7 +8,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Get the sources
uses: actions/checkout@v2
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Extract Docker Tag
+2 -2
View File
@@ -7,10 +7,10 @@ jobs:
steps:
# Checkout the current repo
- name: Checkout current repository
uses: actions/checkout@v2
uses: actions/checkout@v3
# Setup NodeJS version 14
- name: Setup NodeJS V14.x.x
uses: actions/setup-node@v2
uses: actions/setup-node@v3
with:
node-version: '14'
# CD into the current client directory and lint and build the client
+46 -1
View File
@@ -5,6 +5,48 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased]
## [0.6.2-beta] - 2022-06-18
### Fixed
- Fix content repeating for up to a minute near the top of every hour
- Check whether hardware-accelerated hevc codecs are supported by the NVIDIA card
- Software codecs will be used if they are unsupported by the NVIDIA card
- Fix sorting of channel contents in EPG
- Fix Jellyfin admin user id sync
- Ignore disabled admins and admins who do not have access to all libraries
### Added
- Add 32-bit `arm` docker tags (`develop-arm` and `latest-arm`)
### Changed
- Regularly delete old segments from transcode folder while content is actively transcoding
- This should help reduce required disk space
- To further minimize required disk space, set `Work-Ahead HLS Segmenter Limit` to `0` in `Settings`
## [0.6.1-beta] - 2022-06-03
### Fixed
- Fix Jellyfin show library paging
- Properly locate and identify multiple Plex servers
- Properly restore `Unavailable`/`File Not Found` items when they are located on disk
### Added
- Add basic music video credits subtitle generation
- This can be enabled in channel settings
## [0.6.0-beta] - 2022-06-01
### Fixed
- Additional fix for duplicate `Other Videos` entries; trash may need to be emptied one last time after upgrading
- Fix watermark opacity in cultures where `,` is a decimal separator
- Rework playlist filtering to avoid empty playlist responses
- Fix some QSV/VAAPI memory errors by always requesting 64 extra hardware frames
### Added
- Enable QSV hardware acceleration for vaapi docker images
### Changed
- Use paging to synchronize all media from Plex, Jellyfin and Emby
- This will reduce memory use and improve reliability of synchronizing large libraries
- Disable low power mode for `h264_qsv` and `hevc_qsv` encoders
## [0.5.8-beta] - 2022-05-20
### Fixed
- Fix error display with `HLS Segmenter` and `MPEG-TS` streaming modes
@@ -1210,7 +1252,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.5.8-beta...HEAD
[Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.6.2-beta...HEAD
[0.6.2-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.6.1-beta...v0.6.2-beta
[0.6.1-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.6.0-beta...v0.6.1-beta
[0.6.0-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.5.8-beta...v0.6.0-beta
[0.5.8-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.5.7-beta...v0.5.8-beta
[0.5.7-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.5.6-beta...v0.5.7-beta
[0.5.6-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.5.5-beta...v0.5.6-beta
@@ -16,4 +16,5 @@ public record ChannelViewModel(
int? FallbackFillerId,
int PlayoutCount,
string PreferredSubtitleLanguageCode,
ChannelSubtitleMode SubtitleMode);
ChannelSubtitleMode SubtitleMode,
ChannelMusicVideoCreditsMode MusicVideoCreditsMode);
@@ -16,4 +16,5 @@ public record CreateChannel
int? WatermarkId,
int? FallbackFillerId,
string PreferredSubtitleLanguageCode,
ChannelSubtitleMode SubtitleMode) : IRequest<Either<BaseError, CreateChannelResult>>;
ChannelSubtitleMode SubtitleMode,
ChannelMusicVideoCreditsMode MusicVideoCreditsMode) : 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)
@@ -72,7 +72,8 @@ public class CreateChannelHandler : IRequestHandler<CreateChannel, Either<BaseEr
Artwork = artwork,
PreferredAudioLanguageCode = preferredAudioLanguageCode,
PreferredSubtitleLanguageCode = preferredSubtitleLanguageCode,
SubtitleMode = request.SubtitleMode
SubtitleMode = request.SubtitleMode,
MusicVideoCreditsMode = request.MusicVideoCreditsMode
};
foreach (int id in watermarkId)
@@ -17,4 +17,5 @@ public record UpdateChannel
int? WatermarkId,
int? FallbackFillerId,
string PreferredSubtitleLanguageCode,
ChannelSubtitleMode SubtitleMode) : IRequest<Either<BaseError, ChannelViewModel>>;
ChannelSubtitleMode SubtitleMode,
ChannelMusicVideoCreditsMode MusicVideoCreditsMode) : IRequest<Either<BaseError, ChannelViewModel>>;
@@ -44,6 +44,7 @@ public class UpdateChannelHandler : IRequestHandler<UpdateChannel, Either<BaseEr
c.PreferredAudioLanguageCode = update.PreferredAudioLanguageCode;
c.PreferredSubtitleLanguageCode = update.PreferredSubtitleLanguageCode;
c.SubtitleMode = update.SubtitleMode;
c.MusicVideoCreditsMode = update.MusicVideoCreditsMode;
c.Artwork ??= new List<Artwork>();
if (!string.IsNullOrWhiteSpace(update.Logo))
+2 -1
View File
@@ -20,7 +20,8 @@ internal static class Mapper
channel.FallbackFillerId,
channel.Playouts?.Count ?? 0,
channel.PreferredSubtitleLanguageCode,
channel.SubtitleMode);
channel.SubtitleMode,
channel.MusicVideoCreditsMode);
internal static ChannelResponseModel ProjectToResponseModel(Channel channel) =>
new(
@@ -9,7 +9,9 @@ public class GetChannelGuideHandler : IRequestHandler<GetChannelGuide, ChannelGu
private readonly IChannelRepository _channelRepository;
private readonly RecyclableMemoryStreamManager _recyclableMemoryStreamManager;
public GetChannelGuideHandler(IChannelRepository channelRepository, RecyclableMemoryStreamManager recyclableMemoryStreamManager)
public GetChannelGuideHandler(
IChannelRepository channelRepository,
RecyclableMemoryStreamManager recyclableMemoryStreamManager)
{
_channelRepository = channelRepository;
_recyclableMemoryStreamManager = recyclableMemoryStreamManager;
@@ -7,7 +7,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Bugsnag" Version="3.0.1" />
<PackageReference Include="Bugsnag" Version="3.1.0" />
<PackageReference Include="CliWrap" Version="3.4.4" />
<PackageReference Include="Humanizer.Core" Version="2.14.1" />
<PackageReference Include="MediatR" Version="10.0.1" />
@@ -20,7 +20,7 @@ public class CreateFFmpegProfileHandler :
{
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(
@@ -35,6 +35,27 @@ internal static class Mapper
ffmpegProfile.VideoFormat.ToString().ToLowerInvariant(),
ffmpegProfile.AudioFormat.ToString().ToLowerInvariant());
internal static FFmpegFullProfileResponseModel ProjectToFullResponseModel(FFmpegProfile ffmpegProfile) =>
new(
ffmpegProfile.Id,
ffmpegProfile.Name,
ffmpegProfile.ThreadCount,
(int)ffmpegProfile.HardwareAcceleration,
(int)ffmpegProfile.VaapiDriver,
ffmpegProfile.VaapiDevice,
ffmpegProfile.ResolutionId,
(int)ffmpegProfile.VideoFormat,
ffmpegProfile.VideoBitrate,
ffmpegProfile.VideoBufferSize,
(int)ffmpegProfile.AudioFormat,
ffmpegProfile.AudioBitrate,
ffmpegProfile.AudioBufferSize,
ffmpegProfile.NormalizeLoudness,
ffmpegProfile.AudioChannels,
ffmpegProfile.AudioSampleRate,
ffmpegProfile.NormalizeFramerate,
ffmpegProfile.DeinterlaceVideo);
private static ResolutionViewModel Project(Resolution resolution) =>
new(resolution.Id, resolution.Name, resolution.Width, resolution.Height);
}
@@ -0,0 +1,5 @@
using ErsatzTV.Core.Api.FFmpegProfiles;
namespace ErsatzTV.Application.FFmpegProfiles;
public record GetFFmpegFullProfileByIdForApi(int Id) : IRequest<Option<FFmpegFullProfileResponseModel>>;
@@ -0,0 +1,28 @@
using ErsatzTV.Core.Api.FFmpegProfiles;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.FFmpegProfiles.Mapper;
namespace ErsatzTV.Application.FFmpegProfiles;
public class
GetFFmpegProfileByIdForApiHandler : IRequestHandler<GetFFmpegFullProfileByIdForApi,
Option<FFmpegFullProfileResponseModel>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public GetFFmpegProfileByIdForApiHandler(IDbContextFactory<TvContext> dbContextFactory) =>
_dbContextFactory = dbContextFactory;
public async Task<Option<FFmpegFullProfileResponseModel>> Handle(
GetFFmpegFullProfileByIdForApi request,
CancellationToken cancellationToken)
{
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)
.MapT(ProjectToFullResponseModel);
}
}
@@ -69,7 +69,7 @@ public class
string originalPath = _imageCache.GetPathForImage(request.FileName, request.ArtworkKind, None);
Command process = _ffmpegProcessService.ResizeImage(
Command process = await _ffmpegProcessService.ResizeImage(
ffmpegPath,
originalPath,
withExtension,
@@ -5,6 +5,7 @@ using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Scheduling;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
@@ -37,7 +38,7 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Playout> validation = await Validate(dbContext, request);
return await LanguageExtensions.Apply(validation, playout => ApplyUpdateRequest(dbContext, request, playout));
return await validation.Apply(playout => ApplyUpdateRequest(dbContext, request, playout));
}
private async Task<Unit> ApplyUpdateRequest(TvContext dbContext, BuildPlayout request, Playout playout)
@@ -45,7 +46,12 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
try
{
await _playoutBuilder.Build(playout, request.Mode);
if (await dbContext.SaveChangesAsync() > 0)
// let any active segmenter processes know that the playout has been modified
// and therefore the segmenter may need to seek into the next item instead of
// starting at the beginning (if already working ahead)
bool hasChanges = await dbContext.SaveChangesAsync() > 0;
if (request.Mode != PlayoutBuildMode.Continue && hasChanges)
{
_ffmpegSegmenterService.PlayoutUpdated(playout.Channel.Number);
}
@@ -28,6 +28,7 @@ public class HlsSessionWorker : IHlsSessionWorker
private string _channelNumber;
private bool _firstProcess;
private DateTimeOffset _lastAccess;
private DateTimeOffset _lastDelete = DateTimeOffset.MinValue;
private Option<int> _targetFramerate;
private Timer _timer;
private DateTimeOffset _transcodedUntil;
@@ -65,7 +66,19 @@ public class HlsSessionWorker : IHlsSessionWorker
try
{
Option<string[]> maybeLines = await ReadPlaylistLines(cancellationToken);
return maybeLines.Map(input => _hlsPlaylistFilter.TrimPlaylist(PlaylistStart, filterBefore, input));
foreach (string[] input in maybeLines)
{
TrimPlaylistResult trimResult = _hlsPlaylistFilter.TrimPlaylist(PlaylistStart, filterBefore, input);
if (DateTimeOffset.Now > _lastDelete.AddSeconds(30))
{
DeleteOldSegments(trimResult);
_lastDelete = DateTimeOffset.Now;
}
return trimResult;
}
return None;
}
finally
{
@@ -190,7 +203,7 @@ public class HlsSessionWorker : IHlsSessionWorker
IMediator mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
long ptsOffset = await GetPtsOffset(mediator, _channelNumber, cancellationToken);
long ptsOffset = await GetPtsOffset(mediator, _channelNumber, _firstProcess, cancellationToken);
// _logger.LogInformation("PTS offset: {PtsOffset}", ptsOffset);
var request = new GetPlayoutItemProcessByChannelNumber(
@@ -334,33 +347,7 @@ public class HlsSessionWorker : IHlsSessionWorker
lines);
await WritePlaylist(trimResult.Playlist, cancellationToken);
// delete old segments
var allSegments = Directory.GetFiles(
Path.Combine(FileSystemLayout.TranscodeFolder, _channelNumber),
"live*.ts")
.Map(
file =>
{
string fileName = Path.GetFileName(file);
var sequenceNumber = int.Parse(fileName.Replace("live", string.Empty).Split('.')[0]);
return new Segment(file, sequenceNumber);
})
.ToList();
var toDelete = allSegments.Filter(s => s.SequenceNumber < trimResult.Sequence).ToList();
// if (toDelete.Count > 0)
// {
// _logger.LogDebug(
// "Deleting HLS segments {Min} to {Max} (less than {StartSequence})",
// toDelete.Map(s => s.SequenceNumber).Min(),
// toDelete.Map(s => s.SequenceNumber).Max(),
// trimResult.Sequence);
// }
foreach (Segment segment in toDelete)
{
File.Delete(segment.File);
}
DeleteOldSegments(trimResult);
PlaylistStart = trimResult.PlaylistStart;
}
@@ -371,9 +358,41 @@ public class HlsSessionWorker : IHlsSessionWorker
}
}
private void DeleteOldSegments(TrimPlaylistResult trimResult)
{
// delete old segments
var allSegments = Directory.GetFiles(
Path.Combine(FileSystemLayout.TranscodeFolder, _channelNumber),
"live*.ts")
.Map(
file =>
{
string fileName = Path.GetFileName(file);
var sequenceNumber = int.Parse(fileName.Replace("live", string.Empty).Split('.')[0]);
return new Segment(file, sequenceNumber);
})
.ToList();
var toDelete = allSegments.Filter(s => s.SequenceNumber < trimResult.Sequence).ToList();
if (toDelete.Count > 0)
{
_logger.LogDebug(
"Deleting HLS segments {Min} to {Max} (less than {StartSequence})",
toDelete.Map(s => s.SequenceNumber).Min(),
toDelete.Map(s => s.SequenceNumber).Max(),
trimResult.Sequence);
}
foreach (Segment segment in toDelete)
{
File.Delete(segment.File);
}
}
private async Task<long> GetPtsOffset(
IMediator mediator,
string channelNumber,
bool firstProcess,
CancellationToken cancellationToken)
{
await Slim.WaitAsync(cancellationToken);
@@ -381,6 +400,12 @@ public class HlsSessionWorker : IHlsSessionWorker
{
long result = 0;
// the first process always starts at zero
if (firstProcess)
{
return result;
}
Either<BaseError, PtsAndDuration> queryResult = await mediator.Send(
new GetLastPtsDuration(channelNumber),
cancellationToken);
@@ -30,7 +30,7 @@ public class GetConcatProcessByChannelNumberHandler : FFmpegProcessHandler<GetCo
.GetValue<bool>(ConfigElementKey.FFmpegSaveReports)
.Map(result => result.IfNone(false));
Command process = _ffmpegProcessService.ConcatChannel(
Command process = await _ffmpegProcessService.ConcatChannel(
ffmpegPath,
saveReports,
channel,
@@ -27,6 +27,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
private readonly ILocalFileSystem _localFileSystem;
private readonly ILogger<GetPlayoutItemProcessByChannelNumberHandler> _logger;
private readonly IMediaCollectionRepository _mediaCollectionRepository;
private readonly IMusicVideoCreditsGenerator _musicVideoCreditsGenerator;
private readonly IPlexPathReplacementService _plexPathReplacementService;
private readonly ISongVideoGenerator _songVideoGenerator;
private readonly ITelevisionRepository _televisionRepository;
@@ -42,6 +43,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
ITelevisionRepository televisionRepository,
IArtistRepository artistRepository,
ISongVideoGenerator songVideoGenerator,
IMusicVideoCreditsGenerator musicVideoCreditsGenerator,
ILogger<GetPlayoutItemProcessByChannelNumberHandler> logger)
: base(dbContextFactory)
{
@@ -54,6 +56,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
_televisionRepository = televisionRepository;
_artistRepository = artistRepository;
_songVideoGenerator = songVideoGenerator;
_musicVideoCreditsGenerator = musicVideoCreditsGenerator;
_logger = logger;
}
@@ -96,6 +99,9 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
.ThenInclude(mi => (mi as MusicVideo).MediaVersions)
.ThenInclude(mv => mv.Streams)
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as MusicVideo).Artist)
.ThenInclude(mv => mv.ArtistMetadata)
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as OtherVideo).OtherVideoMetadata)
.ThenInclude(ovm => ovm.Subtitles)
.Include(i => i.MediaItem)
@@ -155,7 +161,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
.GetValue<bool>(ConfigElementKey.FFmpegSaveReports)
.Map(result => result.IfNone(false));
List<Subtitle> subtitles = GetSubtitles(playoutItemWithPath);
List<Subtitle> subtitles = await GetSubtitles(playoutItemWithPath, channel);
Command process = await _ffmpegProcessService.ForPlayoutItem(
ffmpegPath,
@@ -256,22 +262,22 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
return BaseError.New($"Unexpected error locating playout item for channel {channel.Number}");
}
private static List<Subtitle> GetSubtitles(PlayoutItemWithPath playoutItemWithPath)
private async Task<List<Subtitle>> GetSubtitles(
PlayoutItemWithPath playoutItemWithPath,
Channel channel)
{
List<Subtitle> allSubtitles = playoutItemWithPath.PlayoutItem.MediaItem switch
{
Episode episode => Optional(episode.EpisodeMetadata).Flatten().HeadOrNone()
Episode episode => await Optional(episode.EpisodeMetadata).Flatten().HeadOrNone()
.Map(mm => mm.Subtitles ?? new List<Subtitle>())
.IfNone(new List<Subtitle>()),
Movie movie => Optional(movie.MovieMetadata).Flatten().HeadOrNone()
.IfNoneAsync(new List<Subtitle>()),
Movie movie => await Optional(movie.MovieMetadata).Flatten().HeadOrNone()
.Map(mm => mm.Subtitles ?? new List<Subtitle>())
.IfNone(new List<Subtitle>()),
MusicVideo musicVideo => Optional(musicVideo.MusicVideoMetadata).Flatten().HeadOrNone()
.IfNoneAsync(new List<Subtitle>()),
MusicVideo musicVideo => await GetMusicVideoSubtitles(musicVideo, channel),
OtherVideo otherVideo => await Optional(otherVideo.OtherVideoMetadata).Flatten().HeadOrNone()
.Map(mm => mm.Subtitles ?? new List<Subtitle>())
.IfNone(new List<Subtitle>()),
OtherVideo otherVideo => Optional(otherVideo.OtherVideoMetadata).Flatten().HeadOrNone()
.Map(mm => mm.Subtitles ?? new List<Subtitle>())
.IfNone(new List<Subtitle>()),
.IfNoneAsync(new List<Subtitle>()),
_ => new List<Subtitle>()
};
@@ -309,6 +315,27 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
return allSubtitles;
}
private async Task<List<Subtitle>> GetMusicVideoSubtitles(MusicVideo musicVideo, Channel channel)
{
var subtitles = new List<Subtitle>();
bool musicVideoCredits = channel.MusicVideoCreditsMode == ChannelMusicVideoCreditsMode.GenerateSubtitles;
if (musicVideoCredits)
{
subtitles.AddRange(
await _musicVideoCreditsGenerator.GenerateCreditsSubtitle(musicVideo, channel.FFmpegProfile));
}
else
{
subtitles.AddRange(
await Optional(musicVideo.MusicVideoMetadata).Flatten().HeadOrNone()
.Map(mm => mm.Subtitles)
.IfNoneAsync(new List<Subtitle>()));
}
return subtitles;
}
private async Task<Either<BaseError, PlayoutItemWithPath>> CheckForFallbackFiller(
TvContext dbContext,
Channel channel,
@@ -7,10 +7,10 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Bugsnag" Version="3.0.1" />
<PackageReference Include="Bugsnag" Version="3.1.0" />
<PackageReference Include="CliWrap" Version="3.4.4" />
<PackageReference Include="FluentAssertions" Version="6.6.0" />
<PackageReference Include="LanguageExt.Core" Version="4.1.0" />
<PackageReference Include="FluentAssertions" Version="6.7.0" />
<PackageReference Include="LanguageExt.Core" Version="4.2.7" />
<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" />
@@ -20,7 +20,7 @@ public class HlsPlaylistFilterTests
private HlsPlaylistFilter _hlsPlaylistFilter;
[Test]
public void _hlsPlaylistFilter_ShouldRewriteProgramDateTime()
public void HlsPlaylistFilter_ShouldRewriteProgramDateTime()
{
var start = new DateTimeOffset(2021, 10, 9, 8, 0, 0, TimeSpan.FromHours(-5));
string[] input = NormalizeLineEndings(
@@ -50,9 +50,8 @@ live001139.ts").Split(Environment.NewLine);
#EXT-X-VERSION:6
#EXT-X-TARGETDURATION:4
#EXT-X-MEDIA-SEQUENCE:1137
#EXT-X-DISCONTINUITY-SEQUENCE:0
#EXT-X-DISCONTINUITY-SEQUENCE:1
#EXT-X-INDEPENDENT-SEGMENTS
#EXT-X-DISCONTINUITY
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-09T08:00:00.000-0500
live001137.ts
@@ -66,7 +65,7 @@ live001139.ts
}
[Test]
public void _hlsPlaylistFilter_ShouldLimitSegments()
public void HlsPlaylistFilter_ShouldLimitSegments()
{
var start = new DateTimeOffset(2021, 10, 9, 8, 0, 0, TimeSpan.FromHours(-5));
string[] input = NormalizeLineEndings(
@@ -96,9 +95,8 @@ live001139.ts").Split(Environment.NewLine);
#EXT-X-VERSION:6
#EXT-X-TARGETDURATION:4
#EXT-X-MEDIA-SEQUENCE:1137
#EXT-X-DISCONTINUITY-SEQUENCE:0
#EXT-X-DISCONTINUITY-SEQUENCE:1
#EXT-X-INDEPENDENT-SEGMENTS
#EXT-X-DISCONTINUITY
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-09T08:00:00.000-0500
live001137.ts
@@ -109,7 +107,7 @@ live001138.ts
}
[Test]
public void _hlsPlaylistFilter_ShouldAddDiscontinuity()
public void HlsPlaylistFilter_ShouldAddDiscontinuity()
{
var start = new DateTimeOffset(2021, 10, 9, 8, 0, 0, TimeSpan.FromHours(-5));
string[] input = NormalizeLineEndings(
@@ -144,9 +142,8 @@ live001139.ts").Split(Environment.NewLine);
#EXT-X-VERSION:6
#EXT-X-TARGETDURATION:4
#EXT-X-MEDIA-SEQUENCE:1137
#EXT-X-DISCONTINUITY-SEQUENCE:0
#EXT-X-DISCONTINUITY-SEQUENCE:1
#EXT-X-INDEPENDENT-SEGMENTS
#EXT-X-DISCONTINUITY
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-09T08:00:00.000-0500
live001137.ts
@@ -161,7 +158,7 @@ live001139.ts
}
[Test]
public void _hlsPlaylistFilter_ShouldFilterOldSegments()
public void HlsPlaylistFilter_ShouldFilterOldSegmentsBeyondMax()
{
var start = new DateTimeOffset(2021, 10, 9, 8, 0, 0, TimeSpan.FromHours(-5));
string[] input = NormalizeLineEndings(
@@ -181,48 +178,7 @@ live001138.ts
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:57.320-0500
live001139.ts").Split(Environment.NewLine);
TrimPlaylistResult result = _hlsPlaylistFilter.TrimPlaylist(start, start.AddSeconds(6), input);
result.PlaylistStart.Should().Be(start.AddSeconds(8));
result.Sequence.Should().Be(1139);
result.Playlist.Should().Be(
NormalizeLineEndings(
@"#EXTM3U
#EXT-X-VERSION:6
#EXT-X-TARGETDURATION:4
#EXT-X-MEDIA-SEQUENCE:1139
#EXT-X-DISCONTINUITY-SEQUENCE:0
#EXT-X-INDEPENDENT-SEGMENTS
#EXT-X-DISCONTINUITY
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-09T08:00:08.000-0500
live001139.ts
"));
}
[Test]
public void _hlsPlaylistFilter_ShouldFilterOldDiscontinuity()
{
var start = new DateTimeOffset(2021, 10, 9, 8, 0, 0, TimeSpan.FromHours(-5));
string[] input = NormalizeLineEndings(
@"#EXTM3U
#EXT-X-VERSION:6
#EXT-X-TARGETDURATION:4
#EXT-X-MEDIA-SEQUENCE:1137
#EXT-X-INDEPENDENT-SEGMENTS
#EXT-X-DISCONTINUITY
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:49.320-0500
live001137.ts
#EXT-X-DISCONTINUITY
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:53.320-0500
live001138.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:57.320-0500
live001139.ts").Split(Environment.NewLine);
TrimPlaylistResult result = _hlsPlaylistFilter.TrimPlaylist(start, start.AddSeconds(6), input);
TrimPlaylistResult result = _hlsPlaylistFilter.TrimPlaylist(start, start.AddSeconds(6), input, 1);
result.PlaylistStart.Should().Be(start.AddSeconds(8));
result.Sequence.Should().Be(1139);
@@ -234,13 +190,367 @@ live001139.ts").Split(Environment.NewLine);
#EXT-X-MEDIA-SEQUENCE:1139
#EXT-X-DISCONTINUITY-SEQUENCE:1
#EXT-X-INDEPENDENT-SEGMENTS
#EXT-X-DISCONTINUITY
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-09T08:00:08.000-0500
live001139.ts
"));
}
[Test]
public void HlsPlaylistFilter_ShouldFilterOldDiscontinuity()
{
var start = new DateTimeOffset(2021, 10, 9, 8, 0, 0, TimeSpan.FromHours(-5));
string[] input = NormalizeLineEndings(
@"#EXTM3U
#EXT-X-VERSION:6
#EXT-X-TARGETDURATION:4
#EXT-X-MEDIA-SEQUENCE:1137
#EXT-X-INDEPENDENT-SEGMENTS
#EXT-X-DISCONTINUITY
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:49.320-0500
live001137.ts
#EXT-X-DISCONTINUITY
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:53.320-0500
live001138.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:57.320-0500
live001139.ts").Split(Environment.NewLine);
TrimPlaylistResult result = _hlsPlaylistFilter.TrimPlaylist(start, start.AddSeconds(6), input);
result.PlaylistStart.Should().Be(start);
result.Sequence.Should().Be(1137);
result.Playlist.Should().Be(
NormalizeLineEndings(
@"#EXTM3U
#EXT-X-VERSION:6
#EXT-X-TARGETDURATION:4
#EXT-X-MEDIA-SEQUENCE:1137
#EXT-X-DISCONTINUITY-SEQUENCE:1
#EXT-X-INDEPENDENT-SEGMENTS
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-09T08:00:00.000-0500
live001137.ts
#EXT-X-DISCONTINUITY
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-09T08:00:04.000-0500
live001138.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-09T08:00:08.000-0500
live001139.ts
"));
}
[Test]
public void HlsPlaylistFilter_Should_Increment_DiscontinuityCount()
{
var start = new DateTimeOffset(2022, 5, 25, 20, 8, 0, TimeSpan.FromHours(-5));
string[] input = NormalizeLineEndings(
@"#EXTM3U
#EXT-X-VERSION:6
#EXT-X-TARGETDURATION:4
#EXT-X-MEDIA-SEQUENCE:0
#EXT-X-DISCONTINUITY
#EXT-X-INDEPENDENT-SEGMENTS
#EXT-X-DISCONTINUITY
#EXTINF:4.004000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:08:55.981-0500
live000000.ts
#EXTINF:4.004000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:08:59.985-0500
live000001.ts
#EXTINF:4.004000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:09:03.989-0500
live000002.ts
#EXTINF:4.004000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:09:07.993-0500
live000003.ts
#EXTINF:4.004000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:09:11.997-0500
live000004.ts
#EXTINF:4.004000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:09:16.001-0500
live000005.ts
#EXTINF:4.004000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:09:20.005-0500
live000006.ts
#EXTINF:4.004000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:09:24.009-0500
live000007.ts
#EXTINF:3.970633,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:09:28.013-0500
live000008.ts
#EXTINF:4.004000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:09:31.983-0500
live000009.ts
#EXTINF:4.004000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:09:35.987-0500
live000010.ts
#EXTINF:4.004000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:09:39.991-0500
live000011.ts
#EXTINF:4.004000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:09:43.995-0500
live000012.ts
#EXTINF:4.004000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:09:47.999-0500
live000013.ts
#EXTINF:4.004000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:09:52.003-0500
live000014.ts
#EXTINF:4.004000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:09:56.007-0500
live000015.ts
#EXTINF:3.970633,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:10:00.011-0500
live000016.ts
#EXTINF:4.004000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:10:03.982-0500
live000017.ts
#EXTINF:4.004000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:10:07.986-0500
live000018.ts
#EXTINF:4.004000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:10:11.990-0500
live000019.ts
#EXTINF:4.004000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:10:15.994-0500
live000020.ts
#EXTINF:4.004000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:10:19.998-0500
live000021.ts
#EXTINF:4.004000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:10:24.002-0500
live000022.ts
#EXTINF:4.004000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:10:28.006-0500
live000023.ts
#EXTINF:4.004000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:10:32.010-0500
live000024.ts
#EXTINF:3.970633,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:10:36.014-0500
live000025.ts
#EXTINF:4.004000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:10:39.985-0500
live000026.ts
#EXTINF:4.004000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:10:43.989-0500
live000027.ts
#EXTINF:4.004000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:10:47.993-0500
live000028.ts
#EXTINF:4.004000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:10:51.997-0500
live000029.ts
#EXTINF:4.004000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:10:56.001-0500
live000030.ts
#EXTINF:4.004000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:11:00.005-0500
live000031.ts
#EXTINF:4.004000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:11:04.009-0500
live000032.ts
#EXTINF:3.970633,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:11:08.013-0500
live000033.ts
#EXTINF:4.004000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:11:11.983-0500
live000034.ts
#EXTINF:4.004000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:11:15.987-0500
live000035.ts
#EXTINF:4.004000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:11:19.991-0500
live000036.ts
#EXTINF:4.004000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:11:23.995-0500
live000037.ts
#EXTINF:4.004000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:11:27.999-0500
live000038.ts
#EXTINF:4.004000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:11:32.003-0500
live000039.ts
#EXTINF:4.004000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:11:36.007-0500
live000040.ts
#EXTINF:3.970633,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:11:40.011-0500
live000041.ts
#EXTINF:4.004000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:11:43.982-0500
live000042.ts
#EXTINF:4.004000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:11:47.986-0500
live000043.ts
#EXTINF:4.004000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:11:51.990-0500
live000044.ts
#EXTINF:4.004000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:11:55.994-0500
live000045.ts
#EXTINF:4.004000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:11:59.998-0500
live000046.ts
#EXTINF:4.004000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:12:04.002-0500
live000047.ts
#EXTINF:4.004000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:12:08.006-0500
live000048.ts
#EXTINF:4.004000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:12:12.010-0500
live000049.ts
#EXTINF:3.970633,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:12:16.014-0500
live000050.ts
#EXTINF:4.004000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:12:19.985-0500
live000051.ts
#EXTINF:0.433767,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:12:23.989-0500
live000052.ts
#EXT-X-DISCONTINUITY
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:11:30.007-0500
live000053.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:11:34.007-0500
live000054.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:11:38.007-0500
live000055.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:11:42.007-0500
live000056.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:11:46.007-0500
live000057.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:11:50.007-0500
live000058.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:11:54.007-0500
live000059.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:11:58.007-0500
live000060.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:12:02.007-0500
live000061.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:12:06.007-0500
live000062.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:12:10.007-0500
live000063.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:12:14.007-0500
live000064.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:12:18.007-0500
live000065.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:12:22.007-0500
live000066.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:12:26.007-0500
live000067.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:12:30.007-0500
live000068.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:12:34.007-0500
live000069.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:12:38.007-0500
live000070.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:12:42.007-0500
live000071.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:12:46.007-0500
live000072.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:12:50.007-0500
live000073.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:12:54.007-0500
live000074.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:12:58.007-0500
live000075.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:13:02.007-0500
live000076.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:13:06.007-0500
live000077.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:13:10.007-0500
live000078.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:13:14.007-0500
live000079.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:13:18.007-0500
live000080.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:13:22.007-0500
live000081.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:13:26.007-0500
live000082.ts").Split(Environment.NewLine);
TrimPlaylistResult result = _hlsPlaylistFilter.TrimPlaylist(start, start.AddSeconds(220), input);
// result.PlaylistStart.Should().Be(start);
result.Sequence.Should().Be(56);
result.Playlist.Should().Be(
NormalizeLineEndings(
@"#EXTM3U
#EXT-X-VERSION:6
#EXT-X-TARGETDURATION:4
#EXT-X-MEDIA-SEQUENCE:56
#EXT-X-DISCONTINUITY-SEQUENCE:2
#EXT-X-INDEPENDENT-SEGMENTS
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:11:40.441-0500
live000056.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:11:44.441-0500
live000057.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:11:48.441-0500
live000058.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:11:52.441-0500
live000059.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:11:56.441-0500
live000060.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:12:00.441-0500
live000061.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:12:04.441-0500
live000062.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:12:08.441-0500
live000063.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:12:12.441-0500
live000064.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:12:16.441-0500
live000065.ts
"));
}
private static string NormalizeLineEndings(string str) =>
str
.Replace("\r\n", "\n")
+16 -3
View File
@@ -10,6 +10,8 @@ using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Metadata;
using ErsatzTV.FFmpeg;
using ErsatzTV.FFmpeg.Capabilities;
using ErsatzTV.FFmpeg.State;
using FluentAssertions;
using Microsoft.Extensions.Caching.Memory;
@@ -17,6 +19,7 @@ using Microsoft.Extensions.Logging;
using Moq;
using NUnit.Framework;
using Serilog;
using MediaStream = ErsatzTV.Core.Domain.MediaStream;
namespace ErsatzTV.Core.Tests.FFmpeg;
@@ -302,6 +305,10 @@ public class TranscodingTests
new FFmpegPlaybackSettingsCalculator(),
new FakeStreamSelector(),
new Mock<ITempFilePool>().Object,
new FakeNvidiaCapabilitiesFactory(),
// new HardwareCapabilitiesFactory(
// new MemoryCache(new MemoryCacheOptions()),
// LoggerFactory.CreateLogger<HardwareCapabilitiesFactory>()),
LoggerFactory.CreateLogger<FFmpegLibraryProcessService>());
var v = new MediaVersion
@@ -566,15 +573,21 @@ public class TranscodingTests
Optional(version.Streams.First(s => s.MediaStreamKind == MediaStreamKind.Audio)).AsTask();
public Task<Option<Domain.Subtitle>> SelectSubtitleStream(
MediaVersion version,
List<Domain.Subtitle> subtitles,
StreamingMode streamingMode,
string channelNumber,
Channel channel,
string preferredSubtitleLanguage,
ChannelSubtitleMode subtitleMode) =>
subtitles.HeadOrNone().AsTask();
}
private class FakeNvidiaCapabilitiesFactory : IHardwareCapabilitiesFactory
{
public Task<IHardwareCapabilities> GetHardwareCapabilities(
string ffmpegPath,
HardwareAccelerationMode hardwareAccelerationMode) =>
Task.FromResult<IHardwareCapabilities>(new NvidiaHardwareCapabilities(61));
}
private static string ExecutableName(string baseName) =>
OperatingSystem.IsWindows() ? $"{baseName}.exe" : baseName;
}
@@ -0,0 +1,21 @@
namespace ErsatzTV.Core.Api.FFmpegProfiles;
public record FFmpegFullProfileResponseModel(
int Id,
string Name,
int ThreadCount,
int HardwareAcceleration,
int VaapiDriver,
string VaapiDevice,
int ResolutionId,
int VideoFormat,
int VideoBitrate,
int VideoBufferSize,
int AudioFormat,
int AudioBitrate,
int AudioBufferSize,
bool NormalizeLoudness,
int AudioChannels,
int AudioSampleRate,
bool NormalizeFramerate,
bool? DeinterlaceVideo);
+1
View File
@@ -25,4 +25,5 @@ public class Channel
public string PreferredAudioLanguageCode { get; set; }
public string PreferredSubtitleLanguageCode { get; set; }
public ChannelSubtitleMode SubtitleMode { get; set; }
public ChannelMusicVideoCreditsMode MusicVideoCreditsMode { get; set; }
}
@@ -0,0 +1,7 @@
namespace ErsatzTV.Core.Domain;
public enum ChannelMusicVideoCreditsMode
{
None = 0,
GenerateSubtitles = 1
}
@@ -3,5 +3,7 @@
public enum SubtitleKind
{
Embedded = 0,
Sidecar = 1
Sidecar = 1,
Generated = 99
}
+38 -41
View File
@@ -30,28 +30,22 @@ public class EmbyCollectionScanner : IEmbyCollectionScanner
public async Task<Either<BaseError, Unit>> ScanCollections(string address, string apiKey)
{
// get all collections from db (item id, etag)
List<EmbyCollection> existingCollections = await _embyCollectionRepository.GetCollections();
// get all collections from emby
Either<BaseError, List<EmbyCollection>> maybeIncomingCollections =
await _embyApiClient.GetCollectionLibraryItems(address, apiKey);
foreach (BaseError error in maybeIncomingCollections.LeftToSeq())
try
{
_logger.LogWarning("Failed to get collections from Emby: {Error}", error.ToString());
return error;
}
var incomingItemIds = new List<string>();
foreach (List<EmbyCollection> incomingCollections in maybeIncomingCollections.RightToSeq())
{
// loop over collections
foreach (EmbyCollection collection in incomingCollections)
// get all collections from db (item id, etag)
List<EmbyCollection> existingCollections = await _embyCollectionRepository.GetCollections();
await foreach (EmbyCollection collection in _embyApiClient.GetCollectionLibraryItems(address, apiKey))
{
incomingItemIds.Add(collection.ItemId);
Option<EmbyCollection> maybeExisting = existingCollections.Find(c => c.ItemId == collection.ItemId);
// skip if unchanged (etag)
if (await maybeExisting.Map(e => e.Etag ?? string.Empty).IfNoneAsync(string.Empty) == collection.Etag)
if (await maybeExisting.Map(e => e.Etag ?? string.Empty).IfNoneAsync(string.Empty) ==
collection.Etag)
{
_logger.LogDebug("Emby collection {Name} is unchanged", collection.Name);
continue;
@@ -75,12 +69,16 @@ public class EmbyCollectionScanner : IEmbyCollectionScanner
}
// remove missing collections (and remove any lingering tags from those collections)
foreach (EmbyCollection collection in existingCollections
.Filter(e => incomingCollections.All(i => i.ItemId != e.ItemId)))
foreach (EmbyCollection collection in existingCollections.Filter(e => !incomingItemIds.Contains(e.ItemId)))
{
await _embyCollectionRepository.RemoveCollection(collection);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to get collections from Emby");
return BaseError.New(ex.Message);
}
return Unit.Default;
}
@@ -90,32 +88,31 @@ public class EmbyCollectionScanner : IEmbyCollectionScanner
string apiKey,
EmbyCollection collection)
{
// get collection items from JF
Either<BaseError, List<MediaItem>> maybeItems =
await _embyApiClient.GetCollectionItems(address, apiKey, collection.ItemId);
foreach (BaseError error in maybeItems.LeftToSeq())
try
{
_logger.LogWarning("Failed to get collection items from Emby: {Error}", error.ToString());
return;
// get collection items from Emby
IAsyncEnumerable<MediaItem> items = _embyApiClient.GetCollectionItems(address, apiKey, collection.ItemId);
List<int> removedIds = await _embyCollectionRepository.RemoveAllTags(collection);
// sync tags on items
var addedIds = new List<int>();
await foreach (MediaItem item in items)
{
addedIds.Add(await _embyCollectionRepository.AddTag(item, collection));
}
_logger.LogDebug("Emby collection {Name} contains {Count} items", collection.Name, addedIds.Count);
var changedIds = removedIds.Except(addedIds).ToList();
changedIds.AddRange(addedIds.Except(removedIds));
await _searchIndex.RebuildItems(_searchRepository, changedIds);
_searchIndex.Commit();
}
List<int> removedIds = await _embyCollectionRepository.RemoveAllTags(collection);
var embyItems = maybeItems.RightToSeq().Flatten().ToList();
_logger.LogDebug("Emby collection {Name} contains {Count} items", collection.Name, embyItems.Count);
// sync tags on items
var addedIds = new List<int>();
foreach (MediaItem item in embyItems)
catch (Exception ex)
{
addedIds.Add(await _embyCollectionRepository.AddTag(item, collection));
_logger.LogWarning(ex, "Failed to synchronize Emby collection {Name}", collection.Name);
}
var changedIds = removedIds.Except(addedIds).ToList();
changedIds.AddRange(addedIds.Except(removedIds));
await _searchIndex.RebuildItems(_searchRepository, changedIds);
_searchIndex.Commit();
}
}
+11
View File
@@ -0,0 +1,11 @@
namespace ErsatzTV.Core.Emby;
public static class EmbyItemType
{
public static readonly string Movie = "Movie";
public static readonly string Show = "Series";
public static readonly string Season = "Season";
public static readonly string Episode = "Episode";
public static readonly string Collection = "BoxSet";
public static readonly string CollectionItems = "Movie,Series,Season,Episode";
}
+10 -1
View File
@@ -79,7 +79,16 @@ public class EmbyMovieLibraryScanner :
protected override string MediaServerItemId(EmbyMovie movie) => movie.ItemId;
protected override string MediaServerEtag(EmbyMovie movie) => movie.Etag;
protected override Task<Either<BaseError, List<EmbyMovie>>> GetMovieLibraryItems(
protected override Task<Either<BaseError, int>> CountMovieLibraryItems(
EmbyConnectionParameters connectionParameters,
EmbyLibrary library) =>
_embyApiClient.GetLibraryItemCount(
connectionParameters.Address,
connectionParameters.ApiKey,
library.ItemId,
EmbyItemType.Movie);
protected override IAsyncEnumerable<EmbyMovie> GetMovieLibraryItems(
EmbyConnectionParameters connectionParameters,
EmbyLibrary library) =>
_embyApiClient.GetMovieLibraryItems(
@@ -76,13 +76,19 @@ public class EmbyTelevisionLibraryScanner : MediaServerTelevisionLibraryScanner<
cancellationToken);
}
protected override Task<Either<BaseError, List<EmbyShow>>> GetShowLibraryItems(
protected override Task<Either<BaseError, int>> CountShowLibraryItems(
EmbyConnectionParameters connectionParameters,
EmbyLibrary library) =>
_embyApiClient.GetShowLibraryItems(
EmbyLibrary library)
=> _embyApiClient.GetLibraryItemCount(
connectionParameters.Address,
connectionParameters.ApiKey,
library.ItemId);
library.ItemId,
EmbyItemType.Show);
protected override IAsyncEnumerable<EmbyShow> GetShowLibraryItems(
EmbyConnectionParameters connectionParameters,
EmbyLibrary library) =>
_embyApiClient.GetShowLibraryItems(connectionParameters.Address, connectionParameters.ApiKey, library);
protected override string MediaServerItemId(EmbyShow show) => show.ItemId;
protected override string MediaServerItemId(EmbySeason season) => season.ItemId;
@@ -92,23 +98,46 @@ public class EmbyTelevisionLibraryScanner : MediaServerTelevisionLibraryScanner<
protected override string MediaServerEtag(EmbySeason season) => season.Etag;
protected override string MediaServerEtag(EmbyEpisode episode) => episode.Etag;
protected override Task<Either<BaseError, List<EmbySeason>>> GetSeasonLibraryItems(
protected override Task<Either<BaseError, int>> CountSeasonLibraryItems(
EmbyConnectionParameters connectionParameters,
EmbyLibrary library,
EmbyShow show) =>
_embyApiClient.GetLibraryItemCount(
connectionParameters.Address,
connectionParameters.ApiKey,
show.ItemId,
EmbyItemType.Season);
protected override IAsyncEnumerable<EmbySeason> GetSeasonLibraryItems(
EmbyLibrary library,
EmbyConnectionParameters connectionParameters,
EmbyShow show) =>
_embyApiClient.GetSeasonLibraryItems(
connectionParameters.Address,
connectionParameters.ApiKey,
library,
show.ItemId);
protected override Task<Either<BaseError, List<EmbyEpisode>>> GetEpisodeLibraryItems(
protected override Task<Either<BaseError, int>> CountEpisodeLibraryItems(
EmbyConnectionParameters connectionParameters,
EmbyLibrary library,
EmbySeason season) =>
_embyApiClient.GetLibraryItemCount(
connectionParameters.Address,
connectionParameters.ApiKey,
season.ItemId,
EmbyItemType.Episode);
protected override IAsyncEnumerable<EmbyEpisode> GetEpisodeLibraryItems(
EmbyLibrary library,
EmbyConnectionParameters connectionParameters,
EmbyShow show,
EmbySeason season) =>
_embyApiClient.GetEpisodeLibraryItems(
connectionParameters.Address,
connectionParameters.ApiKey,
library,
show.ItemId,
season.ItemId);
protected override Task<Option<ShowMetadata>> GetFullMetadata(
+4 -3
View File
@@ -7,10 +7,11 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Bugsnag" Version="3.0.1" />
<PackageReference Include="Bugsnag" Version="3.1.0" />
<PackageReference Include="Destructurama.Attributed" Version="3.0.0" />
<PackageReference Include="Flurl" Version="3.0.5" />
<PackageReference Include="LanguageExt.Core" Version="4.1.0" />
<PackageReference Include="Flurl" Version="3.0.6" />
<PackageReference Include="LanguageExt.Core" Version="4.2.7" />
<PackageReference Include="LanguageExt.Transformers" Version="4.2.7" />
<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" />
@@ -3,6 +3,7 @@ using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.FFmpeg;
using ErsatzTV.FFmpeg.Capabilities;
using ErsatzTV.FFmpeg.Environment;
using ErsatzTV.FFmpeg.Format;
using ErsatzTV.FFmpeg.OutputFormat;
@@ -16,6 +17,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
{
private readonly FFmpegProcessService _ffmpegProcessService;
private readonly IFFmpegStreamSelector _ffmpegStreamSelector;
private readonly IHardwareCapabilitiesFactory _hardwareCapabilitiesFactory;
private readonly ILogger<FFmpegLibraryProcessService> _logger;
private readonly FFmpegPlaybackSettingsCalculator _playbackSettingsCalculator;
private readonly ITempFilePool _tempFilePool;
@@ -25,12 +27,14 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
FFmpegPlaybackSettingsCalculator playbackSettingsCalculator,
IFFmpegStreamSelector ffmpegStreamSelector,
ITempFilePool tempFilePool,
IHardwareCapabilitiesFactory hardwareCapabilitiesFactory,
ILogger<FFmpegLibraryProcessService> logger)
{
_ffmpegProcessService = ffmpegProcessService;
_playbackSettingsCalculator = playbackSettingsCalculator;
_ffmpegStreamSelector = ffmpegStreamSelector;
_tempFilePool = tempFilePool;
_hardwareCapabilitiesFactory = hardwareCapabilitiesFactory;
_logger = logger;
}
@@ -71,10 +75,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
preferredAudioLanguage);
Option<Subtitle> maybeSubtitle =
await _ffmpegStreamSelector.SelectSubtitleStream(
videoVersion,
subtitles,
channel.StreamingMode,
channel.Number,
channel,
preferredSubtitleLanguage,
subtitleMode);
@@ -216,6 +218,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
var ffmpegState = new FFmpegState(
saveReports,
hwAccel,
hwAccel,
VaapiDriverName(hwAccel, vaapiDriver),
VaapiDeviceName(hwAccel, vaapiDevice),
playbackSettings.StreamSeek,
@@ -233,6 +236,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
_logger.LogDebug("FFmpeg desired state {FrameState}", desiredState);
var pipelineBuilder = new PipelineBuilder(
await _hardwareCapabilitiesFactory.GetHardwareCapabilities(ffmpegPath, hwAccel),
videoInputFile,
audioInputFile,
watermarkInputFile,
@@ -335,6 +339,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
var ffmpegState = new FFmpegState(
false,
hwAccel,
hwAccel,
VaapiDriverName(hwAccel, vaapiDriver),
VaapiDeviceName(hwAccel, vaapiDevice),
playbackSettings.StreamSeek,
@@ -361,6 +366,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
_logger.LogDebug("FFmpeg desired error state {FrameState}", desiredState);
var pipelineBuilder = new PipelineBuilder(
await _hardwareCapabilitiesFactory.GetHardwareCapabilities(ffmpegPath, hwAccel),
videoInputFile,
audioInputFile,
None,
@@ -374,7 +380,12 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
return GetCommand(ffmpegPath, videoInputFile, audioInputFile, None, None, pipeline);
}
public Command ConcatChannel(string ffmpegPath, bool saveReports, Channel channel, string scheme, string host)
public async Task<Command> ConcatChannel(
string ffmpegPath,
bool saveReports,
Channel channel,
string scheme,
string host)
{
var resolution = new FrameSize(channel.FFmpegProfile.Resolution.Width, channel.FFmpegProfile.Resolution.Height);
@@ -383,6 +394,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
resolution);
var pipelineBuilder = new PipelineBuilder(
await _hardwareCapabilitiesFactory.GetHardwareCapabilities(ffmpegPath, HardwareAccelerationMode.None),
None,
None,
None,
@@ -401,13 +413,14 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
public Command WrapSegmenter(string ffmpegPath, bool saveReports, Channel channel, string scheme, string host) =>
_ffmpegProcessService.WrapSegmenter(ffmpegPath, saveReports, channel, scheme, host);
public Command ResizeImage(string ffmpegPath, string inputFile, string outputFile, int height)
public async Task<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(
await _hardwareCapabilitiesFactory.GetHardwareCapabilities(ffmpegPath, HardwareAccelerationMode.None),
videoInputFile,
None,
None,
+11 -6
View File
@@ -86,19 +86,24 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector
}
public async Task<Option<Subtitle>> SelectSubtitleStream(
MediaVersion version,
List<Subtitle> subtitles,
StreamingMode streamingMode,
string channelNumber,
Channel channel,
string preferredSubtitleLanguage,
ChannelSubtitleMode subtitleMode)
{
if (channel.MusicVideoCreditsMode == ChannelMusicVideoCreditsMode.GenerateSubtitles &&
subtitles.FirstOrDefault(s => s.SubtitleKind == SubtitleKind.Generated) is { } generatedSubtitle)
{
_logger.LogDebug("Selecting generated subtitle for channel {Number}", channel.Number);
return Optional(generatedSubtitle);
}
if (subtitleMode == ChannelSubtitleMode.None)
{
return None;
}
if (streamingMode == StreamingMode.HttpLiveStreamingDirect &&
if (channel.StreamingMode == StreamingMode.HttpLiveStreamingDirect &&
string.IsNullOrWhiteSpace(preferredSubtitleLanguage))
{
// _logger.LogDebug(
@@ -110,7 +115,7 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector
string language = (preferredSubtitleLanguage ?? string.Empty).ToLowerInvariant();
if (string.IsNullOrWhiteSpace(language))
{
_logger.LogDebug("Channel {Number} has no preferred subtitle language code", channelNumber);
_logger.LogDebug("Channel {Number} has no preferred subtitle language code", channel.Number);
}
else
{
@@ -152,7 +157,7 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector
_logger.LogDebug(
"Found no subtitles for channel {ChannelNumber} with mode {Mode} matching language {Language}",
channelNumber,
channel.Number,
subtitleMode,
preferredSubtitleLanguage);
+110 -59
View File
@@ -25,52 +25,38 @@ public class HlsPlaylistFilter : IHlsPlaylistFilter
{
try
{
// _logger.LogDebug(
// "TrimPlaylist - Start {PlaylistStart}, FilterBefore {FilterBefore}, MaxSegments {MaxSegments}, EndWithDiscontinuity {EndWithDiscontinuity}",
// playlistStart,
// filterBefore,
// maxSegments,
// endWithDiscontinuity);
List<PlaylistItem> items = new();
DateTimeOffset currentTime = playlistStart;
DateTimeOffset nextPlaylistStart = DateTimeOffset.MaxValue;
var discontinuitySequence = 0;
var startSequence = 0;
var output = new StringBuilder();
var started = false;
var i = 0;
var segments = 0;
while (!lines[i].StartsWith("#EXTINF:"))
{
if (lines[i].StartsWith("#EXT-X-DISCONTINUITY-SEQUENCE"))
{
discontinuitySequence = int.Parse(lines[i].Split(':')[1]);
}
else if (lines[i].StartsWith("#EXT-X-DISCONTINUITY"))
{
items.Add(new PlaylistDiscontinuity());
}
i++;
}
while (i < lines.Length)
{
if (segments >= maxSegments)
string line = lines[i];
if (string.IsNullOrWhiteSpace(line))
{
break;
i++;
continue;
}
string line = lines[i];
// _logger.LogInformation("Line: {Line}", line);
if (line.StartsWith("#EXT-X-DISCONTINUITY"))
{
if (started)
{
output.AppendLine("#EXT-X-DISCONTINUITY");
}
else
{
discontinuitySequence++;
}
items.Add(new PlaylistDiscontinuity());
i++;
continue;
}
@@ -80,50 +66,23 @@ public class HlsPlaylistFilter : IHlsPlaylistFilter
lines[i].TrimEnd(',').Split(':')[1],
NumberStyles.Number,
CultureInfo.InvariantCulture));
if (currentTime < filterBefore)
{
currentTime += duration;
i += 3;
continue;
}
nextPlaylistStart = currentTime < nextPlaylistStart ? currentTime : nextPlaylistStart;
if (!started)
{
startSequence = int.Parse(lines[i + 2].Replace("live", string.Empty).Split('.')[0]);
output.AppendLine("#EXTM3U");
output.AppendLine("#EXT-X-VERSION:6");
output.AppendLine("#EXT-X-TARGETDURATION:4");
output.AppendLine($"#EXT-X-MEDIA-SEQUENCE:{startSequence}");
output.AppendLine($"#EXT-X-DISCONTINUITY-SEQUENCE:{discontinuitySequence}");
output.AppendLine("#EXT-X-INDEPENDENT-SEGMENTS");
output.AppendLine("#EXT-X-DISCONTINUITY");
started = true;
}
output.AppendLine(lines[i]);
string offset = currentTime.ToString("zzz").Replace(":", string.Empty);
output.AppendLine($"#EXT-X-PROGRAM-DATE-TIME:{currentTime:yyyy-MM-ddTHH:mm:ss.fff}{offset}");
output.AppendLine(lines[i + 2]);
items.Add(new PlaylistSegment(currentTime, lines[i], lines[i + 2]));
currentTime += duration;
segments++;
i += 3;
}
var playlist = output.ToString();
if (endWithDiscontinuity && !playlist.EndsWith($"#EXT-X-DISCONTINUITY{Environment.NewLine}"))
if (endWithDiscontinuity && items[^1] is not PlaylistDiscontinuity)
{
playlist += "#EXT-X-DISCONTINUITY" + Environment.NewLine;
items.Add(new PlaylistDiscontinuity());
}
if (playlist.Trim().Split(Environment.NewLine).All(l => string.IsNullOrWhiteSpace(l) || l.StartsWith('#')))
{
throw new Exception("Trimming playlist to nothing");
}
(string playlist, DateTimeOffset nextPlaylistStart, int startSequence, int segments) = GeneratePlaylist(
items,
filterBefore,
discontinuitySequence,
maxSegments);
return new TrimPlaylistResult(nextPlaylistStart, startSequence, playlist, segments);
}
@@ -153,6 +112,98 @@ public class HlsPlaylistFilter : IHlsPlaylistFilter
DateTimeOffset filterBefore,
string[] lines) =>
TrimPlaylist(playlistStart, filterBefore, lines, int.MaxValue, true);
private static Tuple<string, DateTimeOffset, int, int> GeneratePlaylist(
List<PlaylistItem> items,
DateTimeOffset filterBefore,
int discontinuitySequence,
int maxSegments)
{
if (items.Any() && items[0] is PlaylistDiscontinuity)
{
discontinuitySequence++;
}
while (items.Any() && items[0] is PlaylistDiscontinuity)
{
items.RemoveAt(0);
}
var allSegments = items.OfType<PlaylistSegment>().ToList();
// only filter if we have more than requested
if (allSegments.Count > maxSegments)
{
var afterFilter = allSegments.Filter(s => s.StartTime >= filterBefore).ToList();
// if there are enough new segments after filtering, use those
// otherwise return the last maxSegments
allSegments = afterFilter.Count >= maxSegments
? afterFilter.Take(maxSegments).ToList()
: allSegments.TakeLast(maxSegments).ToList();
}
int startSequence = allSegments
.HeadOrNone()
.Map(s => s.StartSequence)
.IfNone(0);
// count all discontinuities that were filtered out
if (allSegments.Any())
{
int index = items.IndexOf(allSegments.Head());
int count = items.Take(index + 1).OfType<PlaylistDiscontinuity>().Count();
discontinuitySequence += count;
}
var output = new StringBuilder();
output.AppendLine("#EXTM3U");
output.AppendLine("#EXT-X-VERSION:6");
output.AppendLine("#EXT-X-TARGETDURATION:4");
output.AppendLine($"#EXT-X-MEDIA-SEQUENCE:{startSequence}");
output.AppendLine($"#EXT-X-DISCONTINUITY-SEQUENCE:{discontinuitySequence}");
output.AppendLine("#EXT-X-INDEPENDENT-SEGMENTS");
for (var i = 0; i < items.Count; i++)
{
switch (items[i])
{
case PlaylistDiscontinuity:
if (i == items.Count - 1 || allSegments.Contains(items[i + 1]))
{
output.AppendLine("#EXT-X-DISCONTINUITY");
}
break;
case PlaylistSegment segment:
if (allSegments.Contains(segment))
{
output.AppendLine(segment.ExtInf);
string offset = segment.StartTime.ToString("zzz").Replace(":", string.Empty);
output.AppendLine(
$"#EXT-X-PROGRAM-DATE-TIME:{segment.StartTime:yyyy-MM-ddTHH:mm:ss.fff}{offset}");
output.AppendLine(segment.Line);
}
break;
}
}
var playlist = output.ToString();
DateTimeOffset nextPlaylistStart = allSegments.HeadOrNone()
.Map(s => s.StartTime)
.IfNone(DateTimeOffset.MaxValue);
return Tuple(playlist, nextPlaylistStart, startSequence, allSegments.Count);
}
private abstract record PlaylistItem;
private record PlaylistSegment(DateTimeOffset StartTime, string ExtInf, string Line) : PlaylistItem
{
public int StartSequence => int.Parse(Line.Replace("live", string.Empty).Split('.')[0]);
}
private record PlaylistDiscontinuity : PlaylistItem;
}
public record TrimPlaylistResult(DateTimeOffset PlaylistStart, int Sequence, string Playlist, int SegmentCount);
@@ -0,0 +1,84 @@
using System.Text;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.FFmpeg;
namespace ErsatzTV.Core.FFmpeg;
public class MusicVideoCreditsGenerator : IMusicVideoCreditsGenerator
{
private readonly ITempFilePool _tempFilePool;
public MusicVideoCreditsGenerator(ITempFilePool tempFilePool) => _tempFilePool = tempFilePool;
public async Task<Option<Subtitle>> GenerateCreditsSubtitle(MusicVideo musicVideo, FFmpegProfile ffmpegProfile)
{
const int HORIZONTAL_MARGIN_PERCENT = 3;
const int VERTICAL_MARGIN_PERCENT = 5;
var fontSize = (int)Math.Round(ffmpegProfile.Resolution.Height / 20.0);
int leftMarginPercent = HORIZONTAL_MARGIN_PERCENT;
int rightMarginPercent = HORIZONTAL_MARGIN_PERCENT;
var leftMargin = (int)Math.Round(leftMarginPercent / 100.0 * ffmpegProfile.Resolution.Width);
var rightMargin = (int)Math.Round(rightMarginPercent / 100.0 * ffmpegProfile.Resolution.Width);
var verticalMargin =
(int)Math.Round(VERTICAL_MARGIN_PERCENT / 100.0 * ffmpegProfile.Resolution.Height);
foreach (MusicVideoMetadata metadata in musicVideo.MusicVideoMetadata)
{
var sb = new StringBuilder();
string artist = string.Empty;
foreach (ArtistMetadata artistMetadata in Optional(metadata.MusicVideo?.Artist?.ArtistMetadata).Flatten())
{
artist = artistMetadata.Title;
}
if (!string.IsNullOrWhiteSpace(artist))
{
sb.Append(artist);
}
if (!string.IsNullOrWhiteSpace(metadata.Title))
{
sb.Append($"\\N\"{metadata.Title}\"");
}
if (!string.IsNullOrWhiteSpace(metadata.Album))
{
sb.Append($"\\N{metadata.Album}");
}
string subtitles = await new SubtitleBuilder(_tempFilePool)
.WithResolution(ffmpegProfile.Resolution)
.WithFontName("OPTIKabel-Heavy")
.WithFontSize(fontSize)
.WithPrimaryColor("&HFFFFFF")
.WithOutlineColor("&H444444")
.WithAlignment(0)
.WithMarginRight(rightMargin)
.WithMarginLeft(leftMargin)
.WithMarginV(verticalMargin)
.WithBorderStyle(1)
.WithShadow(3)
.WithFormattedContent(sb.ToString())
.WithStartEnd(TimeSpan.FromSeconds(9), TimeSpan.FromSeconds(16))
.WithFade(true)
.BuildFile();
return new Subtitle
{
Codec = "ass",
Default = true,
Forced = true,
IsExtracted = false,
SubtitleKind = SubtitleKind.Generated,
Path = subtitles,
SDH = false
};
}
return None;
}
}
+31 -6
View File
@@ -9,6 +9,8 @@ public class SubtitleBuilder
private Option<int> _alignment;
private Option<int> _borderStyle;
private string _content;
private Option<TimeSpan> _end;
private bool _fade;
private Option<string> _fontName;
private Option<int> _fontSize;
private int _marginLeft;
@@ -18,6 +20,7 @@ public class SubtitleBuilder
private Option<string> _primaryColor;
private Option<IDisplaySize> _resolution = None;
private Option<int> _shadow;
private Option<TimeSpan> _start;
public SubtitleBuilder(ITempFilePool tempFilePool) => _tempFilePool = tempFilePool;
@@ -93,6 +96,19 @@ public class SubtitleBuilder
return this;
}
public SubtitleBuilder WithStartEnd(TimeSpan start, TimeSpan end)
{
_start = start;
_end = end;
return this;
}
public SubtitleBuilder WithFade(bool fade)
{
_fade = fade;
return this;
}
public async Task<string> BuildFile()
{
string fileName = _tempFilePool.GetNextTempFile(TempFileCategory.Subtitle);
@@ -116,15 +132,24 @@ public class SubtitleBuilder
sb.AppendLine(
$"Style: Default,{await _fontName.IfNoneAsync("")},{await _fontSize.IfNoneAsync(32)},{await _primaryColor.IfNoneAsync("")},{await _outlineColor.IfNoneAsync("")},{await _borderStyle.IfNoneAsync(0)},1,{await _shadow.IfNoneAsync(0)},{await _alignment.IfNoneAsync(0)},1");
var start = "0:00:00.00";
foreach (TimeSpan startTime in _start)
{
start = $"{(int)startTime.TotalHours:00}:{startTime.ToString(@"mm\:ss\.ff")}";
}
var end = "99:99:99.99";
foreach (TimeSpan endTime in _end)
{
end = $"{(int)endTime.TotalHours:00}:{endTime.ToString(@"mm\:ss\.ff")}";
}
string fade = _fade ? @"{\fad(1200, 1200)}" : string.Empty;
sb.AppendLine("[Events]");
sb.AppendLine("Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text");
sb.AppendLine(
$"Dialogue: 0,0:00:00.00,99:99:99.99,Default,,{_marginLeft},{_marginRight},{_marginV},,{_content}");
if (!string.IsNullOrWhiteSpace(_content))
{
sb.AppendLine(_content);
}
@$"Dialogue: 0,{start},{end},Default,,{_marginLeft},{_marginRight},{_marginV},,{fade}{_content}");
await File.WriteAllTextAsync(fileName, sb.ToString());
+18 -21
View File
@@ -8,33 +8,30 @@ public interface IEmbyApiClient
Task<Either<BaseError, EmbyServerInformation>> GetServerInformation(string address, string apiKey);
Task<Either<BaseError, List<EmbyLibrary>>> GetLibraries(string address, string apiKey);
Task<Either<BaseError, List<EmbyMovie>>> GetMovieLibraryItems(
string address,
string apiKey,
EmbyLibrary library);
IAsyncEnumerable<EmbyMovie> GetMovieLibraryItems(string address, string apiKey, EmbyLibrary library);
Task<Either<BaseError, List<EmbyShow>>> GetShowLibraryItems(
string address,
string apiKey,
string libraryId);
IAsyncEnumerable<EmbyShow> GetShowLibraryItems(string address, string apiKey, EmbyLibrary library);
Task<Either<BaseError, List<EmbySeason>>> GetSeasonLibraryItems(
string address,
string apiKey,
string showId);
Task<Either<BaseError, List<EmbyEpisode>>> GetEpisodeLibraryItems(
IAsyncEnumerable<EmbySeason> GetSeasonLibraryItems(
string address,
string apiKey,
EmbyLibrary library,
string seasonId);
string showId);
Task<Either<BaseError, List<EmbyCollection>>> GetCollectionLibraryItems(
string address,
string apiKey);
Task<Either<BaseError, List<MediaItem>>> GetCollectionItems(
IAsyncEnumerable<EmbyEpisode> GetEpisodeLibraryItems(
string address,
string apiKey,
string collectionId);
EmbyLibrary library,
string showId,
string seasonId);
IAsyncEnumerable<EmbyCollection> GetCollectionLibraryItems(string address, string apiKey);
IAsyncEnumerable<MediaItem> GetCollectionItems(string address, string apiKey, string collectionId);
Task<Either<BaseError, int>> GetLibraryItemCount(
string address,
string apiKey,
string parentId,
string includeItemTypes);
}
@@ -46,11 +46,11 @@ public interface IFFmpegProcessService
VaapiDriver vaapiDriver,
string vaapiDevice);
Command ConcatChannel(string ffmpegPath, bool saveReports, Channel channel, string scheme, string host);
Task<Command> ConcatChannel(string ffmpegPath, bool saveReports, Channel channel, string scheme, string host);
Command WrapSegmenter(string ffmpegPath, bool saveReports, Channel channel, string scheme, string host);
Command ResizeImage(string ffmpegPath, string inputFile, string outputFile, int height);
Task<Command> ResizeImage(string ffmpegPath, string inputFile, string outputFile, int height);
Command ConvertToPng(string ffmpegPath, string inputFile, string outputFile);
@@ -13,10 +13,8 @@ public interface IFFmpegStreamSelector
string preferredAudioLanguage);
Task<Option<Subtitle>> SelectSubtitleStream(
MediaVersion version,
List<Subtitle> subtitles,
StreamingMode streamingMode,
string channelNumber,
Channel channel,
string preferredSubtitleLanguage,
ChannelSubtitleMode subtitleMode);
}
@@ -0,0 +1,8 @@
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Interfaces.FFmpeg;
public interface IMusicVideoCreditsGenerator
{
Task<Option<Subtitle>> GenerateCreditsSubtitle(MusicVideo musicVideo, FFmpegProfile ffmpegProfile);
}
@@ -9,37 +9,35 @@ public interface IJellyfinApiClient
Task<Either<BaseError, List<JellyfinLibrary>>> GetLibraries(string address, string apiKey);
Task<Either<BaseError, string>> GetAdminUserId(string address, string apiKey);
Task<Either<BaseError, List<JellyfinMovie>>> GetMovieLibraryItems(
string address,
string apiKey,
JellyfinLibrary library);
IAsyncEnumerable<JellyfinMovie> GetMovieLibraryItems(string address, string apiKey, JellyfinLibrary library);
Task<Either<BaseError, List<JellyfinShow>>> GetShowLibraryItems(
string address,
string apiKey,
int mediaSourceId,
string libraryId);
IAsyncEnumerable<JellyfinShow> GetShowLibraryItems(string address, string apiKey, JellyfinLibrary library);
Task<Either<BaseError, List<JellyfinSeason>>> GetSeasonLibraryItems(
IAsyncEnumerable<JellyfinSeason> GetSeasonLibraryItems(
string address,
string apiKey,
int mediaSourceId,
JellyfinLibrary library,
string showId);
Task<Either<BaseError, List<JellyfinEpisode>>> GetEpisodeLibraryItems(
IAsyncEnumerable<JellyfinEpisode> GetEpisodeLibraryItems(
string address,
string apiKey,
JellyfinLibrary library,
string seasonId);
Task<Either<BaseError, List<JellyfinCollection>>> GetCollectionLibraryItems(
string address,
string apiKey,
int mediaSourceId);
IAsyncEnumerable<JellyfinCollection> GetCollectionLibraryItems(string address, string apiKey, int mediaSourceId);
Task<Either<BaseError, List<MediaItem>>> GetCollectionItems(
IAsyncEnumerable<MediaItem> GetCollectionItems(
string address,
string apiKey,
int mediaSourceId,
string collectionId);
Task<Either<BaseError, int>> GetLibraryItemCount(
string address,
string apiKey,
JellyfinLibrary library,
string parentId,
string includeItemTypes,
bool excludeFolders);
}
@@ -13,23 +13,33 @@ public interface IPlexServerApiClient
PlexConnection connection,
PlexServerAuthToken token);
Task<Either<BaseError, List<PlexMovie>>> GetMovieLibraryContents(
IAsyncEnumerable<PlexMovie> GetMovieLibraryContents(
PlexLibrary library,
PlexConnection connection,
PlexServerAuthToken token);
Task<Either<BaseError, List<PlexShow>>> GetShowLibraryContents(
IAsyncEnumerable<PlexShow> GetShowLibraryContents(
PlexLibrary library,
PlexConnection connection,
PlexServerAuthToken token);
Task<Either<BaseError, List<PlexSeason>>> GetShowSeasons(
Task<Either<BaseError, int>> CountShowSeasons(
PlexShow show,
PlexConnection connection,
PlexServerAuthToken token);
IAsyncEnumerable<PlexSeason> GetShowSeasons(
PlexLibrary library,
PlexShow show,
PlexConnection connection,
PlexServerAuthToken token);
Task<Either<BaseError, List<PlexEpisode>>> GetSeasonEpisodes(
Task<Either<BaseError, int>> CountSeasonEpisodes(
PlexSeason season,
PlexConnection connection,
PlexServerAuthToken token);
IAsyncEnumerable<PlexEpisode> GetSeasonEpisodes(
PlexLibrary library,
PlexSeason season,
PlexConnection connection,
@@ -58,4 +68,9 @@ public interface IPlexServerApiClient
string key,
PlexConnection connection,
PlexServerAuthToken token);
Task<Either<BaseError, int>> GetLibraryItemCount(
PlexLibrary library,
PlexConnection connection,
PlexServerAuthToken token);
}
@@ -19,6 +19,8 @@ public interface IMediaServerTelevisionRepository<in TLibrary, TShow, TSeason, T
Task<Unit> SetEtag(TSeason season, string etag);
Task<Unit> SetEtag(TEpisode episode, string etag);
Task<bool> FlagNormal(TLibrary library, TEpisode episode);
Task<bool> FlagNormal(TLibrary library, TSeason season);
Task<bool> FlagNormal(TLibrary library, TShow show);
Task<List<int>> FlagFileNotFoundShows(TLibrary library, List<string> showItemIds);
Task<List<int>> FlagFileNotFoundSeasons(TLibrary library, List<string> seasonItemIds);
Task<List<int>> FlagFileNotFoundEpisodes(TLibrary library, List<string> episodeItemIds);
+2 -1
View File
@@ -76,7 +76,8 @@ public class ChannelGuide
}
}
foreach ((Channel channel, List<PlayoutItem> sorted) in sortedChannelItems.OrderBy(kvp => kvp.Key.Number))
foreach ((Channel channel, List<PlayoutItem> sorted) in sortedChannelItems.OrderBy(
kvp => decimal.Parse(kvp.Key.Number)))
{
var i = 0;
while (i < sorted.Count && sorted[i].FillerKind != FillerKind.None &&
@@ -30,24 +30,21 @@ public class JellyfinCollectionScanner : IJellyfinCollectionScanner
public async Task<Either<BaseError, Unit>> ScanCollections(string address, string apiKey, int mediaSourceId)
{
// get all collections from db (item id, etag)
List<JellyfinCollection> existingCollections = await _jellyfinCollectionRepository.GetCollections();
// get all collections from jellyfin
Either<BaseError, List<JellyfinCollection>> maybeIncomingCollections =
await _jellyfinApiClient.GetCollectionLibraryItems(address, apiKey, mediaSourceId);
foreach (BaseError error in maybeIncomingCollections.LeftToSeq())
try
{
_logger.LogWarning("Failed to get collections from Jellyfin: {Error}", error.ToString());
return error;
}
var incomingItemIds = new List<string>();
// get all collections from db (item id, etag)
List<JellyfinCollection> existingCollections = await _jellyfinCollectionRepository.GetCollections();
foreach (List<JellyfinCollection> incomingCollections in maybeIncomingCollections.RightToSeq())
{
// loop over collections
foreach (JellyfinCollection collection in incomingCollections)
await foreach (JellyfinCollection collection in _jellyfinApiClient.GetCollectionLibraryItems(
address,
apiKey,
mediaSourceId))
{
incomingItemIds.Add(collection.ItemId);
Option<JellyfinCollection> maybeExisting = existingCollections.Find(c => c.ItemId == collection.ItemId);
// skip if unchanged (etag)
@@ -75,12 +72,17 @@ public class JellyfinCollectionScanner : IJellyfinCollectionScanner
}
// remove missing collections (and remove any lingering tags from those collections)
foreach (JellyfinCollection collection in existingCollections
.Filter(e => incomingCollections.All(i => i.ItemId != e.ItemId)))
foreach (JellyfinCollection collection in existingCollections.Filter(
e => !incomingItemIds.Contains(e.ItemId)))
{
await _jellyfinCollectionRepository.RemoveCollection(collection);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to get collections from Jellyfin");
return BaseError.New(ex.Message);
}
return Unit.Default;
}
@@ -91,32 +93,35 @@ public class JellyfinCollectionScanner : IJellyfinCollectionScanner
int mediaSourceId,
JellyfinCollection collection)
{
// get collection items from JF
Either<BaseError, List<MediaItem>> maybeItems =
await _jellyfinApiClient.GetCollectionItems(address, apiKey, mediaSourceId, collection.ItemId);
foreach (BaseError error in maybeItems.LeftToSeq())
try
{
_logger.LogWarning("Failed to get collection items from Jellyfin: {Error}", error.ToString());
return;
// get collection items from JF
IAsyncEnumerable<MediaItem> items = _jellyfinApiClient.GetCollectionItems(
address,
apiKey,
mediaSourceId,
collection.ItemId);
List<int> removedIds = await _jellyfinCollectionRepository.RemoveAllTags(collection);
// sync tags on items
var addedIds = new List<int>();
await foreach (MediaItem item in items)
{
addedIds.Add(await _jellyfinCollectionRepository.AddTag(item, collection));
}
_logger.LogDebug("Jellyfin collection {Name} contains {Count} items", collection.Name, addedIds.Count);
var changedIds = removedIds.Except(addedIds).ToList();
changedIds.AddRange(addedIds.Except(removedIds));
await _searchIndex.RebuildItems(_searchRepository, changedIds);
_searchIndex.Commit();
}
List<int> removedIds = await _jellyfinCollectionRepository.RemoveAllTags(collection);
var jellyfinItems = maybeItems.RightToSeq().Flatten().ToList();
_logger.LogDebug("Jellyfin collection {Name} contains {Count} items", collection.Name, jellyfinItems.Count);
// sync tags on items
var addedIds = new List<int>();
foreach (MediaItem item in jellyfinItems)
catch (Exception ex)
{
addedIds.Add(await _jellyfinCollectionRepository.AddTag(item, collection));
_logger.LogWarning(ex, "Failed to synchronize Jellyfin collection {Name}", collection.Name);
}
var changedIds = removedIds.Except(addedIds).ToList();
changedIds.AddRange(addedIds.Except(removedIds));
await _searchIndex.RebuildItems(_searchRepository, changedIds);
_searchIndex.Commit();
}
}
@@ -0,0 +1,11 @@
namespace ErsatzTV.Core.Jellyfin;
public static class JellyfinItemType
{
public static readonly string Movie = "Movie";
public static readonly string Show = "Series";
public static readonly string Season = "Season";
public static readonly string Episode = "Episode";
public static readonly string Collection = "BoxSet";
public static readonly string CollectionItems = "Movie,Series,Season,Episode";
}
@@ -80,7 +80,18 @@ public class JellyfinMovieLibraryScanner :
protected override string MediaServerEtag(JellyfinMovie movie) => movie.Etag;
protected override Task<Either<BaseError, List<JellyfinMovie>>> GetMovieLibraryItems(
protected override Task<Either<BaseError, int>> CountMovieLibraryItems(
JellyfinConnectionParameters connectionParameters,
JellyfinLibrary library) =>
_jellyfinApiClient.GetLibraryItemCount(
connectionParameters.Address,
connectionParameters.ApiKey,
library,
library.ItemId,
JellyfinItemType.Movie,
true);
protected override IAsyncEnumerable<JellyfinMovie> GetMovieLibraryItems(
JellyfinConnectionParameters connectionParameters,
JellyfinLibrary library) =>
_jellyfinApiClient.GetMovieLibraryItems(
@@ -77,14 +77,21 @@ public class JellyfinTelevisionLibraryScanner : MediaServerTelevisionLibraryScan
cancellationToken);
}
protected override Task<Either<BaseError, List<JellyfinShow>>> GetShowLibraryItems(
protected override Task<Either<BaseError, int>> CountShowLibraryItems(
JellyfinConnectionParameters connectionParameters,
JellyfinLibrary library) =>
_jellyfinApiClient.GetShowLibraryItems(
JellyfinLibrary library)
=> _jellyfinApiClient.GetLibraryItemCount(
connectionParameters.Address,
connectionParameters.ApiKey,
library.MediaSourceId,
library.ItemId);
library,
library.ItemId,
JellyfinItemType.Show,
false);
protected override IAsyncEnumerable<JellyfinShow> GetShowLibraryItems(
JellyfinConnectionParameters connectionParameters,
JellyfinLibrary library) =>
_jellyfinApiClient.GetShowLibraryItems(connectionParameters.Address, connectionParameters.ApiKey, library);
protected override string MediaServerItemId(JellyfinShow show) => show.ItemId;
protected override string MediaServerItemId(JellyfinSeason season) => season.ItemId;
@@ -94,19 +101,44 @@ public class JellyfinTelevisionLibraryScanner : MediaServerTelevisionLibraryScan
protected override string MediaServerEtag(JellyfinSeason season) => season.Etag;
protected override string MediaServerEtag(JellyfinEpisode episode) => episode.Etag;
protected override Task<Either<BaseError, List<JellyfinSeason>>> GetSeasonLibraryItems(
protected override Task<Either<BaseError, int>> CountSeasonLibraryItems(
JellyfinConnectionParameters connectionParameters,
JellyfinLibrary library,
JellyfinShow show) =>
_jellyfinApiClient.GetLibraryItemCount(
connectionParameters.Address,
connectionParameters.ApiKey,
library,
show.ItemId,
JellyfinItemType.Season,
false);
protected override IAsyncEnumerable<JellyfinSeason> GetSeasonLibraryItems(
JellyfinLibrary library,
JellyfinConnectionParameters connectionParameters,
JellyfinShow show) =>
_jellyfinApiClient.GetSeasonLibraryItems(
connectionParameters.Address,
connectionParameters.ApiKey,
library.MediaSourceId,
library,
show.ItemId);
protected override Task<Either<BaseError, List<JellyfinEpisode>>> GetEpisodeLibraryItems(
protected override Task<Either<BaseError, int>> CountEpisodeLibraryItems(
JellyfinConnectionParameters connectionParameters,
JellyfinLibrary library,
JellyfinSeason season) =>
_jellyfinApiClient.GetLibraryItemCount(
connectionParameters.Address,
connectionParameters.ApiKey,
library,
season.ItemId,
JellyfinItemType.Episode,
true);
protected override IAsyncEnumerable<JellyfinEpisode> GetEpisodeLibraryItems(
JellyfinLibrary library,
JellyfinConnectionParameters connectionParameters,
JellyfinShow _,
JellyfinSeason season) =>
_jellyfinApiClient.GetEpisodeLibraryItems(
connectionParameters.Address,
@@ -53,23 +53,29 @@ public abstract class MediaServerMovieLibraryScanner<TConnectionParameters, TLib
{
try
{
Either<BaseError, List<TMovie>> entries = await GetMovieLibraryItems(connectionParameters, library);
foreach (BaseError error in entries.LeftToSeq())
Either<BaseError, int> maybeCount = await CountMovieLibraryItems(connectionParameters, library);
foreach (BaseError error in maybeCount.LeftToSeq())
{
return error;
}
return await ScanLibrary(
movieRepository,
connectionParameters,
library,
getLocalPath,
ffmpegPath,
ffprobePath,
entries.RightToSeq().Flatten().ToList(),
deepScan,
cancellationToken);
foreach (int count in maybeCount.RightToSeq())
{
return await ScanLibrary(
movieRepository,
connectionParameters,
library,
getLocalPath,
ffmpegPath,
ffprobePath,
GetMovieLibraryItems(connectionParameters, library),
count,
deepScan,
cancellationToken);
}
// this won't happen
return Unit.Default;
}
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
{
@@ -88,21 +94,24 @@ public abstract class MediaServerMovieLibraryScanner<TConnectionParameters, TLib
Func<TMovie, string> getLocalPath,
string ffmpegPath,
string ffprobePath,
List<TMovie> movieEntries,
IAsyncEnumerable<TMovie> movieEntries,
int totalMovieCount,
bool deepScan,
CancellationToken cancellationToken)
{
var incomingItemIds = new List<string>();
List<TEtag> existingMovies = await movieRepository.GetExistingMovies(library);
var sortedMovies = movieEntries.OrderBy(m => m.MovieMetadata.Head().SortTitle).ToList();
foreach (TMovie incoming in sortedMovies)
await foreach (TMovie incoming in movieEntries.WithCancellation(cancellationToken))
{
if (cancellationToken.IsCancellationRequested)
{
return new ScanCanceled();
}
decimal percentCompletion = (decimal)sortedMovies.IndexOf(incoming) / sortedMovies.Count;
incomingItemIds.Add(MediaServerItemId(incoming));
decimal percentCompletion = Math.Clamp((decimal)incomingItemIds.Count / totalMovieCount, 0, 1);
await _mediator.Publish(new LibraryScanProgress(library.Id, percentCompletion), cancellationToken);
string localPath = getLocalPath(incoming);
@@ -165,8 +174,7 @@ public abstract class MediaServerMovieLibraryScanner<TConnectionParameters, TLib
}
// trash movies that are no longer present on the media server
var fileNotFoundItemIds = existingMovies.Map(m => m.MediaServerItemId)
.Except(movieEntries.Map(MediaServerItemId)).ToList();
var fileNotFoundItemIds = existingMovies.Map(m => m.MediaServerItemId).Except(incomingItemIds).ToList();
List<int> ids = await movieRepository.FlagFileNotFound(library, fileNotFoundItemIds);
await _searchIndex.RebuildItems(_searchRepository, ids);
@@ -178,7 +186,11 @@ public abstract class MediaServerMovieLibraryScanner<TConnectionParameters, TLib
protected abstract string MediaServerItemId(TMovie movie);
protected abstract string MediaServerEtag(TMovie movie);
protected abstract Task<Either<BaseError, List<TMovie>>> GetMovieLibraryItems(
protected abstract Task<Either<BaseError, int>> CountMovieLibraryItems(
TConnectionParameters connectionParameters,
TLibrary library);
protected abstract IAsyncEnumerable<TMovie> GetMovieLibraryItems(
TConnectionParameters connectionParameters,
TLibrary library);
@@ -212,9 +224,10 @@ public abstract class MediaServerMovieLibraryScanner<TConnectionParameters, TLib
string existingEtag = await maybeExisting.Map(e => e.Etag ?? string.Empty).IfNoneAsync(string.Empty);
MediaItemState existingState = await maybeExisting.Map(e => e.State).IfNoneAsync(MediaItemState.Normal);
if (existingState == MediaItemState.Unavailable && existingEtag == MediaServerEtag(incoming))
if (existingState is MediaItemState.Unavailable or MediaItemState.FileNotFound &&
existingEtag == MediaServerEtag(incoming))
{
// skip scanning unavailable items that are unchanged and still don't exist locally
// skip scanning unavailable/file not found items that are unchanged and still don't exist locally
if (!_localFileSystem.FileExists(localPath))
{
return false;
@@ -56,23 +56,31 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
{
try
{
Either<BaseError, List<TShow>> entries = await GetShowLibraryItems(connectionParameters, library);
foreach (BaseError error in entries.LeftToSeq())
Either<BaseError, int> maybeCount = await CountShowLibraryItems(connectionParameters, library);
foreach (BaseError error in maybeCount.LeftToSeq())
{
return error;
}
return await ScanLibrary(
televisionRepository,
connectionParameters,
library,
getLocalPath,
ffmpegPath,
ffprobePath,
entries.RightToSeq().Flatten().ToList(),
deepScan,
cancellationToken);
foreach (int count in maybeCount.RightToSeq())
{
_logger.LogDebug("Library {Library} contains {Count} shows", library.Name, count);
return await ScanLibrary(
televisionRepository,
connectionParameters,
library,
getLocalPath,
ffmpegPath,
ffprobePath,
GetShowLibraryItems(connectionParameters, library),
count,
deepScan,
cancellationToken);
}
// this won't happen
return Unit.Default;
}
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
{
@@ -84,7 +92,11 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
}
}
protected abstract Task<Either<BaseError, List<TShow>>> GetShowLibraryItems(
protected abstract Task<Either<BaseError, int>> CountShowLibraryItems(
TConnectionParameters connectionParameters,
TLibrary library);
protected abstract IAsyncEnumerable<TShow> GetShowLibraryItems(
TConnectionParameters connectionParameters,
TLibrary library);
@@ -102,21 +114,24 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
Func<TEpisode, string> getLocalPath,
string ffmpegPath,
string ffprobePath,
List<TShow> showEntries,
IAsyncEnumerable<TShow> showEntries,
int totalShowCount,
bool deepScan,
CancellationToken cancellationToken)
{
var incomingItemIds = new List<string>();
List<TEtag> existingShows = await televisionRepository.GetExistingShows(library);
var sortedShows = showEntries.OrderBy(s => s.ShowMetadata.Head().SortTitle).ToList();
foreach (TShow incoming in showEntries)
await foreach (TShow incoming in showEntries.WithCancellation(cancellationToken))
{
if (cancellationToken.IsCancellationRequested)
{
return new ScanCanceled();
}
decimal percentCompletion = (decimal)sortedShows.IndexOf(incoming) / sortedShows.Count;
incomingItemIds.Add(MediaServerItemId(incoming));
decimal percentCompletion = Math.Clamp((decimal)incomingItemIds.Count / totalShowCount, 0, 1);
await _mediator.Publish(new LibraryScanProgress(library.Id, percentCompletion), cancellationToken);
Either<BaseError, MediaItemScanResult<TShow>> maybeShow = await televisionRepository
@@ -138,16 +153,23 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
foreach (MediaItemScanResult<TShow> result in maybeShow.RightToSeq())
{
Either<BaseError, List<TSeason>> entries = await GetSeasonLibraryItems(
library,
Either<BaseError, int> maybeCount = await CountSeasonLibraryItems(
connectionParameters,
library,
result.Item);
foreach (BaseError error in entries.LeftToSeq())
foreach (BaseError error in maybeCount.LeftToSeq())
{
return error;
}
foreach (int count in maybeCount.RightToSeq())
{
_logger.LogDebug(
"Show {Title} contains {Count} seasons",
result.Item.ShowMetadata.Head().Title,
count);
}
Either<BaseError, Unit> scanResult = await ScanSeasons(
televisionRepository,
library,
@@ -156,7 +178,7 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
connectionParameters,
ffmpegPath,
ffprobePath,
entries.RightToSeq().Flatten().ToList(),
GetSeasonLibraryItems(library, connectionParameters, result.Item),
deepScan,
cancellationToken);
@@ -167,6 +189,11 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
await televisionRepository.SetEtag(result.Item, MediaServerEtag(incoming));
if (await televisionRepository.FlagNormal(library, result.Item))
{
result.IsUpdated = true;
}
if (result.IsAdded || result.IsUpdated)
{
await _searchIndex.RebuildItems(_searchRepository, new List<int> { result.Item.Id });
@@ -175,8 +202,7 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
}
// trash shows that are no longer present on the media server
var fileNotFoundItemIds = existingShows.Map(s => s.MediaServerItemId)
.Except(showEntries.Map(MediaServerItemId)).ToList();
var fileNotFoundItemIds = existingShows.Map(s => s.MediaServerItemId).Except(incomingItemIds).ToList();
List<int> ids = await televisionRepository.FlagFileNotFoundShows(library, fileNotFoundItemIds);
await _searchIndex.RebuildItems(_searchRepository, ids);
@@ -185,14 +211,25 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
return Unit.Default;
}
protected abstract Task<Either<BaseError, List<TSeason>>> GetSeasonLibraryItems(
protected abstract Task<Either<BaseError, int>> CountSeasonLibraryItems(
TConnectionParameters connectionParameters,
TLibrary library,
TShow show);
protected abstract IAsyncEnumerable<TSeason> GetSeasonLibraryItems(
TLibrary library,
TConnectionParameters connectionParameters,
TShow show);
protected abstract Task<Either<BaseError, List<TEpisode>>> GetEpisodeLibraryItems(
protected abstract Task<Either<BaseError, int>> CountEpisodeLibraryItems(
TConnectionParameters connectionParameters,
TLibrary library,
TSeason season);
protected abstract IAsyncEnumerable<TEpisode> GetEpisodeLibraryItems(
TLibrary library,
TConnectionParameters connectionParameters,
TShow show,
TSeason season);
protected abstract Task<Option<ShowMetadata>> GetFullMetadata(
@@ -236,14 +273,14 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
TConnectionParameters connectionParameters,
string ffmpegPath,
string ffprobePath,
List<TSeason> seasonEntries,
IAsyncEnumerable<TSeason> seasonEntries,
bool deepScan,
CancellationToken cancellationToken)
{
var incomingItemIds = new List<string>();
List<TEtag> existingSeasons = await televisionRepository.GetExistingSeasons(library, show);
var sortedSeasons = seasonEntries.OrderBy(s => s.SeasonNumber).ToList();
foreach (TSeason incoming in sortedSeasons)
await foreach (TSeason incoming in seasonEntries.WithCancellation(cancellationToken))
{
incoming.ShowId = show.Id;
@@ -252,6 +289,8 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
return new ScanCanceled();
}
incomingItemIds.Add(MediaServerItemId(incoming));
Either<BaseError, MediaItemScanResult<TSeason>> maybeSeason = await televisionRepository
.GetOrAdd(library, incoming)
.BindT(existing => UpdateMetadata(connectionParameters, library, existing, incoming, deepScan));
@@ -272,16 +311,24 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
foreach (MediaItemScanResult<TSeason> result in maybeSeason.RightToSeq())
{
Either<BaseError, List<TEpisode>> entries = await GetEpisodeLibraryItems(
library,
Either<BaseError, int> maybeCount = await CountEpisodeLibraryItems(
connectionParameters,
library,
result.Item);
foreach (BaseError error in entries.LeftToSeq())
foreach (BaseError error in maybeCount.LeftToSeq())
{
return error;
}
foreach (int count in maybeCount.RightToSeq())
{
_logger.LogDebug(
"Show {Title} season {Season} contains {Count} episodes",
show.ShowMetadata.Head().Title,
result.Item.SeasonNumber,
count);
}
Either<BaseError, Unit> scanResult = await ScanEpisodes(
televisionRepository,
library,
@@ -291,7 +338,7 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
connectionParameters,
ffmpegPath,
ffprobePath,
entries.RightToSeq().Flatten().ToList(),
GetEpisodeLibraryItems(library, connectionParameters, show, result.Item),
deepScan,
cancellationToken);
@@ -302,6 +349,11 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
await televisionRepository.SetEtag(result.Item, MediaServerEtag(incoming));
if (await televisionRepository.FlagNormal(library, result.Item))
{
result.IsUpdated = true;
}
result.Item.Show = show;
if (result.IsAdded || result.IsUpdated)
@@ -312,8 +364,7 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
}
// trash seasons that are no longer present on the media server
var fileNotFoundItemIds = existingSeasons.Map(s => s.MediaServerItemId)
.Except(seasonEntries.Map(MediaServerItemId)).ToList();
var fileNotFoundItemIds = existingSeasons.Map(s => s.MediaServerItemId).Except(incomingItemIds).ToList();
List<int> ids = await televisionRepository.FlagFileNotFoundSeasons(library, fileNotFoundItemIds);
await _searchIndex.RebuildItems(_searchRepository, ids);
@@ -329,20 +380,22 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
TConnectionParameters connectionParameters,
string ffmpegPath,
string ffprobePath,
List<TEpisode> episodeEntries,
IAsyncEnumerable<TEpisode> episodeEntries,
bool deepScan,
CancellationToken cancellationToken)
{
var incomingItemIds = new List<string>();
List<TEtag> existingEpisodes = await televisionRepository.GetExistingEpisodes(library, season);
var sortedEpisodes = episodeEntries.OrderBy(s => s.EpisodeMetadata.Head().EpisodeNumber).ToList();
foreach (TEpisode incoming in sortedEpisodes)
await foreach (TEpisode incoming in episodeEntries.WithCancellation(cancellationToken))
{
if (cancellationToken.IsCancellationRequested)
{
return new ScanCanceled();
}
incomingItemIds.Add(MediaServerItemId(incoming));
string localPath = getLocalPath(incoming);
if (await ShouldScanItem(
televisionRepository,
@@ -414,8 +467,7 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
}
// trash episodes that are no longer present on the media server
var fileNotFoundItemIds = existingEpisodes.Map(m => m.MediaServerItemId)
.Except(episodeEntries.Map(MediaServerItemId)).ToList();
var fileNotFoundItemIds = existingEpisodes.Map(m => m.MediaServerItemId).Except(incomingItemIds).ToList();
List<int> ids = await televisionRepository.FlagFileNotFoundEpisodes(library, fileNotFoundItemIds);
await _searchIndex.RebuildItems(_searchRepository, ids);
@@ -442,9 +494,10 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
string existingEtag = await maybeExisting.Map(e => e.Etag ?? string.Empty).IfNoneAsync(string.Empty);
MediaItemState existingState = await maybeExisting.Map(e => e.State).IfNoneAsync(MediaItemState.Normal);
if (existingState == MediaItemState.Unavailable && existingEtag == MediaServerEtag(incoming))
if (existingState is MediaItemState.Unavailable or MediaItemState.FileNotFound &&
existingEtag == MediaServerEtag(incoming))
{
// skip scanning unavailable items that are unchanged and still don't exist locally
// skip scanning unavailable/file not found items that are unchanged and still don't exist locally
if (!_localFileSystem.FileExists(localPath))
{
return false;
@@ -89,7 +89,15 @@ public class PlexMovieLibraryScanner :
protected override string MediaServerEtag(PlexMovie movie) => movie.Etag;
protected override Task<Either<BaseError, List<PlexMovie>>> GetMovieLibraryItems(
protected override Task<Either<BaseError, int>> CountMovieLibraryItems(
PlexConnectionParameters connectionParameters,
PlexLibrary library)
=> _plexServerApiClient.GetLibraryItemCount(
library,
connectionParameters.Connection,
connectionParameters.Token);
protected override IAsyncEnumerable<PlexMovie> GetMovieLibraryItems(
PlexConnectionParameters connectionParameters,
PlexLibrary library) =>
_plexServerApiClient.GetMovieLibraryContents(
@@ -137,7 +137,15 @@ public class PlexTelevisionLibraryScanner :
// }
// }
protected override Task<Either<BaseError, List<PlexShow>>> GetShowLibraryItems(
protected override Task<Either<BaseError, int>> CountShowLibraryItems(
PlexConnectionParameters connectionParameters,
PlexLibrary library) =>
_plexServerApiClient.GetLibraryItemCount(
library,
connectionParameters.Connection,
connectionParameters.Token);
protected override IAsyncEnumerable<PlexShow> GetShowLibraryItems(
PlexConnectionParameters connectionParameters,
PlexLibrary library) =>
_plexServerApiClient.GetShowLibraryContents(
@@ -145,7 +153,16 @@ public class PlexTelevisionLibraryScanner :
connectionParameters.Connection,
connectionParameters.Token);
protected override Task<Either<BaseError, List<PlexSeason>>> GetSeasonLibraryItems(
protected override Task<Either<BaseError, int>> CountSeasonLibraryItems(
PlexConnectionParameters connectionParameters,
PlexLibrary library,
PlexShow show) =>
_plexServerApiClient.CountShowSeasons(
show,
connectionParameters.Connection,
connectionParameters.Token);
protected override IAsyncEnumerable<PlexSeason> GetSeasonLibraryItems(
PlexLibrary library,
PlexConnectionParameters connectionParameters,
PlexShow show) =>
@@ -155,9 +172,19 @@ public class PlexTelevisionLibraryScanner :
connectionParameters.Connection,
connectionParameters.Token);
protected override Task<Either<BaseError, List<PlexEpisode>>> GetEpisodeLibraryItems(
protected override Task<Either<BaseError, int>> CountEpisodeLibraryItems(
PlexConnectionParameters connectionParameters,
PlexLibrary library,
PlexSeason season) =>
_plexServerApiClient.CountSeasonEpisodes(
season,
connectionParameters.Connection,
connectionParameters.Token);
protected override IAsyncEnumerable<PlexEpisode> GetEpisodeLibraryItems(
PlexLibrary library,
PlexConnectionParameters connectionParameters,
PlexShow _,
PlexSeason season) =>
_plexServerApiClient.GetSeasonEpisodes(
library,
+9 -3
View File
@@ -2,12 +2,18 @@
public enum PlayoutBuildMode
{
// this continues building playout into the future
/// <summary>
/// Continue building the playout into the future, without changing any existing playout items
/// </summary>
Continue = 1,
// this rebuilds a playout but will maintain collection progress
/// <summary>
/// Rebuild the playout while attempting to maintain collection progress
/// </summary>
Refresh = 2,
// this rebuilds a playout and clears all state
/// <summary>
/// Rebuild the playout from scratch (clearing all state)
/// </summary>
Reset = 3
}
@@ -676,7 +676,7 @@ public abstract class PlayoutModeSchedulerBase<T> : IPlayoutModeScheduler<T> whe
}
else
{
if (itemDuration >= duration * 2)
if (itemDuration >= duration * 1.5)
{
_logger.LogWarning(
"Filler item is too long {FillerDuration} to fill {GapDuration}; skipping to next filler item",
@@ -688,6 +688,14 @@ public abstract class PlayoutModeSchedulerBase<T> : IPlayoutModeScheduler<T> whe
}
else
{
if (itemDuration > duration)
{
_logger.LogWarning(
"Filler item is too long {FillerDuration} to fill {GapDuration}; aborting filler block",
itemDuration,
duration);
}
// set to zero so it breaks out of the while loop
remainingToFill = TimeSpan.Zero;
}
@@ -8,7 +8,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.6.0" />
<PackageReference Include="FluentAssertions" Version="6.7.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.2.0" />
<PackageReference Include="Moq" Version="4.18.1" />
<PackageReference Include="NUnit" Version="3.13.3" />
@@ -0,0 +1,47 @@
using System.Collections.Generic;
using ErsatzTV.FFmpeg.Filter;
using ErsatzTV.FFmpeg.State;
using FluentAssertions;
using LanguageExt;
using NUnit.Framework;
namespace ErsatzTV.FFmpeg.Tests.Filter;
[TestFixture]
public class WatermarkOpacityFilterTests
{
[Test]
// this needs to be a culture where ',' is a decimal separator
[SetCulture("it-IT")]
public void Should_Return_Filter_With_Period_Decimal_Unlike_Local_Culture()
{
var filter = new WatermarkOpacityFilter(
new WatermarkState(
Option<List<WatermarkFadePoint>>.None,
WatermarkLocation.BottomRight,
WatermarkSize.ActualSize,
50,
50,
50,
75));
filter.Filter.Should().Be("colorchannelmixer=aa=0.75");
}
[Test]
[SetCulture("en-US")]
public void Should_Return_Filter_With_Period_Decimal()
{
var filter = new WatermarkOpacityFilter(
new WatermarkState(
Option<List<WatermarkFadePoint>>.None,
WatermarkLocation.BottomRight,
WatermarkSize.ActualSize,
50,
50,
50,
75));
filter.Filter.Should().Be("colorchannelmixer=aa=0.75");
}
}
+25 -4
View File
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using ErsatzTV.FFmpeg.Capabilities;
using ErsatzTV.FFmpeg.Encoder;
using ErsatzTV.FFmpeg.Format;
using ErsatzTV.FFmpeg.OutputFormat;
@@ -54,6 +55,7 @@ public class PipelineGeneratorTests
var ffmpegState = new FFmpegState(
false,
HardwareAccelerationMode.None,
HardwareAccelerationMode.None,
Option<string>.None,
Option<string>.None,
TimeSpan.FromSeconds(1),
@@ -68,14 +70,23 @@ public class PipelineGeneratorTests
0,
Option<int>.None);
var builder = new PipelineBuilder(videoInputFile, audioInputFile, None, None, "", "", _logger);
var builder = new PipelineBuilder(
new DefaultHardwareCapabilities(),
videoInputFile,
audioInputFile,
None,
None,
"",
"",
_logger);
FFmpegPipeline result = builder.Build(ffmpegState, desiredState);
result.PipelineSteps.Should().HaveCountGreaterThan(0);
result.PipelineSteps.Should().Contain(ps => ps is EncoderLibx265);
string command = PrintCommand(videoInputFile, audioInputFile, None, None, result);
command.Should().Be("-threads 1 -nostdin -hide_banner -nostats -loglevel error -fflags +genpts+discardcorrupt+igndts -ss 00:00:01 -c:v h264 -re -i /tmp/whatever.mkv -map 0:1 -map 0:0 -muxdelay 0 -muxpreload 0 -movflags +faststart -flags cgop -sc_threshold 0 -video_track_timescale 90000 -b:v 2000k -maxrate:v 2000k -bufsize:v 4000k -c:a aac -ac 2 -b:a 320k -maxrate:a 320k -bufsize:a 640k -ar 48k -c:v libx265 -tag:v hvc1 -x265-params log-level=error -f mpegts -mpegts_flags +initial_discontinuity pipe:1");
command.Should().Be(
"-threads 1 -nostdin -hide_banner -nostats -loglevel error -fflags +genpts+discardcorrupt+igndts -ss 00:00:01 -c:v h264 -re -i /tmp/whatever.mkv -map 0:1 -map 0:0 -muxdelay 0 -muxpreload 0 -movflags +faststart -flags cgop -sc_threshold 0 -video_track_timescale 90000 -b:v 2000k -maxrate:v 2000k -bufsize:v 4000k -c:a aac -ac 2 -b:a 320k -maxrate:a 320k -bufsize:a 640k -ar 48k -c:v libx265 -tag:v hvc1 -x265-params log-level=error -f mpegts -mpegts_flags +initial_discontinuity pipe:1");
}
[Test]
@@ -84,7 +95,7 @@ public class PipelineGeneratorTests
var resolution = new FrameSize(1920, 1080);
var concatInputFile = new ConcatInputFile("http://localhost:8080/ffmpeg/concat/1", resolution);
var builder = new PipelineBuilder(None, None, None, None, "", "", _logger);
var builder = new PipelineBuilder(new DefaultHardwareCapabilities(), None, None, None, None, "", "", _logger);
FFmpegPipeline result = builder.Concat(concatInputFile, FFmpegState.Concat(false, "Some Channel"));
result.PipelineSteps.Should().HaveCountGreaterThan(0);
@@ -131,6 +142,7 @@ public class PipelineGeneratorTests
var ffmpegState = new FFmpegState(
false,
HardwareAccelerationMode.None,
HardwareAccelerationMode.None,
Option<string>.None,
Option<string>.None,
Option<TimeSpan>.None,
@@ -145,7 +157,15 @@ public class PipelineGeneratorTests
0,
Option<int>.None);
var builder = new PipelineBuilder(videoInputFile, audioInputFile, None, None, "", "", _logger);
var builder = new PipelineBuilder(
new DefaultHardwareCapabilities(),
videoInputFile,
audioInputFile,
None,
None,
"",
"",
_logger);
FFmpegPipeline result = builder.Build(ffmpegState, desiredState);
result.PipelineSteps.Should().HaveCountGreaterThan(0);
@@ -171,6 +191,7 @@ public class PipelineGeneratorTests
});
var pipelineBuilder = new PipelineBuilder(
new DefaultHardwareCapabilities(),
videoInputFile,
Option<AudioInputFile>.None,
Option<WatermarkInputFile>.None,
@@ -0,0 +1,9 @@
using ErsatzTV.FFmpeg.Format;
namespace ErsatzTV.FFmpeg.Capabilities;
public class DefaultHardwareCapabilities : IHardwareCapabilities
{
public bool CanDecode(string videoFormat, Option<IPixelFormat> maybePixelFormat) => true;
public bool CanEncode(string videoFormat, Option<IPixelFormat> maybePixelFormat) => true;
}
@@ -0,0 +1,76 @@
using System.Text;
using System.Text.RegularExpressions;
using CliWrap;
using CliWrap.Buffered;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.FFmpeg.Capabilities;
public class HardwareCapabilitiesFactory : IHardwareCapabilitiesFactory
{
private const string CacheKey = "ffmpeg.hardware.nvidia.architecture";
private readonly ILogger<HardwareCapabilitiesFactory> _logger;
private readonly IMemoryCache _memoryCache;
public HardwareCapabilitiesFactory(IMemoryCache memoryCache, ILogger<HardwareCapabilitiesFactory> logger)
{
_memoryCache = memoryCache;
_logger = logger;
}
public async Task<IHardwareCapabilities> GetHardwareCapabilities(
string ffmpegPath,
HardwareAccelerationMode hardwareAccelerationMode) =>
hardwareAccelerationMode switch
{
HardwareAccelerationMode.Nvenc => await GetNvidiaCapabilities(ffmpegPath),
_ => new DefaultHardwareCapabilities()
};
private async Task<IHardwareCapabilities> GetNvidiaCapabilities(string ffmpegPath)
{
if (_memoryCache.TryGetValue(CacheKey, out int cachedArchitecture))
{
return new NvidiaHardwareCapabilities(cachedArchitecture);
}
string[] arguments =
{
"-f", "lavfi",
"-i", "nullsrc",
"-c:v", "h264_nvenc",
"-gpu", "list",
"-f", "null", "-"
};
BufferedCommandResult result = await Cli.Wrap(ffmpegPath)
.WithArguments(arguments)
.WithValidation(CommandResultValidation.None)
.ExecuteBufferedAsync(Encoding.UTF8);
string output = string.IsNullOrWhiteSpace(result.StandardOutput)
? result.StandardError
: result.StandardOutput;
Option<string> maybeLine = Optional(output.Split("\n").FirstOrDefault(x => x.Contains("GPU")));
foreach (string line in maybeLine)
{
const string PATTERN = @"SM\s+(\d\.\d)";
Match match = Regex.Match(line, PATTERN);
if (match.Success && int.TryParse(match.Groups[1].Value.Replace(".", string.Empty), out int architecture))
{
_logger.LogInformation("Detected NVIDIA GPU architecture SM {Architecture}", architecture);
_memoryCache.Set(CacheKey, architecture);
return new NvidiaHardwareCapabilities(architecture);
}
}
_logger.LogWarning(
"Error detecting NVIDIA GPU capabilities; some hardware accelerated features will be unavailable: {ExitCode}",
result.ExitCode);
return new NoHardwareCapabilities();
}
}
@@ -0,0 +1,9 @@
using ErsatzTV.FFmpeg.Format;
namespace ErsatzTV.FFmpeg.Capabilities;
public interface IHardwareCapabilities
{
public bool CanDecode(string videoFormat, Option<IPixelFormat> maybePixelFormat);
public bool CanEncode(string videoFormat, Option<IPixelFormat> maybePixelFormat);
}
@@ -0,0 +1,8 @@
namespace ErsatzTV.FFmpeg.Capabilities;
public interface IHardwareCapabilitiesFactory
{
Task<IHardwareCapabilities> GetHardwareCapabilities(
string ffmpegPath,
HardwareAccelerationMode hardwareAccelerationMode);
}
@@ -0,0 +1,9 @@
using ErsatzTV.FFmpeg.Format;
namespace ErsatzTV.FFmpeg.Capabilities;
public class NoHardwareCapabilities : IHardwareCapabilities
{
public bool CanDecode(string videoFormat, Option<IPixelFormat> maybePixelFormat) => false;
public bool CanEncode(string videoFormat, Option<IPixelFormat> maybePixelFormat) => false;
}
@@ -0,0 +1,49 @@
using System.Numerics;
using ErsatzTV.FFmpeg.Format;
namespace ErsatzTV.FFmpeg.Capabilities;
public class NvidiaHardwareCapabilities : IHardwareCapabilities
{
private readonly int _architecture;
public NvidiaHardwareCapabilities(int architecture) => _architecture = architecture;
public bool CanDecode(string videoFormat, Option<IPixelFormat> maybePixelFormat)
{
int bitDepth = maybePixelFormat.Map(pf => pf.BitDepth).IfNone(8);
return videoFormat switch
{
// second gen maxwell is required to decode hevc
VideoFormat.Hevc => _architecture >= 52,
// pascal is required to decode vp9 10-bit
VideoFormat.Vp9 when bitDepth == 10 => _architecture >= 60,
// second gen maxwell is required to decode vp9
VideoFormat.Vp9 => _architecture >= 52,
_ => true
};
}
public bool CanEncode(string videoFormat, Option<IPixelFormat> maybePixelFormat)
{
int bitDepth = maybePixelFormat.Map(pf => pf.BitDepth).IfNone(8);
return videoFormat switch
{
// pascal is required to encode 10-bit hevc
VideoFormat.Hevc when bitDepth == 10 => _architecture >= 60,
// second gen maxwell is required to encode hevc
VideoFormat.Hevc => _architecture >= 52,
// nvidia cannot encode 10-bit h264
VideoFormat.H264 when bitDepth == 10 => false,
_ => true
};
}
}
+16 -7
View File
@@ -1,4 +1,5 @@
using ErsatzTV.FFmpeg.Decoder.Cuvid;
using ErsatzTV.FFmpeg.Capabilities;
using ErsatzTV.FFmpeg.Decoder.Cuvid;
using ErsatzTV.FFmpeg.Decoder.Qsv;
using ErsatzTV.FFmpeg.Format;
using Microsoft.Extensions.Logging;
@@ -8,16 +9,19 @@ namespace ErsatzTV.FFmpeg.Decoder;
public static class AvailableDecoders
{
public static Option<IDecoder> ForVideoFormat(
IHardwareCapabilities hardwareCapabilities,
FFmpegState ffmpegState,
FrameState currentState,
FrameState desiredState,
Option<WatermarkInputFile> watermarkInputFile,
Option<SubtitleInputFile> subtitleInputFile,
ILogger logger) =>
(ffmpegState.HardwareAccelerationMode, currentState.VideoFormat,
(ffmpegState.DecoderHardwareAccelerationMode, currentState.VideoFormat,
currentState.PixelFormat.Match(pf => pf.Name, () => string.Empty)) switch
{
(HardwareAccelerationMode.Nvenc, VideoFormat.Hevc, _) => new DecoderHevcCuvid(),
(HardwareAccelerationMode.Nvenc, VideoFormat.Hevc, _)
when hardwareCapabilities.CanDecode(VideoFormat.Hevc, currentState.PixelFormat) =>
new DecoderHevcCuvid(ffmpegState),
// nvenc doesn't support hardware decoding of 10-bit content
(HardwareAccelerationMode.Nvenc, VideoFormat.H264, PixelFormat.YUV420P10LE or PixelFormat.YUV444P10LE)
@@ -27,12 +31,17 @@ public static class AvailableDecoders
(HardwareAccelerationMode.Nvenc, VideoFormat.Mpeg2Video, _) when desiredState.Deinterlaced =>
new DecoderMpeg2Video(),
(HardwareAccelerationMode.Nvenc, VideoFormat.H264, _) => new DecoderH264Cuvid(),
(HardwareAccelerationMode.Nvenc, VideoFormat.H264, _)
when hardwareCapabilities.CanDecode(VideoFormat.H264, currentState.PixelFormat) =>
new DecoderH264Cuvid(ffmpegState),
(HardwareAccelerationMode.Nvenc, VideoFormat.Mpeg2Video, _) => new DecoderMpeg2Cuvid(
ffmpegState,
desiredState.Deinterlaced),
(HardwareAccelerationMode.Nvenc, VideoFormat.Vc1, _) => new DecoderVc1Cuvid(),
(HardwareAccelerationMode.Nvenc, VideoFormat.Vp9, _) => new DecoderVp9Cuvid(),
(HardwareAccelerationMode.Nvenc, VideoFormat.Mpeg4, _) => new DecoderMpeg4Cuvid(),
(HardwareAccelerationMode.Nvenc, VideoFormat.Vc1, _) => new DecoderVc1Cuvid(ffmpegState),
(HardwareAccelerationMode.Nvenc, VideoFormat.Vp9, _)
when hardwareCapabilities.CanDecode(VideoFormat.Vp9, currentState.PixelFormat) =>
new DecoderVp9Cuvid(ffmpegState),
(HardwareAccelerationMode.Nvenc, VideoFormat.Mpeg4, _) => new DecoderMpeg4Cuvid(ffmpegState),
// hevc_qsv decoder sometimes causes green lines with 10-bit content
(HardwareAccelerationMode.Qsv, VideoFormat.Hevc, PixelFormat.YUV420P10LE) => new DecoderHevc(),
@@ -2,16 +2,31 @@
public class DecoderH264Cuvid : DecoderBase
{
private readonly FFmpegState _ffmpegState;
public DecoderH264Cuvid(FFmpegState ffmpegState) => _ffmpegState = ffmpegState;
public override string Name => "h264_cuvid";
protected override FrameDataLocation OutputFrameDataLocation => FrameDataLocation.Hardware;
protected override FrameDataLocation OutputFrameDataLocation =>
_ffmpegState.EncoderHardwareAccelerationMode == HardwareAccelerationMode.None
? FrameDataLocation.Software
: FrameDataLocation.Hardware;
public override IList<string> InputOptions(InputFile inputFile)
{
IList<string> result = base.InputOptions(inputFile);
result.Add("-hwaccel_output_format");
result.Add("cuda");
if (_ffmpegState.EncoderHardwareAccelerationMode != HardwareAccelerationMode.None)
{
result.Add("-hwaccel_output_format");
result.Add("cuda");
}
else
{
result.Add("-hwaccel_output_format");
result.Add(InputBitDepth(inputFile) == 10 ? "p010le" : "nv12");
}
return result;
}
@@ -2,16 +2,31 @@
public class DecoderHevcCuvid : DecoderBase
{
private readonly FFmpegState _ffmpegState;
public DecoderHevcCuvid(FFmpegState ffmpegState) => _ffmpegState = ffmpegState;
public override string Name => "hevc_cuvid";
protected override FrameDataLocation OutputFrameDataLocation => FrameDataLocation.Hardware;
protected override FrameDataLocation OutputFrameDataLocation =>
_ffmpegState.EncoderHardwareAccelerationMode == HardwareAccelerationMode.None
? FrameDataLocation.Software
: FrameDataLocation.Hardware;
public override IList<string> InputOptions(InputFile inputFile)
{
IList<string> result = base.InputOptions(inputFile);
result.Add("-hwaccel_output_format");
result.Add("cuda");
if (_ffmpegState.EncoderHardwareAccelerationMode != HardwareAccelerationMode.None)
{
result.Add("-hwaccel_output_format");
result.Add("cuda");
}
else
{
result.Add("-hwaccel_output_format");
result.Add(InputBitDepth(inputFile) == 10 ? "p010le" : "nv12");
}
return result;
}
@@ -3,20 +3,35 @@
public class DecoderMpeg2Cuvid : DecoderBase
{
private readonly bool _contentIsInterlaced;
private readonly FFmpegState _ffmpegState;
public DecoderMpeg2Cuvid(bool contentIsInterlaced) => _contentIsInterlaced = contentIsInterlaced;
public DecoderMpeg2Cuvid(FFmpegState ffmpegState, bool contentIsInterlaced)
{
_ffmpegState = ffmpegState;
_contentIsInterlaced = contentIsInterlaced;
}
public override string Name => "mpeg2_cuvid";
protected override FrameDataLocation OutputFrameDataLocation =>
_contentIsInterlaced ? FrameDataLocation.Software : FrameDataLocation.Hardware;
_contentIsInterlaced || _ffmpegState.EncoderHardwareAccelerationMode == HardwareAccelerationMode.None
? FrameDataLocation.Software
: FrameDataLocation.Hardware;
public override IList<string> InputOptions(InputFile inputFile)
{
IList<string> result = base.InputOptions(inputFile);
result.Add("-hwaccel_output_format");
result.Add("cuda");
if (_ffmpegState.EncoderHardwareAccelerationMode != HardwareAccelerationMode.None)
{
result.Add("-hwaccel_output_format");
result.Add("cuda");
}
else
{
result.Add("-hwaccel_output_format");
result.Add(InputBitDepth(inputFile) == 10 ? "p010le" : "nv12");
}
return result;
}
@@ -2,16 +2,31 @@
public class DecoderMpeg4Cuvid : DecoderBase
{
private readonly FFmpegState _ffmpegState;
public DecoderMpeg4Cuvid(FFmpegState ffmpegState) => _ffmpegState = ffmpegState;
public override string Name => "mpeg4_cuvid";
protected override FrameDataLocation OutputFrameDataLocation => FrameDataLocation.Hardware;
protected override FrameDataLocation OutputFrameDataLocation =>
_ffmpegState.EncoderHardwareAccelerationMode == HardwareAccelerationMode.None
? FrameDataLocation.Software
: FrameDataLocation.Hardware;
public override IList<string> InputOptions(InputFile inputFile)
{
IList<string> result = base.InputOptions(inputFile);
result.Add("-hwaccel_output_format");
result.Add("cuda");
if (_ffmpegState.EncoderHardwareAccelerationMode != HardwareAccelerationMode.None)
{
result.Add("-hwaccel_output_format");
result.Add("cuda");
}
else
{
result.Add("-hwaccel_output_format");
result.Add(InputBitDepth(inputFile) == 10 ? "p010le" : "nv12");
}
return result;
}
@@ -2,16 +2,23 @@
public class DecoderVc1Cuvid : DecoderBase
{
private readonly FFmpegState _ffmpegState;
public DecoderVc1Cuvid(FFmpegState ffmpegState) => _ffmpegState = ffmpegState;
public override string Name => "vc1_cuvid";
protected override FrameDataLocation OutputFrameDataLocation => FrameDataLocation.Hardware;
protected override FrameDataLocation OutputFrameDataLocation =>
_ffmpegState.EncoderHardwareAccelerationMode == HardwareAccelerationMode.None
? FrameDataLocation.Software
: FrameDataLocation.Hardware;
public override IList<string> InputOptions(InputFile inputFile)
{
IList<string> result = base.InputOptions(inputFile);
result.Add("-hwaccel_output_format");
result.Add("cuda");
result.Add(_ffmpegState.EncoderHardwareAccelerationMode != HardwareAccelerationMode.None ? "cuda" : "nv12");
return result;
}
@@ -2,16 +2,23 @@
public class DecoderVp9Cuvid : DecoderBase
{
private readonly FFmpegState _ffmpegState;
public DecoderVp9Cuvid(FFmpegState ffmpegState) => _ffmpegState = ffmpegState;
public override string Name => "vp9_cuvid";
protected override FrameDataLocation OutputFrameDataLocation => FrameDataLocation.Hardware;
protected override FrameDataLocation OutputFrameDataLocation =>
_ffmpegState.EncoderHardwareAccelerationMode == HardwareAccelerationMode.None
? FrameDataLocation.Software
: FrameDataLocation.Hardware;
public override IList<string> InputOptions(InputFile inputFile)
{
IList<string> result = base.InputOptions(inputFile);
result.Add("-hwaccel_output_format");
result.Add("cuda");
result.Add(_ffmpegState.EncoderHardwareAccelerationMode != HardwareAccelerationMode.None ? "cuda" : "nv12");
return result;
}
+19
View File
@@ -1,4 +1,5 @@
using ErsatzTV.FFmpeg.Environment;
using ErsatzTV.FFmpeg.Format;
namespace ErsatzTV.FFmpeg.Decoder;
@@ -20,4 +21,22 @@ public abstract class DecoderBase : IDecoder
public bool AppliesTo(VideoInputFile videoInputFile) => true;
public bool AppliesTo(ConcatInputFile concatInputFile) => false;
protected int InputBitDepth(InputFile inputFile)
{
var bitDepth = 8;
if (inputFile is VideoInputFile videoInputFile)
{
foreach (VideoStream videoStream in videoInputFile.VideoStreams.HeadOrNone())
{
foreach (IPixelFormat pixelFormat in videoStream.PixelFormat)
{
bitDepth = pixelFormat.BitDepth;
}
}
}
return bitDepth;
}
}
+53 -29
View File
@@ -1,4 +1,5 @@
using ErsatzTV.FFmpeg.Encoder.Nvenc;
using ErsatzTV.FFmpeg.Capabilities;
using ErsatzTV.FFmpeg.Encoder.Nvenc;
using ErsatzTV.FFmpeg.Encoder.Qsv;
using ErsatzTV.FFmpeg.Encoder.Vaapi;
using ErsatzTV.FFmpeg.Encoder.VideoToolbox;
@@ -11,45 +12,68 @@ namespace ErsatzTV.FFmpeg.Encoder;
public static class AvailableEncoders
{
public static Option<IEncoder> ForVideoFormat(
IHardwareCapabilities hardwareCapabilities,
FFmpegState ffmpegState,
FrameState currentState,
FrameState desiredState,
Option<WatermarkInputFile> maybeWatermarkInputFile,
Option<SubtitleInputFile> maybeSubtitleInputFile,
ILogger logger) =>
(ffmpegState.HardwareAccelerationMode, desiredState.VideoFormat) switch
(ffmpegState.EncoderHardwareAccelerationMode, desiredState.VideoFormat) switch
{
(HardwareAccelerationMode.Nvenc, VideoFormat.Hevc) => new EncoderHevcNvenc(
currentState,
maybeWatermarkInputFile,
maybeSubtitleInputFile),
(HardwareAccelerationMode.Nvenc, VideoFormat.H264) => new EncoderH264Nvenc(
currentState,
maybeWatermarkInputFile,
maybeSubtitleInputFile),
(HardwareAccelerationMode.Nvenc, VideoFormat.Hevc) when hardwareCapabilities.CanEncode(
VideoFormat.Hevc,
desiredState.PixelFormat) =>
new EncoderHevcNvenc(
currentState,
maybeWatermarkInputFile,
maybeSubtitleInputFile),
(HardwareAccelerationMode.Nvenc, VideoFormat.H264) when hardwareCapabilities.CanEncode(
VideoFormat.H264,
desiredState.PixelFormat) =>
new EncoderH264Nvenc(
currentState,
maybeWatermarkInputFile,
maybeSubtitleInputFile),
(HardwareAccelerationMode.Qsv, VideoFormat.Hevc) => new EncoderHevcQsv(
currentState,
maybeWatermarkInputFile,
maybeSubtitleInputFile),
(HardwareAccelerationMode.Qsv, VideoFormat.H264) => new EncoderH264Qsv(
currentState,
maybeWatermarkInputFile,
maybeSubtitleInputFile),
(HardwareAccelerationMode.Qsv, VideoFormat.Hevc) when hardwareCapabilities.CanEncode(
VideoFormat.Hevc,
desiredState.PixelFormat) =>
new EncoderHevcQsv(
currentState,
maybeWatermarkInputFile,
maybeSubtitleInputFile),
(HardwareAccelerationMode.Qsv, VideoFormat.H264) when hardwareCapabilities.CanEncode(
VideoFormat.H264,
desiredState.PixelFormat) =>
new EncoderH264Qsv(
currentState,
maybeWatermarkInputFile,
maybeSubtitleInputFile),
(HardwareAccelerationMode.Vaapi, VideoFormat.Hevc) => new EncoderHevcVaapi(
currentState,
maybeWatermarkInputFile,
maybeSubtitleInputFile),
(HardwareAccelerationMode.Vaapi, VideoFormat.H264) => new EncoderH264Vaapi(
currentState,
maybeWatermarkInputFile,
maybeSubtitleInputFile),
(HardwareAccelerationMode.Vaapi, VideoFormat.Hevc) when hardwareCapabilities.CanEncode(
VideoFormat.Hevc,
desiredState.PixelFormat) =>
new EncoderHevcVaapi(
currentState,
maybeWatermarkInputFile,
maybeSubtitleInputFile),
(HardwareAccelerationMode.Vaapi, VideoFormat.H264) when hardwareCapabilities.CanEncode(
VideoFormat.H264,
desiredState.PixelFormat) =>
new EncoderH264Vaapi(
currentState,
maybeWatermarkInputFile,
maybeSubtitleInputFile),
(HardwareAccelerationMode.VideoToolbox, VideoFormat.Hevc) => new EncoderHevcVideoToolbox(),
(HardwareAccelerationMode.VideoToolbox, VideoFormat.H264) => new EncoderH264VideoToolbox(),
(HardwareAccelerationMode.VideoToolbox, VideoFormat.Hevc) when hardwareCapabilities.CanEncode(
VideoFormat.Hevc,
desiredState.PixelFormat) => new EncoderHevcVideoToolbox(),
(HardwareAccelerationMode.VideoToolbox, VideoFormat.H264) when hardwareCapabilities.CanEncode(
VideoFormat.H264,
desiredState.PixelFormat) => new EncoderH264VideoToolbox(),
(_, VideoFormat.Hevc) => new EncoderLibx265(),
(_, VideoFormat.Hevc) => new EncoderLibx265(currentState),
(_, VideoFormat.H264) => new EncoderLibx264(),
(_, VideoFormat.Mpeg2Video) => new EncoderMpeg2Video(),
+8 -1
View File
@@ -1,9 +1,16 @@
using ErsatzTV.FFmpeg.Format;
using ErsatzTV.FFmpeg.Filter;
using ErsatzTV.FFmpeg.Format;
namespace ErsatzTV.FFmpeg.Encoder;
public class EncoderLibx265 : EncoderBase
{
private readonly FrameState _currentState;
public EncoderLibx265(FrameState currentState) => _currentState = currentState;
public override string Filter => new HardwareDownloadFilter(_currentState).Filter;
// TODO: is tag:v needed for mpegts?
public override IList<string> OutputOptions => new List<string>
{ "-c:v", Name, "-tag:v", "hvc1", "-x265-params", "log-level=error" };
@@ -20,6 +20,7 @@ public class EncoderH264Qsv : EncoderBase
public override string Name => "h264_qsv";
public override StreamKind Kind => StreamKind.Video;
public override IList<string> OutputOptions => new[] { "-c:v", "h264_qsv", "-low_power", "0" };
// need to upload if we're still in software and a watermark is used
public override string Filter
@@ -36,11 +37,11 @@ public class EncoderH264Qsv : EncoderBase
// pixel format should already be converted to a supported format by QsvHardwareAccelerationOption
foreach (IPixelFormat pixelFormat in _currentState.PixelFormat)
{
return $"format={pixelFormat.FFmpegName},hwupload=extra_hw_frames=128";
return $"format={pixelFormat.FFmpegName},hwupload=extra_hw_frames=64";
}
// default to nv12
return "format=nv12,hwupload=extra_hw_frames=128";
return "format=nv12,hwupload=extra_hw_frames=64";
}
}
@@ -20,6 +20,7 @@ public class EncoderHevcQsv : EncoderBase
public override string Name => "hevc_qsv";
public override StreamKind Kind => StreamKind.Video;
public override IList<string> OutputOptions => new[] { "-c:v", "hevc_qsv", "-low_power", "0" };
// need to upload if we're still in software and a watermark is used
public override string Filter
@@ -36,11 +37,11 @@ public class EncoderHevcQsv : EncoderBase
// pixel format should already be converted to a supported format by QsvHardwareAccelerationOption
foreach (IPixelFormat pixelFormat in _currentState.PixelFormat)
{
return $"format={pixelFormat.FFmpegName},hwupload=extra_hw_frames=128";
return $"format={pixelFormat.FFmpegName},hwupload=extra_hw_frames=64";
}
// default to nv12
return "format=nv12,hwupload=extra_hw_frames=128";
return "format=nv12,hwupload=extra_hw_frames=64";
}
}
+2 -1
View File
@@ -8,7 +8,8 @@
<ItemGroup>
<PackageReference Include="CliWrap" Version="3.4.4" />
<PackageReference Include="LanguageExt.Core" Version="4.1.0" />
<PackageReference Include="LanguageExt.Core" Version="4.2.7" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="6.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="6.0.1" />
</ItemGroup>
+3 -1
View File
@@ -4,7 +4,8 @@ namespace ErsatzTV.FFmpeg;
public record FFmpegState(
bool SaveReport,
HardwareAccelerationMode HardwareAccelerationMode,
HardwareAccelerationMode DecoderHardwareAccelerationMode,
HardwareAccelerationMode EncoderHardwareAccelerationMode,
Option<string> VaapiDriver,
Option<string> VaapiDevice,
Option<TimeSpan> Start,
@@ -23,6 +24,7 @@ public record FFmpegState(
new(
saveReport,
HardwareAccelerationMode.None,
HardwareAccelerationMode.None,
Option<string>.None,
Option<string>.None,
Option<TimeSpan>.None,
+8 -8
View File
@@ -148,7 +148,7 @@ public class ComplexFilter : IPipelineStep
}
IPipelineFilterStep overlayFilter = AvailableWatermarkOverlayFilters.ForAcceleration(
_ffmpegState.HardwareAccelerationMode,
_ffmpegState.EncoderHardwareAccelerationMode,
_currentState,
watermarkInputFile.DesiredState,
_resolution);
@@ -164,16 +164,16 @@ public class ComplexFilter : IPipelineStep
// also wait to upload if a subtitle overlay is coming
string uploadDownloadFilter = string.Empty;
if (_maybeSubtitleInputFile.IsNone &&
(_ffmpegState.HardwareAccelerationMode == HardwareAccelerationMode.Vaapi ||
_ffmpegState.HardwareAccelerationMode == HardwareAccelerationMode.VideoToolbox &&
(_ffmpegState.EncoderHardwareAccelerationMode == HardwareAccelerationMode.Vaapi ||
_ffmpegState.EncoderHardwareAccelerationMode == HardwareAccelerationMode.VideoToolbox &&
_currentState.VideoFormat == VideoFormat.Hevc))
{
uploadDownloadFilter = new HardwareUploadFilter(_ffmpegState).Filter;
}
if (_maybeSubtitleInputFile.Map(s => !s.IsImageBased).IfNone(false) &&
_ffmpegState.HardwareAccelerationMode != HardwareAccelerationMode.Vaapi &&
_ffmpegState.HardwareAccelerationMode != HardwareAccelerationMode.VideoToolbox)
_ffmpegState.EncoderHardwareAccelerationMode != HardwareAccelerationMode.Vaapi &&
_ffmpegState.EncoderHardwareAccelerationMode != HardwareAccelerationMode.VideoToolbox)
{
uploadDownloadFilter = new HardwareDownloadFilter(_currentState).Filter;
}
@@ -217,7 +217,7 @@ public class ComplexFilter : IPipelineStep
{
IPipelineFilterStep overlayFilter =
AvailableSubtitleOverlayFilters.ForAcceleration(
_ffmpegState.HardwareAccelerationMode,
_ffmpegState.EncoderHardwareAccelerationMode,
_currentState);
filter = overlayFilter.Filter;
}
@@ -238,8 +238,8 @@ public class ComplexFilter : IPipelineStep
// vaapi uses software overlay and needs to upload
// videotoolbox seems to require a hwupload for hevc
string uploadFilter = string.Empty;
if (_ffmpegState.HardwareAccelerationMode == HardwareAccelerationMode.Vaapi
|| _ffmpegState.HardwareAccelerationMode == HardwareAccelerationMode.VideoToolbox &&
if (_ffmpegState.EncoderHardwareAccelerationMode == HardwareAccelerationMode.Vaapi
|| _ffmpegState.EncoderHardwareAccelerationMode == HardwareAccelerationMode.VideoToolbox &&
_currentState.VideoFormat == VideoFormat.Hevc)
{
uploadFilter = new HardwareUploadFilter(_ffmpegState).Filter;
@@ -6,16 +6,16 @@ public class HardwareUploadFilter : BaseFilter
public HardwareUploadFilter(FFmpegState ffmpegState) => _ffmpegState = ffmpegState;
public override string Filter => _ffmpegState.HardwareAccelerationMode switch
public override string Filter => _ffmpegState.EncoderHardwareAccelerationMode switch
{
HardwareAccelerationMode.None => string.Empty,
HardwareAccelerationMode.Nvenc => "hwupload_cuda",
HardwareAccelerationMode.Qsv => "hwupload=extra_hw_frames=128",
HardwareAccelerationMode.Qsv => "hwupload=extra_hw_frames=64",
HardwareAccelerationMode.Vaapi => "format=nv12|vaapi,hwupload",
_ => "hwupload"
};
public override FrameState NextState(FrameState currentState) => _ffmpegState.HardwareAccelerationMode switch
public override FrameState NextState(FrameState currentState) => _ffmpegState.EncoderHardwareAccelerationMode switch
{
HardwareAccelerationMode.None => currentState,
_ => currentState with { FrameDataLocation = FrameDataLocation.Hardware }
@@ -10,7 +10,7 @@ public class DeinterlaceQsvFilter : BaseFilter
// deinterlace_qsv seems to only support nv12, not p010le
public override string Filter => _currentState.FrameDataLocation == FrameDataLocation.Software
? "format=nv12,hwupload=extra_hw_frames=128,deinterlace_qsv"
? "format=nv12,hwupload=extra_hw_frames=64,deinterlace_qsv"
: "deinterlace_qsv";
public override FrameState NextState(FrameState currentState)
+2 -2
View File
@@ -49,10 +49,10 @@ public class ScaleQsvFilter : BaseFilter
string initialPixelFormat = _currentState.PixelFormat.Match(pf => pf.FFmpegName, FFmpegFormat.NV12);
if (!string.IsNullOrWhiteSpace(scale))
{
return $"format={initialPixelFormat},hwupload=extra_hw_frames=128,{scale}";
return $"format={initialPixelFormat},hwupload=extra_hw_frames=64,{scale}";
}
return $"format={initialPixelFormat},hwupload=extra_hw_frames=128";
return $"format={initialPixelFormat},hwupload=extra_hw_frames=64";
}
}
@@ -12,11 +12,11 @@ public class SubtitleHardwareUploadFilter : BaseFilter
}
public override string Filter =>
_ffmpegState.HardwareAccelerationMode switch
_ffmpegState.EncoderHardwareAccelerationMode switch
{
HardwareAccelerationMode.None => string.Empty,
HardwareAccelerationMode.Nvenc => "hwupload_cuda",
HardwareAccelerationMode.Qsv => "hwupload=extra_hw_frames=128",
HardwareAccelerationMode.Qsv => "hwupload=extra_hw_frames=64",
// leave vaapi in software since we don't (yet) use overlay_vaapi
HardwareAccelerationMode.Vaapi when _currentState.FrameDataLocation == FrameDataLocation.Software =>
@@ -10,7 +10,7 @@ public class SubtitlePixelFormatFilter : BaseFilter
{
get
{
Option<string> maybeFormat = _ffmpegState.HardwareAccelerationMode switch
Option<string> maybeFormat = _ffmpegState.EncoderHardwareAccelerationMode switch
{
HardwareAccelerationMode.Nvenc => "yuva420p",
HardwareAccelerationMode.Qsv => "yuva420p",
@@ -11,11 +11,11 @@ public class WatermarkHardwareUploadFilter : BaseFilter
_ffmpegState = ffmpegState;
}
public override string Filter => _ffmpegState.HardwareAccelerationMode switch
public override string Filter => _ffmpegState.EncoderHardwareAccelerationMode switch
{
HardwareAccelerationMode.None => string.Empty,
HardwareAccelerationMode.Nvenc => "hwupload_cuda",
HardwareAccelerationMode.Qsv => "hwupload=extra_hw_frames=128",
HardwareAccelerationMode.Qsv => "hwupload=extra_hw_frames=64",
// leave vaapi in software since we don't (yet) use overlay_vaapi
HardwareAccelerationMode.Vaapi when _currentState.FrameDataLocation == FrameDataLocation.Software =>
@@ -1,4 +1,5 @@
using ErsatzTV.FFmpeg.State;
using System.Globalization;
using ErsatzTV.FFmpeg.State;
namespace ErsatzTV.FFmpeg.Filter;
@@ -13,7 +14,7 @@ public class WatermarkOpacityFilter : BaseFilter
get
{
double opacity = _desiredState.Opacity / 100.0;
return $"colorchannelmixer=aa={opacity:F2}";
return $"colorchannelmixer=aa={opacity.ToString("F2", NumberFormatInfo.InvariantInfo)}";
}
}
@@ -19,7 +19,7 @@ public class WatermarkPixelFormatFilter : BaseFilter
{
bool hasFadePoints = _watermarkState.MaybeFadePoints.Map(fp => fp.Count).IfNone(0) > 0;
Option<string> maybeFormat = _ffmpegState.HardwareAccelerationMode switch
Option<string> maybeFormat = _ffmpegState.EncoderHardwareAccelerationMode switch
{
HardwareAccelerationMode.Nvenc => "yuva420p",
HardwareAccelerationMode.Qsv => "yuva420p",
+1
View File
@@ -4,4 +4,5 @@ public interface IPixelFormat
{
string Name { get; }
string FFmpegName { get; }
int BitDepth { get; }
}
@@ -7,4 +7,5 @@ public class PixelFormatNv12 : IPixelFormat
public string Name { get; }
public string FFmpegName => "nv12";
public int BitDepth => 8;
}
@@ -4,4 +4,5 @@ public class PixelFormatUnknown : IPixelFormat
{
public string Name => "unknown";
public string FFmpegName => "unknown";
public int BitDepth => 8;
}
@@ -4,4 +4,5 @@ public class PixelFormatYuv420P : IPixelFormat
{
public string Name => PixelFormat.YUV420P;
public string FFmpegName => FFmpegFormat.YUV420P;
public int BitDepth => 8;
}
@@ -4,4 +4,5 @@ public class PixelFormatYuv420P10Le : IPixelFormat
{
public string Name => PixelFormat.YUV420P10LE;
public string FFmpegName => FFmpegFormat.P010LE;
public int BitDepth => 10;
}
@@ -4,4 +4,5 @@ public class PixelFormatYuv444P : IPixelFormat
{
public string Name => PixelFormat.YUV444P;
public string FFmpegName => FFmpegFormat.YUV444P;
public int BitDepth => 8;
}
@@ -6,4 +6,5 @@ public class PixelFormatYuvJ420P : IPixelFormat
// always convert this to yuv420p in filter chains
public string FFmpegName => FFmpegFormat.YUV420P;
public int BitDepth => 8;
}

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