Compare commits
31
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
940d9cd6b5 | ||
|
|
197c166789 | ||
|
|
d114db091e | ||
|
|
3204da8e43 | ||
|
|
100eb14408 | ||
|
|
025017ace5 | ||
|
|
6a690c7c10 | ||
|
|
dd7f77751c | ||
|
|
0c13b8ef1a | ||
|
|
c6ca58ab97 | ||
|
|
0846fc1d96 | ||
|
|
e41dd68ee0 | ||
|
|
0a92996da8 | ||
|
|
082bc6145c | ||
|
|
bf3f16451b | ||
|
|
3cb37003cb | ||
|
|
9acfd2cd06 | ||
|
|
3242e7ebb8 | ||
|
|
7644d628e7 | ||
|
|
b4f19e6de4 | ||
|
|
0388425763 | ||
|
|
ca5d303ac7 | ||
|
|
18e66a92ad | ||
|
|
7d0a56ab98 | ||
|
|
5069792d12 | ||
|
|
c9789458b9 | ||
|
|
777a0d09ed | ||
|
|
4e2ebcc48a | ||
|
|
90fe1d7709 | ||
|
|
1576dd026e | ||
|
|
ee7a64eea9 |
+1
-1
@@ -1,6 +1,6 @@
|
||||
|
||||
[*]
|
||||
charset=utf-8-bom
|
||||
charset=utf-8
|
||||
end_of_line=lf
|
||||
trim_trailing_whitespace=false
|
||||
insert_final_newline=false
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
|
||||
+38
-11
@@ -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")
|
||||
|
||||
@@ -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);
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user