Compare commits
53
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40ed4b8b0e | ||
|
|
b43d08ca67 | ||
|
|
5e7e386108 | ||
|
|
4176df9940 | ||
|
|
de2ef959fe | ||
|
|
b53cfebac1 | ||
|
|
6895b9cc6b | ||
|
|
c60d6e46f1 | ||
|
|
c66d190174 | ||
|
|
5e8da591be | ||
|
|
9c02a6738b | ||
|
|
5ed0184bca | ||
|
|
ae64ca4a93 | ||
|
|
c47099895e | ||
|
|
a2529febba | ||
|
|
521e0ba8b3 | ||
|
|
ee0efac9be | ||
|
|
bfe7635489 | ||
|
|
aa1735f024 | ||
|
|
8deae983c7 | ||
|
|
f349646703 | ||
|
|
5003e80500 | ||
|
|
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'
|
||||
|
||||
@@ -108,15 +108,16 @@ jobs:
|
||||
--icon "ErsatzTV.app" 200 190 \
|
||||
--hide-extension "ErsatzTV.app" \
|
||||
--app-drop-link 600 185 \
|
||||
--skip-jenkins \
|
||||
"ErsatzTV.dmg" \
|
||||
"ErsatzTV.app/"
|
||||
|
||||
- name: Notarize
|
||||
shell: bash
|
||||
run: |
|
||||
curl -o gon.zip -L -s "https://github.com/mitchellh/gon/releases/latest/download/gon_macos.zip"
|
||||
unzip -o -q gon.zip
|
||||
./gon -log-level=debug -log-json ./gon.json
|
||||
brew tap mitchellh/gon
|
||||
brew install mitchellh/gon/gon
|
||||
gon -log-level=debug -log-json ./gon.json
|
||||
env:
|
||||
AC_USERNAME: ${{ secrets.ac_username }}
|
||||
AC_PASSWORD: ${{ secrets.ac_password }}
|
||||
@@ -167,17 +168,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' }}
|
||||
|
||||
@@ -3,13 +3,16 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- docs/**
|
||||
- mkdocs.yml
|
||||
jobs:
|
||||
build:
|
||||
name: Deploy docs
|
||||
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
|
||||
|
||||
+72
-1
@@ -5,6 +5,72 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.6.4-beta] - 2022-07-28
|
||||
### Fixed
|
||||
- Fix subtitle stream selection when subtitle language is different than audio language
|
||||
- Fix bug with unsupported AAC channel layouts
|
||||
- Fix NVIDIA second-gen maxwell capabilities detection
|
||||
- Return distinct search results for episodes and other videos that have the same title
|
||||
- For example, two other videos both named `Trailer` would previously have displayed as one item in search results
|
||||
- Fix schedules that would begin to repeat the same content in the same order after a couple of days
|
||||
|
||||
### Added
|
||||
- Add `640x480` resolution
|
||||
|
||||
## [0.6.3-beta] - 2022-07-04
|
||||
### Fixed
|
||||
- Maintain stream continuity when playout is rebuilt for a channel that is actively being streamed
|
||||
- Properly apply changes to episode title, sort title, outline and plot from Plex
|
||||
- Fix search index for other videos and songs
|
||||
- In previous versions, some libraries would incorrectly display only one item
|
||||
- Properly display old versions of renamed items in trash
|
||||
|
||||
### Added
|
||||
- Add `Minimum Log Level` option to `Settings` page
|
||||
- Other methods of configuring the log level will no longer work
|
||||
|
||||
## [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 +1276,12 @@ 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.4-beta...HEAD
|
||||
[0.6.4-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.6.3-beta...v0.6.4-beta
|
||||
[0.6.3-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.6.2-beta...v0.6.3-beta
|
||||
[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;
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public record UpdateGeneralSettings(GeneralSettingsViewModel GeneralSettings) : IRequest<Either<BaseError, Unit>>;
|
||||
@@ -0,0 +1,32 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using Serilog.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public class UpdateGeneralSettingsHandler : IRequestHandler<UpdateGeneralSettings, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
private readonly LoggingLevelSwitch _loggingLevelSwitch;
|
||||
|
||||
public UpdateGeneralSettingsHandler(
|
||||
LoggingLevelSwitch loggingLevelSwitch,
|
||||
IConfigElementRepository configElementRepository)
|
||||
{
|
||||
_loggingLevelSwitch = loggingLevelSwitch;
|
||||
_configElementRepository = configElementRepository;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Unit>> Handle(
|
||||
UpdateGeneralSettings request,
|
||||
CancellationToken cancellationToken) => await ApplyUpdate(request.GeneralSettings);
|
||||
|
||||
private async Task<Unit> ApplyUpdate(GeneralSettingsViewModel generalSettings)
|
||||
{
|
||||
await _configElementRepository.Upsert(ConfigElementKey.MinimumLogLevel, generalSettings.MinimumLogLevel);
|
||||
_loggingLevelSwitch.MinimumLevel = generalSettings.MinimumLogLevel;
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using Serilog.Events;
|
||||
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public class GeneralSettingsViewModel
|
||||
{
|
||||
public LogEventLevel MinimumLogLevel { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public record GetGeneralSettings : IRequest<GeneralSettingsViewModel>;
|
||||
@@ -0,0 +1,24 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using Serilog.Events;
|
||||
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public class GetGeneralSettingsHandler : IRequestHandler<GetGeneralSettings, GeneralSettingsViewModel>
|
||||
{
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
|
||||
public GetGeneralSettingsHandler(IConfigElementRepository configElementRepository) =>
|
||||
_configElementRepository = configElementRepository;
|
||||
|
||||
public async Task<GeneralSettingsViewModel> Handle(GetGeneralSettings request, CancellationToken cancellationToken)
|
||||
{
|
||||
Option<LogEventLevel> maybeLogLevel =
|
||||
await _configElementRepository.GetValue<LogEventLevel>(ConfigElementKey.MinimumLogLevel);
|
||||
|
||||
return new GeneralSettingsViewModel
|
||||
{
|
||||
MinimumLogLevel = await maybeLogLevel.IfNoneAsync(LogEventLevel.Information)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
using Dapper;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories.Caching;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
@@ -13,18 +14,21 @@ namespace ErsatzTV.Application.Libraries;
|
||||
public class MoveLocalLibraryPathHandler : IRequestHandler<MoveLocalLibraryPath, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
|
||||
private readonly ILogger<MoveLocalLibraryPathHandler> _logger;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
private readonly ISearchRepository _searchRepository;
|
||||
private readonly ICachingSearchRepository _searchRepository;
|
||||
|
||||
public MoveLocalLibraryPathHandler(
|
||||
ISearchIndex searchIndex,
|
||||
ISearchRepository searchRepository,
|
||||
ICachingSearchRepository searchRepository,
|
||||
IFallbackMetadataProvider fallbackMetadataProvider,
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ILogger<MoveLocalLibraryPathHandler> logger)
|
||||
{
|
||||
_searchIndex = searchIndex;
|
||||
_searchRepository = searchRepository;
|
||||
_fallbackMetadataProvider = fallbackMetadataProvider;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
@@ -35,7 +39,7 @@ public class MoveLocalLibraryPathHandler : IRequestHandler<MoveLocalLibraryPath,
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, Parameters> validation = await Validate(dbContext, request);
|
||||
return await LanguageExtensions.Apply(validation, parameters => MovePath(dbContext, parameters));
|
||||
return await validation.Apply(parameters => MovePath(dbContext, parameters));
|
||||
}
|
||||
|
||||
private async Task<Unit> MovePath(TvContext dbContext, Parameters parameters)
|
||||
@@ -57,7 +61,10 @@ public class MoveLocalLibraryPathHandler : IRequestHandler<MoveLocalLibraryPath,
|
||||
foreach (MediaItem mediaItem in maybeMediaItem)
|
||||
{
|
||||
_logger.LogInformation("Moving item at {Path}", await GetPath(dbContext, mediaItem));
|
||||
await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { mediaItem });
|
||||
await _searchIndex.UpdateItems(
|
||||
_searchRepository,
|
||||
_fallbackMetadataProvider,
|
||||
new List<MediaItem> { mediaItem });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Interfaces.Locking;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories.Caching;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Interfaces.Trakt;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
@@ -17,12 +18,13 @@ public class AddTraktListHandler : TraktCommandBase, IRequestHandler<AddTraktLis
|
||||
|
||||
public AddTraktListHandler(
|
||||
ITraktApiClient traktApiClient,
|
||||
ISearchRepository searchRepository,
|
||||
ICachingSearchRepository searchRepository,
|
||||
ISearchIndex searchIndex,
|
||||
IFallbackMetadataProvider fallbackMetadataProvider,
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ILogger<AddTraktListHandler> logger,
|
||||
IEntityLocker entityLocker)
|
||||
: base(traktApiClient, searchRepository, searchIndex, logger)
|
||||
: base(traktApiClient, searchRepository, searchIndex, fallbackMetadataProvider, logger)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_entityLocker = entityLocker;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Locking;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories.Caching;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Interfaces.Trakt;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
@@ -14,20 +15,23 @@ public class DeleteTraktListHandler : TraktCommandBase, IRequestHandler<DeleteTr
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly IEntityLocker _entityLocker;
|
||||
private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
private readonly ISearchRepository _searchRepository;
|
||||
private readonly ICachingSearchRepository _searchRepository;
|
||||
|
||||
public DeleteTraktListHandler(
|
||||
ITraktApiClient traktApiClient,
|
||||
ISearchRepository searchRepository,
|
||||
ICachingSearchRepository searchRepository,
|
||||
ISearchIndex searchIndex,
|
||||
IFallbackMetadataProvider fallbackMetadataProvider,
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ILogger<DeleteTraktListHandler> logger,
|
||||
IEntityLocker entityLocker)
|
||||
: base(traktApiClient, searchRepository, searchIndex, logger)
|
||||
: base(traktApiClient, searchRepository, searchIndex, fallbackMetadataProvider, logger)
|
||||
{
|
||||
_searchRepository = searchRepository;
|
||||
_searchIndex = searchIndex;
|
||||
_fallbackMetadataProvider = fallbackMetadataProvider;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_entityLocker = entityLocker;
|
||||
}
|
||||
@@ -38,8 +42,7 @@ public class DeleteTraktListHandler : TraktCommandBase, IRequestHandler<DeleteTr
|
||||
{
|
||||
try
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, TraktList> validation = await TraktListMustExist(dbContext, request.TraktListId);
|
||||
return await LanguageExtensions.Apply(validation, c => DoDeletion(dbContext, c));
|
||||
}
|
||||
@@ -56,7 +59,7 @@ public class DeleteTraktListHandler : TraktCommandBase, IRequestHandler<DeleteTr
|
||||
dbContext.TraktLists.Remove(traktList);
|
||||
if (await dbContext.SaveChangesAsync() > 0)
|
||||
{
|
||||
await _searchIndex.RebuildItems(_searchRepository, mediaItemIds);
|
||||
await _searchIndex.RebuildItems(_searchRepository, _fallbackMetadataProvider, mediaItemIds);
|
||||
}
|
||||
|
||||
_searchIndex.Commit();
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Locking;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories.Caching;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Interfaces.Trakt;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
@@ -18,11 +19,17 @@ public class MatchTraktListItemsHandler : TraktCommandBase,
|
||||
|
||||
public MatchTraktListItemsHandler(
|
||||
ITraktApiClient traktApiClient,
|
||||
ISearchRepository searchRepository,
|
||||
ICachingSearchRepository searchRepository,
|
||||
ISearchIndex searchIndex,
|
||||
IFallbackMetadataProvider fallbackMetadataProvider,
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ILogger<MatchTraktListItemsHandler> logger,
|
||||
IEntityLocker entityLocker) : base(traktApiClient, searchRepository, searchIndex, logger)
|
||||
IEntityLocker entityLocker) : base(
|
||||
traktApiClient,
|
||||
searchRepository,
|
||||
searchIndex,
|
||||
fallbackMetadataProvider,
|
||||
logger)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_entityLocker = entityLocker;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories.Caching;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Interfaces.Trakt;
|
||||
using ErsatzTV.Core.Trakt;
|
||||
@@ -13,18 +14,21 @@ namespace ErsatzTV.Application.MediaCollections;
|
||||
|
||||
public abstract class TraktCommandBase
|
||||
{
|
||||
private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
|
||||
private readonly ILogger _logger;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
private readonly ISearchRepository _searchRepository;
|
||||
private readonly ICachingSearchRepository _searchRepository;
|
||||
|
||||
protected TraktCommandBase(
|
||||
ITraktApiClient traktApiClient,
|
||||
ISearchRepository searchRepository,
|
||||
ICachingSearchRepository searchRepository,
|
||||
ISearchIndex searchIndex,
|
||||
IFallbackMetadataProvider fallbackMetadataProvider,
|
||||
ILogger logger)
|
||||
{
|
||||
_searchRepository = searchRepository;
|
||||
_searchIndex = searchIndex;
|
||||
_fallbackMetadataProvider = fallbackMetadataProvider;
|
||||
_logger = logger;
|
||||
TraktApiClient = traktApiClient;
|
||||
}
|
||||
@@ -158,7 +162,7 @@ public abstract class TraktCommandBase
|
||||
|
||||
if (await dbContext.SaveChangesAsync() > 0)
|
||||
{
|
||||
await _searchIndex.RebuildItems(_searchRepository, ids.ToList());
|
||||
await _searchIndex.RebuildItems(_searchRepository, _fallbackMetadataProvider, ids.ToList());
|
||||
}
|
||||
|
||||
_searchIndex.Commit();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Repositories.Caching;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using Humanizer;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -12,16 +13,18 @@ namespace ErsatzTV.Application.Search;
|
||||
public class RebuildSearchIndexHandler : IRequestHandler<RebuildSearchIndex, Unit>
|
||||
{
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly ILogger<RebuildSearchIndexHandler> _logger;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
private readonly ISearchRepository _searchRepository;
|
||||
private readonly ICachingSearchRepository _searchRepository;
|
||||
|
||||
public RebuildSearchIndexHandler(
|
||||
ISearchIndex searchIndex,
|
||||
ISearchRepository searchRepository,
|
||||
ICachingSearchRepository searchRepository,
|
||||
IConfigElementRepository configElementRepository,
|
||||
ILocalFileSystem localFileSystem,
|
||||
IFallbackMetadataProvider fallbackMetadataProvider,
|
||||
ILogger<RebuildSearchIndexHandler> logger)
|
||||
{
|
||||
_searchIndex = searchIndex;
|
||||
@@ -29,14 +32,19 @@ public class RebuildSearchIndexHandler : IRequestHandler<RebuildSearchIndex, Uni
|
||||
_searchRepository = searchRepository;
|
||||
_configElementRepository = configElementRepository;
|
||||
_localFileSystem = localFileSystem;
|
||||
_fallbackMetadataProvider = fallbackMetadataProvider;
|
||||
}
|
||||
|
||||
public async Task<Unit> Handle(RebuildSearchIndex request, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("Initializing search index");
|
||||
|
||||
bool indexFolderExists = Directory.Exists(FileSystemLayout.SearchIndexFolder);
|
||||
|
||||
await _searchIndex.Initialize(_localFileSystem, _configElementRepository);
|
||||
|
||||
_logger.LogInformation("Done initializing search index");
|
||||
|
||||
if (!indexFolderExists ||
|
||||
await _configElementRepository.GetValue<int>(ConfigElementKey.SearchIndexVersion) <
|
||||
_searchIndex.Version)
|
||||
@@ -44,7 +52,7 @@ public class RebuildSearchIndexHandler : IRequestHandler<RebuildSearchIndex, Uni
|
||||
_logger.LogInformation("Migrating search index to version {Version}", _searchIndex.Version);
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
await _searchIndex.Rebuild(_searchRepository);
|
||||
await _searchIndex.Rebuild(_searchRepository, _fallbackMetadataProvider);
|
||||
|
||||
await _configElementRepository.Upsert(ConfigElementKey.SearchIndexVersion, _searchIndex.Version);
|
||||
sw.Stop();
|
||||
|
||||
@@ -3,10 +3,14 @@ using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Extensions;
|
||||
using ErsatzTV.Core.Interfaces.Emby;
|
||||
using ErsatzTV.Core.Interfaces.Jellyfin;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Plex;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Search;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using static ErsatzTV.Application.MediaCards.Mapper;
|
||||
|
||||
namespace ErsatzTV.Application.Search;
|
||||
@@ -14,7 +18,9 @@ namespace ErsatzTV.Application.Search;
|
||||
public class
|
||||
QuerySearchIndexEpisodesHandler : IRequestHandler<QuerySearchIndexEpisodes, TelevisionEpisodeCardResultsViewModel>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly IEmbyPathReplacementService _embyPathReplacementService;
|
||||
private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
|
||||
private readonly IJellyfinPathReplacementService _jellyfinPathReplacementService;
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
private readonly IPlexPathReplacementService _plexPathReplacementService;
|
||||
@@ -27,7 +33,9 @@ public class
|
||||
IMediaSourceRepository mediaSourceRepository,
|
||||
IPlexPathReplacementService plexPathReplacementService,
|
||||
IJellyfinPathReplacementService jellyfinPathReplacementService,
|
||||
IEmbyPathReplacementService embyPathReplacementService)
|
||||
IEmbyPathReplacementService embyPathReplacementService,
|
||||
IFallbackMetadataProvider fallbackMetadataProvider,
|
||||
IDbContextFactory<TvContext> dbContextFactory)
|
||||
{
|
||||
_searchIndex = searchIndex;
|
||||
_televisionRepository = televisionRepository;
|
||||
@@ -35,6 +43,8 @@ public class
|
||||
_plexPathReplacementService = plexPathReplacementService;
|
||||
_jellyfinPathReplacementService = jellyfinPathReplacementService;
|
||||
_embyPathReplacementService = embyPathReplacementService;
|
||||
_fallbackMetadataProvider = fallbackMetadataProvider;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
}
|
||||
|
||||
public async Task<TelevisionEpisodeCardResultsViewModel> Handle(
|
||||
@@ -52,8 +62,40 @@ public class
|
||||
Option<EmbyMediaSource> maybeEmby = await _mediaSourceRepository.GetAllEmby()
|
||||
.Map(list => list.HeadOrNone());
|
||||
|
||||
List<EpisodeMetadata> episodes = await _televisionRepository
|
||||
.GetEpisodesForCards(searchResult.Items.Map(i => i.Id).ToList());
|
||||
var episodeIds = searchResult.Items.Map(i => i.Id).ToList();
|
||||
|
||||
List<EpisodeMetadata> episodes = await _televisionRepository.GetEpisodesForCards(episodeIds);
|
||||
|
||||
// try to load fallback metadata for episodes that have none
|
||||
// this handles an edge case of trashed items with no saved metadata
|
||||
var missingEpisodes = episodeIds.Except(episodes.Map(e => e.EpisodeId)).ToList();
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
foreach (int missingEpisodeId in missingEpisodes)
|
||||
{
|
||||
Option<Episode> maybeEpisode = await dbContext.Episodes
|
||||
.AsNoTracking()
|
||||
.Include(e => e.MediaVersions)
|
||||
.ThenInclude(e => e.MediaFiles)
|
||||
.Include(e => e.Season)
|
||||
.ThenInclude(s => s.SeasonMetadata)
|
||||
.ThenInclude(sm => sm.Artwork)
|
||||
.Include(e => e.Season)
|
||||
.ThenInclude(s => s.Show)
|
||||
.ThenInclude(s => s.ShowMetadata)
|
||||
.ThenInclude(sm => sm.Artwork)
|
||||
.SelectOneAsync(e => e.Id, e => e.Id == missingEpisodeId);
|
||||
|
||||
foreach (Episode episode in maybeEpisode)
|
||||
{
|
||||
foreach (EpisodeMetadata headMetadata in _fallbackMetadataProvider.GetFallbackMetadata(episode)
|
||||
.HeadOrNone())
|
||||
{
|
||||
headMetadata.Episode = episode;
|
||||
episode.EpisodeMetadata = new List<EpisodeMetadata> { headMetadata };
|
||||
episodes.Add(headMetadata);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var items = new List<TelevisionEpisodeCardViewModel>();
|
||||
|
||||
|
||||
@@ -27,7 +27,10 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
private readonly object _sync = new();
|
||||
private string _channelNumber;
|
||||
private bool _firstProcess;
|
||||
private bool _hasWrittenSegments;
|
||||
private DateTimeOffset _lastAccess;
|
||||
private DateTimeOffset _lastDelete = DateTimeOffset.MinValue;
|
||||
private bool _seekNextItem;
|
||||
private Option<int> _targetFramerate;
|
||||
private Timer _timer;
|
||||
private DateTimeOffset _transcodedUntil;
|
||||
@@ -65,7 +68,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
|
||||
{
|
||||
@@ -73,7 +88,11 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
}
|
||||
}
|
||||
|
||||
public void PlayoutUpdated() => _firstProcess = true;
|
||||
public void PlayoutUpdated()
|
||||
{
|
||||
_firstProcess = true;
|
||||
_seekNextItem = true;
|
||||
}
|
||||
|
||||
public async Task Run(string channelNumber, TimeSpan idleTimeout, CancellationToken incomingCancellationToken)
|
||||
{
|
||||
@@ -237,6 +256,13 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
_logger.LogInformation("HLS process has completed for channel {Channel}", _channelNumber);
|
||||
_transcodedUntil = processModel.Until;
|
||||
_firstProcess = false;
|
||||
if (_seekNextItem)
|
||||
{
|
||||
_firstProcess = true;
|
||||
_seekNextItem = false;
|
||||
}
|
||||
|
||||
_hasWrittenSegments = true;
|
||||
return true;
|
||||
}
|
||||
else
|
||||
@@ -281,6 +307,14 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
if (commandResult.ExitCode == 0)
|
||||
{
|
||||
_firstProcess = false;
|
||||
if (_seekNextItem)
|
||||
{
|
||||
_firstProcess = true;
|
||||
_seekNextItem = false;
|
||||
}
|
||||
|
||||
_hasWrittenSegments = true;
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -334,33 +368,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,6 +379,37 @@ 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,
|
||||
@@ -381,6 +420,12 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
{
|
||||
long result = 0;
|
||||
|
||||
// if we haven't yet written any segments, start at zero
|
||||
if (!_hasWrittenSegments)
|
||||
{
|
||||
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.9" />
|
||||
<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" />
|
||||
@@ -31,6 +31,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ErsatzTV.Core\ErsatzTV.Core.csproj" />
|
||||
<ProjectReference Include="..\ErsatzTV.Infrastructure\ErsatzTV.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -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, string.Empty));
|
||||
}
|
||||
|
||||
private static string ExecutableName(string baseName) =>
|
||||
OperatingSystem.IsWindows() ? $"{baseName}.exe" : baseName;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
using ErsatzTV.Core.Plex;
|
||||
|
||||
namespace ErsatzTV.Core.Tests.Fakes;
|
||||
|
||||
@@ -42,8 +41,7 @@ public class FakeTelevisionRepository : ITelevisionRepository
|
||||
public Task<Option<Show>> GetShowByMetadata(int libraryPathId, ShowMetadata metadata) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public Task<Either<BaseError, MediaItemScanResult<Show>>>
|
||||
AddShow(int libraryPathId, string showFolder, ShowMetadata metadata) =>
|
||||
public Task<Either<BaseError, MediaItemScanResult<Show>>> AddShow(int libraryPathId, ShowMetadata metadata) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public Task<Either<BaseError, Season>> GetOrAddSeason(Show show, int libraryPathId, int seasonNumber) =>
|
||||
@@ -74,36 +72,11 @@ public class FakeTelevisionRepository : ITelevisionRepository
|
||||
public Task<bool> AddDirector(EpisodeMetadata metadata, Director director) => throw new NotSupportedException();
|
||||
|
||||
public Task<bool> AddWriter(EpisodeMetadata metadata, Writer writer) => throw new NotSupportedException();
|
||||
public Task<Unit> UpdatePath(int mediaFileId, string path) => throw new NotSupportedException();
|
||||
|
||||
public Task<Either<BaseError, MediaItemScanResult<PlexShow>>> GetOrAddPlexShow(
|
||||
PlexLibrary library,
|
||||
PlexShow item) =>
|
||||
public Task<bool> UpdateTitles(EpisodeMetadata metadata, string title, string sortTitle) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public Task<Either<BaseError, PlexSeason>> GetOrAddPlexSeason(PlexLibrary library, PlexSeason item) =>
|
||||
throw new NotSupportedException();
|
||||
public Task<bool> UpdateOutline(EpisodeMetadata metadata, string outline) => throw new NotSupportedException();
|
||||
|
||||
public Task<Either<BaseError, MediaItemScanResult<PlexEpisode>>> GetOrAddPlexEpisode(
|
||||
PlexLibrary library,
|
||||
PlexEpisode item) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public Task<List<int>> RemoveMissingPlexShows(PlexLibrary library, List<string> showKeys) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public Task<Unit> RemoveMissingPlexSeasons(string showKey, List<string> seasonKeys) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public Task<List<int>> RemoveMissingPlexEpisodes(string seasonKey, List<string> episodeKeys) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public Task<Unit> SetPlexEtag(PlexShow show, string etag) => throw new NotSupportedException();
|
||||
|
||||
public Task<Unit> SetPlexEtag(PlexSeason season, string etag) => throw new NotSupportedException();
|
||||
|
||||
public Task<Unit> SetPlexEtag(PlexEpisode episode, string etag) => throw new NotSupportedException();
|
||||
|
||||
public Task<List<PlexItemEtag>> GetExistingPlexEpisodes(PlexLibrary library, PlexSeason season) =>
|
||||
throw new NotSupportedException();
|
||||
public Task<bool> UpdatePlot(EpisodeMetadata metadata, string plot) => throw new NotSupportedException();
|
||||
}
|
||||
|
||||
@@ -229,6 +229,38 @@ public class JellyfinPathReplacementServiceTests
|
||||
result.Should().Be(@"/mnt/something else/Some Shared Folder/Some Movie/Some Movie.mkv");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task JellyfinLinux_To_EtvLinux_UncPath()
|
||||
{
|
||||
var replacements = new List<JellyfinPathReplacement>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = 1,
|
||||
JellyfinPath = @"\\192.168.1.100\Something\Some Shared Folder",
|
||||
LocalPath = @"/mnt/something else/Some Shared Folder",
|
||||
JellyfinMediaSource = new JellyfinMediaSource { OperatingSystem = "Linux" }
|
||||
}
|
||||
};
|
||||
|
||||
var repo = new Mock<IMediaSourceRepository>();
|
||||
repo.Setup(x => x.GetJellyfinPathReplacementsByLibraryId(It.IsAny<int>())).Returns(replacements.AsTask());
|
||||
|
||||
var runtime = new Mock<IRuntimeInfo>();
|
||||
runtime.Setup(x => x.IsOSPlatform(OSPlatform.Windows)).Returns(false);
|
||||
|
||||
var service = new JellyfinPathReplacementService(
|
||||
repo.Object,
|
||||
runtime.Object,
|
||||
new Mock<ILogger<JellyfinPathReplacementService>>().Object);
|
||||
|
||||
string result = await service.GetReplacementJellyfinPath(
|
||||
0,
|
||||
@"\\192.168.1.100\Something\Some Shared Folder\Some Movie\Some Movie.mkv");
|
||||
|
||||
result.Should().Be(@"/mnt/something else/Some Shared Folder/Some Movie/Some Movie.mkv");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Not_Throw_For_Null_JellyfinPath()
|
||||
{
|
||||
|
||||
@@ -40,6 +40,13 @@ public class FallbackMetadataProviderTests
|
||||
"Awesome.Show.S01E02.Description.more.Description.QUAlity.codec.CODEC-GROUP.mkv",
|
||||
1,
|
||||
2)]
|
||||
[TestCase("Awesome Show - s01.e02.mkv", 1, 2)]
|
||||
[TestCase("Awesome Show - S01.E02.mkv", 1, 2)]
|
||||
[TestCase("Awesome Show - s01_e02.mkv", 1, 2)]
|
||||
[TestCase("Awesome Show - S01_E02.mkv", 1, 2)]
|
||||
[TestCase("Awesome Show - s01xe02.mkv", 1, 2)]
|
||||
[TestCase("Awesome Show - S01XE02.mkv", 1, 2)]
|
||||
[TestCase("Awesome Show - 1x02.mkv", 1, 2)]
|
||||
public void GetFallbackMetadata_ShouldHandleVariousFormats(string path, int season, int episode)
|
||||
{
|
||||
List<EpisodeMetadata> metadata = _fallbackMetadataProvider.GetFallbackMetadata(
|
||||
|
||||
@@ -5,6 +5,7 @@ using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Images;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Repositories.Caching;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
using ErsatzTV.Core.Tests.Fakes;
|
||||
@@ -622,7 +623,8 @@ public class MovieFolderScannerTests
|
||||
new Mock<IMetadataRepository>().Object,
|
||||
_imageCache.Object,
|
||||
new Mock<ISearchIndex>().Object,
|
||||
new Mock<ISearchRepository>().Object,
|
||||
new Mock<ICachingSearchRepository>().Object,
|
||||
new Mock<IFallbackMetadataProvider>().Object,
|
||||
new Mock<ILibraryRepository>().Object,
|
||||
_mediaItemRepository.Object,
|
||||
new Mock<IMediator>().Object,
|
||||
@@ -642,7 +644,8 @@ public class MovieFolderScannerTests
|
||||
new Mock<IMetadataRepository>().Object,
|
||||
_imageCache.Object,
|
||||
new Mock<ISearchIndex>().Object,
|
||||
new Mock<ISearchRepository>().Object,
|
||||
new Mock<ICachingSearchRepository>().Object,
|
||||
new Mock<IFallbackMetadataProvider>().Object,
|
||||
new Mock<ILibraryRepository>().Object,
|
||||
_mediaItemRepository.Object,
|
||||
new Mock<IMediator>().Object,
|
||||
|
||||
@@ -2235,7 +2235,7 @@ public class PlayoutBuilderTests
|
||||
DateTimeOffset start2 = HoursAfterMidnight(0);
|
||||
DateTimeOffset finish2 = start2 + TimeSpan.FromHours(6);
|
||||
|
||||
Playout result2 = await builder.Build(playout, PlayoutBuildMode.Continue, start2, finish2);
|
||||
Playout result2 = await builder.Build(result, PlayoutBuildMode.Continue, start2, finish2);
|
||||
|
||||
int secondSeedValue = result2.ProgramScheduleAnchors.Head().EnumeratorState.Seed;
|
||||
|
||||
@@ -2244,6 +2244,57 @@ public class PlayoutBuilderTests
|
||||
result2.ProgramScheduleAnchors.Head().EnumeratorState.Index.Should().Be(0);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ShuffleFlood_Should_MaintainRandomSeed_MultipleDays()
|
||||
{
|
||||
var mediaItems = new List<MediaItem>();
|
||||
for (int i = 1; i <= 25; i++)
|
||||
{
|
||||
mediaItems.Add(TestMovie(i, TimeSpan.FromMinutes(55), DateTime.Today.AddHours(i)));
|
||||
}
|
||||
|
||||
(PlayoutBuilder builder, Playout playout) = TestDataFloodForItems(mediaItems, PlaybackOrder.Shuffle);
|
||||
DateTimeOffset start = HoursAfterMidnight(0).AddSeconds(5);
|
||||
DateTimeOffset finish = start + TimeSpan.FromDays(2);
|
||||
|
||||
Playout result = await builder.Build(playout, PlayoutBuildMode.Reset, start, finish);
|
||||
|
||||
result.Items.Count.Should().Be(53);
|
||||
result.ProgramScheduleAnchors.Count.Should().Be(2);
|
||||
|
||||
result.ProgramScheduleAnchors.All(x => x.AnchorDate is not null).Should().BeTrue();
|
||||
PlayoutProgramScheduleAnchor lastCheckpoint = result.ProgramScheduleAnchors
|
||||
.OrderByDescending(a => a.AnchorDate ?? DateTime.MinValue)
|
||||
.First();
|
||||
lastCheckpoint.EnumeratorState.Seed.Should().BeGreaterThan(0);
|
||||
lastCheckpoint.EnumeratorState.Index.Should().Be(3);
|
||||
|
||||
// we need to mess up the ordering to trigger the problematic behavior
|
||||
// this simulates the way the rows are loaded with EF
|
||||
PlayoutProgramScheduleAnchor oldest = result.ProgramScheduleAnchors.OrderByDescending(a => a.AnchorDate).Last();
|
||||
PlayoutProgramScheduleAnchor newest = result.ProgramScheduleAnchors.OrderByDescending(a => a.AnchorDate).First();
|
||||
|
||||
result.ProgramScheduleAnchors = new List<PlayoutProgramScheduleAnchor>
|
||||
{
|
||||
oldest,
|
||||
newest
|
||||
};
|
||||
|
||||
int firstSeedValue = lastCheckpoint.EnumeratorState.Seed;
|
||||
|
||||
DateTimeOffset start2 = start.AddHours(1);
|
||||
DateTimeOffset finish2 = start2 + TimeSpan.FromDays(2);
|
||||
|
||||
Playout result2 = await builder.Build(result, PlayoutBuildMode.Continue, start2, finish2);
|
||||
|
||||
PlayoutProgramScheduleAnchor continueAnchor =
|
||||
result2.ProgramScheduleAnchors.First(x => x.AnchorDate is null);
|
||||
int secondSeedValue = continueAnchor.EnumeratorState.Seed;
|
||||
|
||||
// the continue anchor should have the same seed as the most recent (last) checkpoint from the first run
|
||||
firstSeedValue.Should().Be(secondSeedValue);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task FloodContent_Should_FloodWithFixedStartTime_FromAnchor()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
using Dapper;
|
||||
using Destructurama;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Data.Repositories;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using LanguageExt.UnsafeValueAccess;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using NUnit.Framework;
|
||||
using Serilog;
|
||||
using Serilog.Events;
|
||||
using Serilog.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Core.Tests.Scheduling;
|
||||
|
||||
[TestFixture]
|
||||
[Explicit]
|
||||
public class ScheduleIntegrationTests
|
||||
{
|
||||
public ScheduleIntegrationTests()
|
||||
{
|
||||
Log.Logger = new LoggerConfiguration()
|
||||
.MinimumLevel.Information()
|
||||
.MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
|
||||
.MinimumLevel.Override("System.Net.Http.HttpClient", LogEventLevel.Warning)
|
||||
.WriteTo.Console()
|
||||
.Destructure.UsingAttributes()
|
||||
.CreateLogger();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Test()
|
||||
{
|
||||
string dbFileName = Path.GetTempFileName() + ".sqlite3";
|
||||
|
||||
IServiceCollection services = new ServiceCollection()
|
||||
.AddLogging();
|
||||
|
||||
var connectionString = $"Data Source={dbFileName};foreign keys=true;";
|
||||
|
||||
services.AddDbContext<TvContext>(
|
||||
options => options.UseSqlite(
|
||||
connectionString,
|
||||
o =>
|
||||
{
|
||||
o.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery);
|
||||
o.MigrationsAssembly("ErsatzTV.Infrastructure");
|
||||
}),
|
||||
ServiceLifetime.Scoped,
|
||||
ServiceLifetime.Singleton);
|
||||
|
||||
services.AddDbContextFactory<TvContext>(
|
||||
options => options.UseSqlite(
|
||||
connectionString,
|
||||
o =>
|
||||
{
|
||||
o.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery);
|
||||
o.MigrationsAssembly("ErsatzTV.Infrastructure");
|
||||
}));
|
||||
|
||||
SqlMapper.AddTypeHandler(new DateTimeOffsetHandler());
|
||||
SqlMapper.AddTypeHandler(new GuidHandler());
|
||||
SqlMapper.AddTypeHandler(new TimeSpanHandler());
|
||||
|
||||
services.AddSingleton((Func<IServiceProvider, ILoggerFactory>)(_ => new SerilogLoggerFactory()));
|
||||
|
||||
ServiceProvider provider = services.BuildServiceProvider();
|
||||
|
||||
IDbContextFactory<TvContext> factory = provider.GetRequiredService<IDbContextFactory<TvContext>>();
|
||||
|
||||
ILogger<ScheduleIntegrationTests> logger = provider.GetRequiredService<ILogger<ScheduleIntegrationTests>>();
|
||||
logger.LogInformation("Database is at {File}", dbFileName);
|
||||
|
||||
await using TvContext dbContext = await factory.CreateDbContextAsync(CancellationToken.None);
|
||||
await dbContext.Database.MigrateAsync(CancellationToken.None);
|
||||
await DbInitializer.Initialize(dbContext, CancellationToken.None);
|
||||
|
||||
var path = new LibraryPath
|
||||
{
|
||||
Path = "Test LibraryPath"
|
||||
};
|
||||
|
||||
var library = new LocalLibrary
|
||||
{
|
||||
MediaKind = LibraryMediaKind.Movies,
|
||||
Paths = new List<LibraryPath> { path },
|
||||
MediaSource = new LocalMediaSource()
|
||||
};
|
||||
|
||||
await dbContext.Libraries.AddAsync(library);
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
var movies = new List<Movie>();
|
||||
for (var i = 1; i < 25; i++)
|
||||
{
|
||||
var movie = new Movie
|
||||
{
|
||||
MediaVersions = new List<MediaVersion>
|
||||
{
|
||||
new() { Duration = TimeSpan.FromMinutes(55) }
|
||||
},
|
||||
MovieMetadata = new List<MovieMetadata>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Title = $"Movie {i}",
|
||||
ReleaseDate = new DateTime(2000, 1, 1).AddDays(i)
|
||||
}
|
||||
},
|
||||
LibraryPath = path,
|
||||
LibraryPathId = path.Id
|
||||
};
|
||||
|
||||
movies.Add(movie);
|
||||
}
|
||||
|
||||
await dbContext.Movies.AddRangeAsync(movies);
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
var collection = new Collection
|
||||
{
|
||||
Name = "Test Collection",
|
||||
MediaItems = movies.Cast<MediaItem>().ToList()
|
||||
};
|
||||
|
||||
await dbContext.Collections.AddAsync(collection);
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
var scheduleItems = new List<ProgramScheduleItem>
|
||||
{
|
||||
new ProgramScheduleItemDuration
|
||||
{
|
||||
Collection = collection,
|
||||
CollectionId = collection.Id,
|
||||
PlayoutDuration = TimeSpan.FromHours(1),
|
||||
TailMode = TailMode.None, // immediately continue
|
||||
PlaybackOrder = PlaybackOrder.Shuffle
|
||||
}
|
||||
};
|
||||
|
||||
int playoutId = await AddTestData(dbContext, scheduleItems);
|
||||
|
||||
DateTimeOffset start = new DateTimeOffset(2022, 7, 26, 8, 0, 5, TimeSpan.FromHours(-5));
|
||||
DateTimeOffset finish = start.AddDays(2);
|
||||
|
||||
var builder = new PlayoutBuilder(
|
||||
new ConfigElementRepository(factory),
|
||||
new MediaCollectionRepository(new Mock<ISearchIndex>().Object, factory),
|
||||
new TelevisionRepository(factory),
|
||||
new ArtistRepository(factory),
|
||||
provider.GetRequiredService<ILogger<PlayoutBuilder>>());
|
||||
|
||||
for (var i = 0; i <= (24 * 4); i++)
|
||||
{
|
||||
await using TvContext context = await factory.CreateDbContextAsync();
|
||||
|
||||
Option<Playout> maybePlayout = await GetPlayout(context, playoutId);
|
||||
Playout playout = maybePlayout.ValueUnsafe();
|
||||
|
||||
await builder.Build(playout, PlayoutBuildMode.Continue, start.AddHours(i), finish.AddHours(i));
|
||||
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<int> AddTestData(TvContext dbContext, List<ProgramScheduleItem> scheduleItems)
|
||||
{
|
||||
var ffmpegProfile = new FFmpegProfile
|
||||
{
|
||||
Name = "Test FFmpeg Profile"
|
||||
};
|
||||
|
||||
await dbContext.FFmpegProfiles.AddAsync(ffmpegProfile);
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
var channel = new Channel(Guid.Parse("00000000-0000-0000-0000-000000000001"))
|
||||
{
|
||||
Name = "Test Channel",
|
||||
FFmpegProfile = ffmpegProfile,
|
||||
FFmpegProfileId = ffmpegProfile.Id
|
||||
};
|
||||
|
||||
await dbContext.Channels.AddAsync(channel);
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
var schedule = new ProgramSchedule
|
||||
{
|
||||
Name = "Test Schedule",
|
||||
Items = scheduleItems
|
||||
};
|
||||
|
||||
await dbContext.ProgramSchedules.AddAsync(schedule);
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
var playout = new Playout
|
||||
{
|
||||
Channel = channel,
|
||||
ChannelId = channel.Id,
|
||||
ProgramSchedule = schedule,
|
||||
ProgramScheduleId = schedule.Id
|
||||
};
|
||||
|
||||
await dbContext.Playouts.AddAsync(playout);
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
return playout.Id;
|
||||
}
|
||||
|
||||
private static async Task<Option<Playout>> GetPlayout(TvContext dbContext, int playoutId)
|
||||
{
|
||||
return await dbContext.Playouts
|
||||
.Include(p => p.Channel)
|
||||
.Include(p => p.Items)
|
||||
.Include(p => p.ProgramScheduleAnchors)
|
||||
.ThenInclude(a => a.MediaItem)
|
||||
.Include(p => p.ProgramSchedule)
|
||||
.ThenInclude(ps => ps.Items)
|
||||
.ThenInclude(psi => psi.Collection)
|
||||
.Include(p => p.ProgramSchedule)
|
||||
.ThenInclude(ps => ps.Items)
|
||||
.ThenInclude(psi => psi.MediaItem)
|
||||
.Include(p => p.ProgramSchedule)
|
||||
.ThenInclude(ps => ps.Items)
|
||||
.ThenInclude(psi => psi.PreRollFiller)
|
||||
.Include(p => p.ProgramSchedule)
|
||||
.ThenInclude(ps => ps.Items)
|
||||
.ThenInclude(psi => psi.MidRollFiller)
|
||||
.Include(p => p.ProgramSchedule)
|
||||
.ThenInclude(ps => ps.Items)
|
||||
.ThenInclude(psi => psi.PostRollFiller)
|
||||
.Include(p => p.ProgramSchedule)
|
||||
.ThenInclude(ps => ps.Items)
|
||||
.ThenInclude(psi => psi.TailFiller)
|
||||
.Include(p => p.ProgramSchedule)
|
||||
.ThenInclude(ps => ps.Items)
|
||||
.ThenInclude(psi => psi.FallbackFiller)
|
||||
.SelectOneAsync(p => p.Id, p => p.Id == playoutId);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -6,6 +6,7 @@ public class ConfigElementKey
|
||||
|
||||
public string Key { get; }
|
||||
|
||||
public static ConfigElementKey MinimumLogLevel => new("log.minimum_level");
|
||||
public static ConfigElementKey FFmpegPath => new("ffmpeg.ffmpeg_path");
|
||||
public static ConfigElementKey FFprobePath => new("ffmpeg.ffprobe_path");
|
||||
public static ConfigElementKey FFmpegDefaultProfileId => new("ffmpeg.default_profile_id");
|
||||
|
||||
@@ -3,5 +3,7 @@
|
||||
public enum SubtitleKind
|
||||
{
|
||||
Embedded = 0,
|
||||
Sidecar = 1
|
||||
Sidecar = 1,
|
||||
|
||||
Generated = 99
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Emby;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Repositories.Caching;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
@@ -10,48 +12,45 @@ public class EmbyCollectionScanner : IEmbyCollectionScanner
|
||||
{
|
||||
private readonly IEmbyApiClient _embyApiClient;
|
||||
private readonly IEmbyCollectionRepository _embyCollectionRepository;
|
||||
private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
|
||||
private readonly ILogger<EmbyCollectionScanner> _logger;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
private readonly ISearchRepository _searchRepository;
|
||||
private readonly ICachingSearchRepository _searchRepository;
|
||||
|
||||
public EmbyCollectionScanner(
|
||||
IEmbyCollectionRepository embyCollectionRepository,
|
||||
IEmbyApiClient embyApiClient,
|
||||
ISearchRepository searchRepository,
|
||||
ICachingSearchRepository searchRepository,
|
||||
ISearchIndex searchIndex,
|
||||
IFallbackMetadataProvider fallbackMetadataProvider,
|
||||
ILogger<EmbyCollectionScanner> logger)
|
||||
{
|
||||
_embyCollectionRepository = embyCollectionRepository;
|
||||
_embyApiClient = embyApiClient;
|
||||
_searchRepository = searchRepository;
|
||||
_searchIndex = searchIndex;
|
||||
_fallbackMetadataProvider = fallbackMetadataProvider;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
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 +74,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 +93,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, _fallbackMetadataProvider, 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";
|
||||
}
|
||||
@@ -3,6 +3,7 @@ using ErsatzTV.Core.Extensions;
|
||||
using ErsatzTV.Core.Interfaces.Emby;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Repositories.Caching;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
using MediatR;
|
||||
@@ -25,7 +26,8 @@ public class EmbyMovieLibraryScanner :
|
||||
IMediator mediator,
|
||||
IMediaSourceRepository mediaSourceRepository,
|
||||
IEmbyMovieRepository embyMovieRepository,
|
||||
ISearchRepository searchRepository,
|
||||
ICachingSearchRepository searchRepository,
|
||||
IFallbackMetadataProvider fallbackMetadataProvider,
|
||||
IEmbyPathReplacementService pathReplacementService,
|
||||
ILocalFileSystem localFileSystem,
|
||||
ILocalStatisticsProvider localStatisticsProvider,
|
||||
@@ -38,6 +40,7 @@ public class EmbyMovieLibraryScanner :
|
||||
mediator,
|
||||
searchIndex,
|
||||
searchRepository,
|
||||
fallbackMetadataProvider,
|
||||
logger)
|
||||
{
|
||||
_embyApiClient = embyApiClient;
|
||||
@@ -79,7 +82,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(
|
||||
|
||||
@@ -3,6 +3,7 @@ using ErsatzTV.Core.Extensions;
|
||||
using ErsatzTV.Core.Interfaces.Emby;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Repositories.Caching;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
using MediatR;
|
||||
@@ -24,7 +25,8 @@ public class EmbyTelevisionLibraryScanner : MediaServerTelevisionLibraryScanner<
|
||||
IMediaSourceRepository mediaSourceRepository,
|
||||
IEmbyTelevisionRepository televisionRepository,
|
||||
ISearchIndex searchIndex,
|
||||
ISearchRepository searchRepository,
|
||||
ICachingSearchRepository searchRepository,
|
||||
IFallbackMetadataProvider fallbackMetadataProvider,
|
||||
IEmbyPathReplacementService pathReplacementService,
|
||||
ILocalFileSystem localFileSystem,
|
||||
ILocalStatisticsProvider localStatisticsProvider,
|
||||
@@ -37,6 +39,7 @@ public class EmbyTelevisionLibraryScanner : MediaServerTelevisionLibraryScanner<
|
||||
localFileSystem,
|
||||
searchRepository,
|
||||
searchIndex,
|
||||
fallbackMetadataProvider,
|
||||
mediator,
|
||||
logger)
|
||||
{
|
||||
@@ -76,13 +79,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 +101,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.9" />
|
||||
<PackageReference Include="LanguageExt.Transformers" Version="4.2.9" />
|
||||
<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,
|
||||
|
||||
@@ -148,14 +148,11 @@ public class FFmpegPlaybackSettingsCalculator
|
||||
result.AudioBitrate = ffmpegProfile.AudioBitrate;
|
||||
result.AudioBufferSize = ffmpegProfile.AudioBufferSize;
|
||||
|
||||
audioStream.IfSome(
|
||||
stream =>
|
||||
{
|
||||
if (stream.Channels != ffmpegProfile.AudioChannels)
|
||||
{
|
||||
result.AudioChannels = ffmpegProfile.AudioChannels;
|
||||
}
|
||||
});
|
||||
foreach (MediaStream _ in audioStream)
|
||||
{
|
||||
// this can be optimized out later, depending on the audio codec
|
||||
result.AudioChannels = ffmpegProfile.AudioChannels;
|
||||
}
|
||||
|
||||
result.AudioSampleRate = ffmpegProfile.AudioSampleRate;
|
||||
result.AudioDuration = outPoint - inPoint;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
namespace ErsatzTV.Core.Interfaces.Repositories.Caching;
|
||||
|
||||
public interface ICachingSearchRepository : ISearchRepository
|
||||
{
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -22,12 +22,7 @@ public interface ITelevisionRepository
|
||||
Task<int> GetEpisodeCount(int seasonId);
|
||||
Task<List<EpisodeMetadata>> GetPagedEpisodes(int seasonId, int pageNumber, int pageSize);
|
||||
Task<Option<Show>> GetShowByMetadata(int libraryPathId, ShowMetadata metadata);
|
||||
|
||||
Task<Either<BaseError, MediaItemScanResult<Show>>> AddShow(
|
||||
int libraryPathId,
|
||||
string showFolder,
|
||||
ShowMetadata metadata);
|
||||
|
||||
Task<Either<BaseError, MediaItemScanResult<Show>>> AddShow(int libraryPathId, ShowMetadata metadata);
|
||||
Task<Either<BaseError, Season>> GetOrAddSeason(Show show, int libraryPathId, int seasonNumber);
|
||||
Task<Either<BaseError, Episode>> GetOrAddEpisode(Season season, LibraryPath libraryPath, string path);
|
||||
Task<IEnumerable<string>> FindEpisodePaths(LibraryPath libraryPath);
|
||||
@@ -42,5 +37,7 @@ public interface ITelevisionRepository
|
||||
Task<Unit> RemoveMetadata(Episode episode, EpisodeMetadata metadata);
|
||||
Task<bool> AddDirector(EpisodeMetadata metadata, Director director);
|
||||
Task<bool> AddWriter(EpisodeMetadata metadata, Writer writer);
|
||||
Task<Unit> UpdatePath(int mediaFileId, string path);
|
||||
Task<bool> UpdateTitles(EpisodeMetadata metadata, string title, string sortTitle);
|
||||
Task<bool> UpdateOutline(EpisodeMetadata metadata, string outline);
|
||||
Task<bool> UpdatePlot(EpisodeMetadata metadata, string plot);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Repositories.Caching;
|
||||
using ErsatzTV.Core.Search;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Search;
|
||||
@@ -9,9 +10,18 @@ public interface ISearchIndex : IDisposable
|
||||
{
|
||||
public int Version { get; }
|
||||
Task<bool> Initialize(ILocalFileSystem localFileSystem, IConfigElementRepository configElementRepository);
|
||||
Task<Unit> Rebuild(ISearchRepository searchRepository);
|
||||
Task<Unit> RebuildItems(ISearchRepository searchRepository, List<int> itemIds);
|
||||
Task<Unit> UpdateItems(ISearchRepository searchRepository, List<MediaItem> items);
|
||||
Task<Unit> Rebuild(ICachingSearchRepository searchRepository, IFallbackMetadataProvider fallbackMetadataProvider);
|
||||
|
||||
Task<Unit> RebuildItems(
|
||||
ICachingSearchRepository searchRepository,
|
||||
IFallbackMetadataProvider fallbackMetadataProvider,
|
||||
List<int> itemIds);
|
||||
|
||||
Task<Unit> UpdateItems(
|
||||
ICachingSearchRepository searchRepository,
|
||||
IFallbackMetadataProvider fallbackMetadataProvider,
|
||||
List<MediaItem> items);
|
||||
|
||||
Task<Unit> RemoveItems(List<int> ids);
|
||||
Task<SearchResult> Search(string query, int skip, int limit, string searchField = "");
|
||||
void Commit();
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Jellyfin;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Repositories.Caching;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
@@ -8,46 +10,46 @@ namespace ErsatzTV.Core.Jellyfin;
|
||||
|
||||
public class JellyfinCollectionScanner : IJellyfinCollectionScanner
|
||||
{
|
||||
private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
|
||||
private readonly IJellyfinApiClient _jellyfinApiClient;
|
||||
private readonly IJellyfinCollectionRepository _jellyfinCollectionRepository;
|
||||
private readonly ILogger<JellyfinCollectionScanner> _logger;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
private readonly ISearchRepository _searchRepository;
|
||||
private readonly ICachingSearchRepository _searchRepository;
|
||||
|
||||
public JellyfinCollectionScanner(
|
||||
IJellyfinCollectionRepository jellyfinCollectionRepository,
|
||||
IJellyfinApiClient jellyfinApiClient,
|
||||
ISearchRepository searchRepository,
|
||||
ICachingSearchRepository searchRepository,
|
||||
ISearchIndex searchIndex,
|
||||
IFallbackMetadataProvider fallbackMetadataProvider,
|
||||
ILogger<JellyfinCollectionScanner> logger)
|
||||
{
|
||||
_jellyfinCollectionRepository = jellyfinCollectionRepository;
|
||||
_jellyfinApiClient = jellyfinApiClient;
|
||||
_searchRepository = searchRepository;
|
||||
_searchIndex = searchIndex;
|
||||
_fallbackMetadataProvider = fallbackMetadataProvider;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
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 +77,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 +98,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, _fallbackMetadataProvider, 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";
|
||||
}
|
||||
@@ -3,6 +3,7 @@ using ErsatzTV.Core.Extensions;
|
||||
using ErsatzTV.Core.Interfaces.Jellyfin;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Repositories.Caching;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
using MediatR;
|
||||
@@ -24,7 +25,8 @@ public class JellyfinMovieLibraryScanner :
|
||||
ISearchIndex searchIndex,
|
||||
IMediator mediator,
|
||||
IJellyfinMovieRepository jellyfinMovieRepository,
|
||||
ISearchRepository searchRepository,
|
||||
ICachingSearchRepository searchRepository,
|
||||
IFallbackMetadataProvider fallbackMetadataProvider,
|
||||
IJellyfinPathReplacementService pathReplacementService,
|
||||
IMediaSourceRepository mediaSourceRepository,
|
||||
ILocalFileSystem localFileSystem,
|
||||
@@ -38,6 +40,7 @@ public class JellyfinMovieLibraryScanner :
|
||||
mediator,
|
||||
searchIndex,
|
||||
searchRepository,
|
||||
fallbackMetadataProvider,
|
||||
logger)
|
||||
{
|
||||
_jellyfinApiClient = jellyfinApiClient;
|
||||
@@ -80,7 +83,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(
|
||||
|
||||
@@ -3,6 +3,7 @@ using ErsatzTV.Core.Extensions;
|
||||
using ErsatzTV.Core.Interfaces.Jellyfin;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Repositories.Caching;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
using MediatR;
|
||||
@@ -25,7 +26,8 @@ public class JellyfinTelevisionLibraryScanner : MediaServerTelevisionLibraryScan
|
||||
IMediaSourceRepository mediaSourceRepository,
|
||||
IJellyfinTelevisionRepository televisionRepository,
|
||||
ISearchIndex searchIndex,
|
||||
ISearchRepository searchRepository,
|
||||
ICachingSearchRepository searchRepository,
|
||||
IFallbackMetadataProvider fallbackMetadataProvider,
|
||||
IJellyfinPathReplacementService pathReplacementService,
|
||||
ILocalFileSystem localFileSystem,
|
||||
ILocalStatisticsProvider localStatisticsProvider,
|
||||
@@ -38,6 +40,7 @@ public class JellyfinTelevisionLibraryScanner : MediaServerTelevisionLibraryScan
|
||||
localFileSystem,
|
||||
searchRepository,
|
||||
searchIndex,
|
||||
fallbackMetadataProvider,
|
||||
mediator,
|
||||
logger)
|
||||
{
|
||||
@@ -77,14 +80,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 +104,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,
|
||||
|
||||
@@ -161,8 +161,14 @@ public class FallbackMetadataProvider : IFallbackMetadataProvider
|
||||
|
||||
try
|
||||
{
|
||||
const string PATTERN = @"[sS]\d+[eE]([e\-\d{1,2}]+)";
|
||||
const string PATTERN = @"[sS]\d+[\._xX]?[eE]([e\-\d{1,2}]+)";
|
||||
const string PATTERN_2 = @"\d+[\._xX]([e\-\d{1,2}]+)";
|
||||
MatchCollection matches = Regex.Matches(fileName, PATTERN);
|
||||
if (matches.Count == 0)
|
||||
{
|
||||
matches = Regex.Matches(fileName, PATTERN_2);
|
||||
}
|
||||
|
||||
if (matches.Count > 0)
|
||||
{
|
||||
foreach (Match match in matches)
|
||||
|
||||
@@ -3,6 +3,7 @@ using ErsatzTV.Core.Domain.MediaServer;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Repositories.Caching;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -15,13 +16,14 @@ public abstract class MediaServerMovieLibraryScanner<TConnectionParameters, TLib
|
||||
where TMovie : Movie
|
||||
where TEtag : MediaServerItemEtag
|
||||
{
|
||||
private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly ILocalStatisticsProvider _localStatisticsProvider;
|
||||
private readonly ILocalSubtitlesProvider _localSubtitlesProvider;
|
||||
private readonly ILogger _logger;
|
||||
private readonly IMediator _mediator;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
private readonly ISearchRepository _searchRepository;
|
||||
private readonly ICachingSearchRepository _searchRepository;
|
||||
|
||||
protected MediaServerMovieLibraryScanner(
|
||||
ILocalStatisticsProvider localStatisticsProvider,
|
||||
@@ -29,7 +31,8 @@ public abstract class MediaServerMovieLibraryScanner<TConnectionParameters, TLib
|
||||
ILocalFileSystem localFileSystem,
|
||||
IMediator mediator,
|
||||
ISearchIndex searchIndex,
|
||||
ISearchRepository searchRepository,
|
||||
ICachingSearchRepository searchRepository,
|
||||
IFallbackMetadataProvider fallbackMetadataProvider,
|
||||
ILogger logger)
|
||||
{
|
||||
_localStatisticsProvider = localStatisticsProvider;
|
||||
@@ -38,6 +41,7 @@ public abstract class MediaServerMovieLibraryScanner<TConnectionParameters, TLib
|
||||
_mediator = mediator;
|
||||
_searchIndex = searchIndex;
|
||||
_searchRepository = searchRepository;
|
||||
_fallbackMetadataProvider = fallbackMetadataProvider;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -53,23 +57,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 +98,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);
|
||||
@@ -159,16 +172,18 @@ public abstract class MediaServerMovieLibraryScanner<TConnectionParameters, TLib
|
||||
|
||||
if (result.IsAdded || result.IsUpdated)
|
||||
{
|
||||
await _searchIndex.RebuildItems(_searchRepository, new List<int> { result.Item.Id });
|
||||
await _searchIndex.RebuildItems(
|
||||
_searchRepository,
|
||||
_fallbackMetadataProvider,
|
||||
new List<int> { result.Item.Id });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
await _searchIndex.RebuildItems(_searchRepository, _fallbackMetadataProvider, ids);
|
||||
|
||||
await _mediator.Publish(new LibraryScanProgress(library.Id, 0), cancellationToken);
|
||||
|
||||
@@ -178,7 +193,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 +231,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;
|
||||
@@ -228,7 +248,7 @@ public abstract class MediaServerMovieLibraryScanner<TConnectionParameters, TLib
|
||||
{
|
||||
foreach (int id in await movieRepository.FlagUnavailable(library, incoming))
|
||||
{
|
||||
await _searchIndex.RebuildItems(_searchRepository, new List<int> { id });
|
||||
await _searchIndex.RebuildItems(_searchRepository, _fallbackMetadataProvider, new List<int> { id });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ using ErsatzTV.Core.Domain.MediaServer;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Repositories.Caching;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -18,20 +19,22 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
|
||||
where TEpisode : Episode
|
||||
where TEtag : MediaServerItemEtag
|
||||
{
|
||||
private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly ILocalStatisticsProvider _localStatisticsProvider;
|
||||
private readonly ILocalSubtitlesProvider _localSubtitlesProvider;
|
||||
private readonly ILogger _logger;
|
||||
private readonly IMediator _mediator;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
private readonly ISearchRepository _searchRepository;
|
||||
private readonly ICachingSearchRepository _searchRepository;
|
||||
|
||||
protected MediaServerTelevisionLibraryScanner(
|
||||
ILocalStatisticsProvider localStatisticsProvider,
|
||||
ILocalSubtitlesProvider localSubtitlesProvider,
|
||||
ILocalFileSystem localFileSystem,
|
||||
ISearchRepository searchRepository,
|
||||
ICachingSearchRepository searchRepository,
|
||||
ISearchIndex searchIndex,
|
||||
IFallbackMetadataProvider fallbackMetadataProvider,
|
||||
IMediator mediator,
|
||||
ILogger logger)
|
||||
{
|
||||
@@ -40,6 +43,7 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
|
||||
_localFileSystem = localFileSystem;
|
||||
_searchRepository = searchRepository;
|
||||
_searchIndex = searchIndex;
|
||||
_fallbackMetadataProvider = fallbackMetadataProvider;
|
||||
_mediator = mediator;
|
||||
_logger = logger;
|
||||
}
|
||||
@@ -56,23 +60,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 +96,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 +118,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 +157,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 +182,7 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
|
||||
connectionParameters,
|
||||
ffmpegPath,
|
||||
ffprobePath,
|
||||
entries.RightToSeq().Flatten().ToList(),
|
||||
GetSeasonLibraryItems(library, connectionParameters, result.Item),
|
||||
deepScan,
|
||||
cancellationToken);
|
||||
|
||||
@@ -167,32 +193,50 @@ 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 });
|
||||
await _searchIndex.RebuildItems(
|
||||
_searchRepository,
|
||||
_fallbackMetadataProvider,
|
||||
new List<int> { result.Item.Id });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
await _searchIndex.RebuildItems(_searchRepository, _fallbackMetadataProvider, ids);
|
||||
|
||||
await _mediator.Publish(new LibraryScanProgress(library.Id, 0), cancellationToken);
|
||||
|
||||
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 +280,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 +296,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 +318,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 +345,7 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
|
||||
connectionParameters,
|
||||
ffmpegPath,
|
||||
ffprobePath,
|
||||
entries.RightToSeq().Flatten().ToList(),
|
||||
GetEpisodeLibraryItems(library, connectionParameters, show, result.Item),
|
||||
deepScan,
|
||||
cancellationToken);
|
||||
|
||||
@@ -302,20 +356,27 @@ 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)
|
||||
{
|
||||
await _searchIndex.RebuildItems(_searchRepository, new List<int> { result.Item.Id });
|
||||
await _searchIndex.RebuildItems(
|
||||
_searchRepository,
|
||||
_fallbackMetadataProvider,
|
||||
new List<int> { result.Item.Id });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
await _searchIndex.RebuildItems(_searchRepository, _fallbackMetadataProvider, ids);
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
@@ -329,20 +390,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,
|
||||
@@ -408,16 +471,18 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
|
||||
|
||||
if (result.IsAdded || result.IsUpdated)
|
||||
{
|
||||
await _searchIndex.RebuildItems(_searchRepository, new List<int> { result.Item.Id });
|
||||
await _searchIndex.RebuildItems(
|
||||
_searchRepository,
|
||||
_fallbackMetadataProvider,
|
||||
new List<int> { result.Item.Id });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
await _searchIndex.RebuildItems(_searchRepository, _fallbackMetadataProvider, ids);
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
@@ -442,9 +507,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;
|
||||
@@ -458,7 +524,7 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
|
||||
{
|
||||
foreach (int id in await televisionRepository.FlagUnavailable(library, incoming))
|
||||
{
|
||||
await _searchIndex.RebuildItems(_searchRepository, new List<int> { id });
|
||||
await _searchIndex.RebuildItems(_searchRepository, _fallbackMetadataProvider, new List<int> { id });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Images;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Repositories.Caching;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -15,6 +16,7 @@ namespace ErsatzTV.Core.Metadata;
|
||||
public class MovieFolderScanner : LocalFolderScanner, IMovieFolderScanner
|
||||
{
|
||||
private readonly IClient _client;
|
||||
private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
|
||||
private readonly ILibraryRepository _libraryRepository;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly ILocalMetadataProvider _localMetadataProvider;
|
||||
@@ -23,7 +25,7 @@ public class MovieFolderScanner : LocalFolderScanner, IMovieFolderScanner
|
||||
private readonly IMediator _mediator;
|
||||
private readonly IMovieRepository _movieRepository;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
private readonly ISearchRepository _searchRepository;
|
||||
private readonly ICachingSearchRepository _searchRepository;
|
||||
|
||||
public MovieFolderScanner(
|
||||
ILocalFileSystem localFileSystem,
|
||||
@@ -34,7 +36,8 @@ public class MovieFolderScanner : LocalFolderScanner, IMovieFolderScanner
|
||||
IMetadataRepository metadataRepository,
|
||||
IImageCache imageCache,
|
||||
ISearchIndex searchIndex,
|
||||
ISearchRepository searchRepository,
|
||||
ICachingSearchRepository searchRepository,
|
||||
IFallbackMetadataProvider fallbackMetadataProvider,
|
||||
ILibraryRepository libraryRepository,
|
||||
IMediaItemRepository mediaItemRepository,
|
||||
IMediator mediator,
|
||||
@@ -59,6 +62,7 @@ public class MovieFolderScanner : LocalFolderScanner, IMovieFolderScanner
|
||||
_localMetadataProvider = localMetadataProvider;
|
||||
_searchIndex = searchIndex;
|
||||
_searchRepository = searchRepository;
|
||||
_fallbackMetadataProvider = fallbackMetadataProvider;
|
||||
_libraryRepository = libraryRepository;
|
||||
_mediator = mediator;
|
||||
_client = client;
|
||||
@@ -160,7 +164,10 @@ public class MovieFolderScanner : LocalFolderScanner, IMovieFolderScanner
|
||||
{
|
||||
if (result.IsAdded || result.IsUpdated)
|
||||
{
|
||||
await _searchIndex.RebuildItems(_searchRepository, new List<int> { result.Item.Id });
|
||||
await _searchIndex.RebuildItems(
|
||||
_searchRepository,
|
||||
_fallbackMetadataProvider,
|
||||
new List<int> { result.Item.Id });
|
||||
}
|
||||
|
||||
await _libraryRepository.SetEtag(libraryPath, knownFolder, movieFolder, etag);
|
||||
@@ -174,7 +181,7 @@ public class MovieFolderScanner : LocalFolderScanner, IMovieFolderScanner
|
||||
{
|
||||
_logger.LogInformation("Flagging missing movie at {Path}", path);
|
||||
List<int> ids = await FlagFileNotFound(libraryPath, path);
|
||||
await _searchIndex.RebuildItems(_searchRepository, ids);
|
||||
await _searchIndex.RebuildItems(_searchRepository, _fallbackMetadataProvider, ids);
|
||||
}
|
||||
else if (Path.GetFileName(path).StartsWith("._"))
|
||||
{
|
||||
|
||||
@@ -5,6 +5,7 @@ using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Images;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Repositories.Caching;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -15,6 +16,7 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan
|
||||
{
|
||||
private readonly IArtistRepository _artistRepository;
|
||||
private readonly IClient _client;
|
||||
private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
|
||||
private readonly ILibraryRepository _libraryRepository;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly ILocalMetadataProvider _localMetadataProvider;
|
||||
@@ -23,7 +25,7 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan
|
||||
private readonly IMediator _mediator;
|
||||
private readonly IMusicVideoRepository _musicVideoRepository;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
private readonly ISearchRepository _searchRepository;
|
||||
private readonly ICachingSearchRepository _searchRepository;
|
||||
|
||||
public MusicVideoFolderScanner(
|
||||
ILocalFileSystem localFileSystem,
|
||||
@@ -33,7 +35,8 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan
|
||||
IMetadataRepository metadataRepository,
|
||||
IImageCache imageCache,
|
||||
ISearchIndex searchIndex,
|
||||
ISearchRepository searchRepository,
|
||||
ICachingSearchRepository searchRepository,
|
||||
IFallbackMetadataProvider fallbackMetadataProvider,
|
||||
IArtistRepository artistRepository,
|
||||
IMusicVideoRepository musicVideoRepository,
|
||||
ILibraryRepository libraryRepository,
|
||||
@@ -58,6 +61,7 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan
|
||||
_localSubtitlesProvider = localSubtitlesProvider;
|
||||
_searchIndex = searchIndex;
|
||||
_searchRepository = searchRepository;
|
||||
_fallbackMetadataProvider = fallbackMetadataProvider;
|
||||
_artistRepository = artistRepository;
|
||||
_musicVideoRepository = musicVideoRepository;
|
||||
_libraryRepository = libraryRepository;
|
||||
@@ -137,7 +141,10 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan
|
||||
|
||||
if (result.IsAdded || result.IsUpdated)
|
||||
{
|
||||
await _searchIndex.RebuildItems(_searchRepository, new List<int> { result.Item.Id });
|
||||
await _searchIndex.RebuildItems(
|
||||
_searchRepository,
|
||||
_fallbackMetadataProvider,
|
||||
new List<int> { result.Item.Id });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -155,7 +162,7 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan
|
||||
{
|
||||
_logger.LogInformation("Flagging missing music video at {Path}", path);
|
||||
List<int> musicVideoIds = await FlagFileNotFound(libraryPath, path);
|
||||
await _searchIndex.RebuildItems(_searchRepository, musicVideoIds);
|
||||
await _searchIndex.RebuildItems(_searchRepository, _fallbackMetadataProvider, musicVideoIds);
|
||||
}
|
||||
else if (Path.GetFileName(path).StartsWith("._"))
|
||||
{
|
||||
@@ -330,7 +337,10 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan
|
||||
{
|
||||
if (result.IsAdded || result.IsUpdated)
|
||||
{
|
||||
await _searchIndex.RebuildItems(_searchRepository, new List<int> { result.Item.Id });
|
||||
await _searchIndex.RebuildItems(
|
||||
_searchRepository,
|
||||
_fallbackMetadataProvider,
|
||||
new List<int> { result.Item.Id });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Images;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Repositories.Caching;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -14,6 +15,7 @@ namespace ErsatzTV.Core.Metadata;
|
||||
public class OtherVideoFolderScanner : LocalFolderScanner, IOtherVideoFolderScanner
|
||||
{
|
||||
private readonly IClient _client;
|
||||
private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
|
||||
private readonly ILibraryRepository _libraryRepository;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly ILocalMetadataProvider _localMetadataProvider;
|
||||
@@ -22,7 +24,7 @@ public class OtherVideoFolderScanner : LocalFolderScanner, IOtherVideoFolderScan
|
||||
private readonly IMediator _mediator;
|
||||
private readonly IOtherVideoRepository _otherVideoRepository;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
private readonly ISearchRepository _searchRepository;
|
||||
private readonly ICachingSearchRepository _searchRepository;
|
||||
|
||||
public OtherVideoFolderScanner(
|
||||
ILocalFileSystem localFileSystem,
|
||||
@@ -33,7 +35,8 @@ public class OtherVideoFolderScanner : LocalFolderScanner, IOtherVideoFolderScan
|
||||
IImageCache imageCache,
|
||||
IMediator mediator,
|
||||
ISearchIndex searchIndex,
|
||||
ISearchRepository searchRepository,
|
||||
ICachingSearchRepository searchRepository,
|
||||
IFallbackMetadataProvider fallbackMetadataProvider,
|
||||
IOtherVideoRepository otherVideoRepository,
|
||||
ILibraryRepository libraryRepository,
|
||||
IMediaItemRepository mediaItemRepository,
|
||||
@@ -57,6 +60,7 @@ public class OtherVideoFolderScanner : LocalFolderScanner, IOtherVideoFolderScan
|
||||
_mediator = mediator;
|
||||
_searchIndex = searchIndex;
|
||||
_searchRepository = searchRepository;
|
||||
_fallbackMetadataProvider = fallbackMetadataProvider;
|
||||
_otherVideoRepository = otherVideoRepository;
|
||||
_libraryRepository = libraryRepository;
|
||||
_client = client;
|
||||
@@ -160,7 +164,10 @@ public class OtherVideoFolderScanner : LocalFolderScanner, IOtherVideoFolderScan
|
||||
{
|
||||
if (result.IsAdded || result.IsUpdated)
|
||||
{
|
||||
await _searchIndex.RebuildItems(_searchRepository, new List<int> { result.Item.Id });
|
||||
await _searchIndex.RebuildItems(
|
||||
_searchRepository,
|
||||
_fallbackMetadataProvider,
|
||||
new List<int> { result.Item.Id });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -178,7 +185,7 @@ public class OtherVideoFolderScanner : LocalFolderScanner, IOtherVideoFolderScan
|
||||
{
|
||||
_logger.LogInformation("Flagging missing other video at {Path}", path);
|
||||
List<int> otherVideoIds = await FlagFileNotFound(libraryPath, path);
|
||||
await _searchIndex.RebuildItems(_searchRepository, otherVideoIds);
|
||||
await _searchIndex.RebuildItems(_searchRepository, _fallbackMetadataProvider, otherVideoIds);
|
||||
}
|
||||
else if (Path.GetFileName(path).StartsWith("._"))
|
||||
{
|
||||
|
||||
@@ -6,6 +6,7 @@ using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Images;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Repositories.Caching;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -15,13 +16,14 @@ namespace ErsatzTV.Core.Metadata;
|
||||
public class SongFolderScanner : LocalFolderScanner, ISongFolderScanner
|
||||
{
|
||||
private readonly IClient _client;
|
||||
private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
|
||||
private readonly ILibraryRepository _libraryRepository;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly ILocalMetadataProvider _localMetadataProvider;
|
||||
private readonly ILogger<SongFolderScanner> _logger;
|
||||
private readonly IMediator _mediator;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
private readonly ISearchRepository _searchRepository;
|
||||
private readonly ICachingSearchRepository _searchRepository;
|
||||
private readonly ISongRepository _songRepository;
|
||||
|
||||
public SongFolderScanner(
|
||||
@@ -32,7 +34,8 @@ public class SongFolderScanner : LocalFolderScanner, ISongFolderScanner
|
||||
IImageCache imageCache,
|
||||
IMediator mediator,
|
||||
ISearchIndex searchIndex,
|
||||
ISearchRepository searchRepository,
|
||||
ICachingSearchRepository searchRepository,
|
||||
IFallbackMetadataProvider fallbackMetadataProvider,
|
||||
ISongRepository songRepository,
|
||||
ILibraryRepository libraryRepository,
|
||||
IMediaItemRepository mediaItemRepository,
|
||||
@@ -55,6 +58,7 @@ public class SongFolderScanner : LocalFolderScanner, ISongFolderScanner
|
||||
_mediator = mediator;
|
||||
_searchIndex = searchIndex;
|
||||
_searchRepository = searchRepository;
|
||||
_fallbackMetadataProvider = fallbackMetadataProvider;
|
||||
_songRepository = songRepository;
|
||||
_libraryRepository = libraryRepository;
|
||||
_client = client;
|
||||
@@ -155,7 +159,10 @@ public class SongFolderScanner : LocalFolderScanner, ISongFolderScanner
|
||||
{
|
||||
if (result.IsAdded || result.IsUpdated)
|
||||
{
|
||||
await _searchIndex.RebuildItems(_searchRepository, new List<int> { result.Item.Id });
|
||||
await _searchIndex.RebuildItems(
|
||||
_searchRepository,
|
||||
_fallbackMetadataProvider,
|
||||
new List<int> { result.Item.Id });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -173,7 +180,7 @@ public class SongFolderScanner : LocalFolderScanner, ISongFolderScanner
|
||||
{
|
||||
_logger.LogInformation("Flagging missing song at {Path}", path);
|
||||
List<int> songIds = await FlagFileNotFound(libraryPath, path);
|
||||
await _searchIndex.RebuildItems(_searchRepository, songIds);
|
||||
await _searchIndex.RebuildItems(_searchRepository, _fallbackMetadataProvider, songIds);
|
||||
}
|
||||
else if (Path.GetFileName(path).StartsWith("._"))
|
||||
{
|
||||
|
||||
@@ -5,6 +5,7 @@ using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Images;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Repositories.Caching;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -14,6 +15,7 @@ namespace ErsatzTV.Core.Metadata;
|
||||
public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScanner
|
||||
{
|
||||
private readonly IClient _client;
|
||||
private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
|
||||
private readonly ILibraryRepository _libraryRepository;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly ILocalMetadataProvider _localMetadataProvider;
|
||||
@@ -22,7 +24,7 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan
|
||||
private readonly IMediator _mediator;
|
||||
private readonly IMetadataRepository _metadataRepository;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
private readonly ISearchRepository _searchRepository;
|
||||
private readonly ICachingSearchRepository _searchRepository;
|
||||
private readonly ITelevisionRepository _televisionRepository;
|
||||
|
||||
public TelevisionFolderScanner(
|
||||
@@ -30,11 +32,12 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan
|
||||
ITelevisionRepository televisionRepository,
|
||||
ILocalStatisticsProvider localStatisticsProvider,
|
||||
ILocalMetadataProvider localMetadataProvider,
|
||||
IFallbackMetadataProvider fallbackMetadataProvider,
|
||||
ILocalSubtitlesProvider localSubtitlesProvider,
|
||||
IMetadataRepository metadataRepository,
|
||||
IImageCache imageCache,
|
||||
ISearchIndex searchIndex,
|
||||
ISearchRepository searchRepository,
|
||||
ICachingSearchRepository searchRepository,
|
||||
ILibraryRepository libraryRepository,
|
||||
IMediaItemRepository mediaItemRepository,
|
||||
IMediator mediator,
|
||||
@@ -55,6 +58,7 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan
|
||||
_localFileSystem = localFileSystem;
|
||||
_televisionRepository = televisionRepository;
|
||||
_localMetadataProvider = localMetadataProvider;
|
||||
_fallbackMetadataProvider = fallbackMetadataProvider;
|
||||
_localSubtitlesProvider = localSubtitlesProvider;
|
||||
_metadataRepository = metadataRepository;
|
||||
_searchIndex = searchIndex;
|
||||
@@ -127,7 +131,10 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan
|
||||
|
||||
if (result.IsAdded || result.IsUpdated)
|
||||
{
|
||||
await _searchIndex.RebuildItems(_searchRepository, new List<int> { result.Item.Id });
|
||||
await _searchIndex.RebuildItems(
|
||||
_searchRepository,
|
||||
_fallbackMetadataProvider,
|
||||
new List<int> { result.Item.Id });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -137,8 +144,9 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan
|
||||
if (!_localFileSystem.FileExists(path))
|
||||
{
|
||||
_logger.LogInformation("Flagging missing episode at {Path}", path);
|
||||
|
||||
List<int> episodeIds = await FlagFileNotFound(libraryPath, path);
|
||||
await _searchIndex.RebuildItems(_searchRepository, episodeIds);
|
||||
await _searchIndex.RebuildItems(_searchRepository, _fallbackMetadataProvider, episodeIds);
|
||||
}
|
||||
else if (Path.GetFileName(path).StartsWith("._"))
|
||||
{
|
||||
@@ -177,7 +185,7 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan
|
||||
return new MediaItemScanResult<Show>(show);
|
||||
}
|
||||
|
||||
return await _televisionRepository.AddShow(libraryPathId, showFolder, metadata);
|
||||
return await _televisionRepository.AddShow(libraryPathId, metadata);
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, Unit>> ScanSeasons(
|
||||
@@ -241,7 +249,10 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan
|
||||
await _libraryRepository.SetEtag(libraryPath, knownFolder, seasonFolder, etag);
|
||||
|
||||
season.Show = show;
|
||||
await _searchIndex.RebuildItems(_searchRepository, new List<int> { season.Id });
|
||||
await _searchIndex.RebuildItems(
|
||||
_searchRepository,
|
||||
_fallbackMetadataProvider,
|
||||
new List<int> { season.Id });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -287,7 +298,10 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan
|
||||
|
||||
foreach (Episode episode in maybeEpisode.RightToSeq())
|
||||
{
|
||||
await _searchIndex.RebuildItems(_searchRepository, new List<int> { episode.Id });
|
||||
await _searchIndex.RebuildItems(
|
||||
_searchRepository,
|
||||
_fallbackMetadataProvider,
|
||||
new List<int> { episode.Id });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ using ErsatzTV.Core.Extensions;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Plex;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Repositories.Caching;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
using MediatR;
|
||||
@@ -27,7 +28,8 @@ public class PlexMovieLibraryScanner :
|
||||
IMovieRepository movieRepository,
|
||||
IMetadataRepository metadataRepository,
|
||||
ISearchIndex searchIndex,
|
||||
ISearchRepository searchRepository,
|
||||
ICachingSearchRepository searchRepository,
|
||||
IFallbackMetadataProvider fallbackMetadataProvider,
|
||||
IMediator mediator,
|
||||
IMediaSourceRepository mediaSourceRepository,
|
||||
IPlexMovieRepository plexMovieRepository,
|
||||
@@ -43,6 +45,7 @@ public class PlexMovieLibraryScanner :
|
||||
mediator,
|
||||
searchIndex,
|
||||
searchRepository,
|
||||
fallbackMetadataProvider,
|
||||
logger)
|
||||
{
|
||||
_plexServerApiClient = plexServerApiClient;
|
||||
@@ -89,7 +92,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(
|
||||
|
||||
@@ -3,6 +3,7 @@ using ErsatzTV.Core.Extensions;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Plex;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Repositories.Caching;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
using MediatR;
|
||||
@@ -27,7 +28,8 @@ public class PlexTelevisionLibraryScanner :
|
||||
ITelevisionRepository televisionRepository,
|
||||
IMetadataRepository metadataRepository,
|
||||
ISearchIndex searchIndex,
|
||||
ISearchRepository searchRepository,
|
||||
ICachingSearchRepository searchRepository,
|
||||
IFallbackMetadataProvider fallbackMetadataProvider,
|
||||
IMediator mediator,
|
||||
IMediaSourceRepository mediaSourceRepository,
|
||||
IPlexPathReplacementService plexPathReplacementService,
|
||||
@@ -42,6 +44,7 @@ public class PlexTelevisionLibraryScanner :
|
||||
localFileSystem,
|
||||
searchRepository,
|
||||
searchIndex,
|
||||
fallbackMetadataProvider,
|
||||
mediator,
|
||||
logger)
|
||||
{
|
||||
@@ -137,7 +140,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 +156,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 +175,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,
|
||||
@@ -493,6 +523,34 @@ public class PlexTelevisionLibraryScanner :
|
||||
}
|
||||
}
|
||||
|
||||
if (fullMetadata.SortTitle != existingMetadata.SortTitle || fullMetadata.Title != existingMetadata.Title)
|
||||
{
|
||||
existingMetadata.Title = fullMetadata.Title;
|
||||
existingMetadata.SortTitle = fullMetadata.SortTitle;
|
||||
if (await _televisionRepository.UpdateTitles(existingMetadata, fullMetadata.Title, fullMetadata.SortTitle))
|
||||
{
|
||||
result.IsUpdated = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (fullMetadata.Outline != existingMetadata.Outline)
|
||||
{
|
||||
existingMetadata.Outline = fullMetadata.Outline;
|
||||
if (await _televisionRepository.UpdateOutline(existingMetadata, fullMetadata.Outline))
|
||||
{
|
||||
result.IsUpdated = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (fullMetadata.Plot != existingMetadata.Plot)
|
||||
{
|
||||
existingMetadata.Plot = fullMetadata.Plot;
|
||||
if (await _televisionRepository.UpdatePlot(existingMetadata, fullMetadata.Plot))
|
||||
{
|
||||
result.IsUpdated = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (await UpdateArtworkIfNeeded(existingMetadata, fullMetadata, ArtworkKind.Thumbnail))
|
||||
{
|
||||
result.IsUpdated = true;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -626,9 +626,7 @@ public class PlayoutBuilder : IPlayoutBuilder
|
||||
&& a.MediaItemId == collectionKey.MediaItemId
|
||||
&& a.AnchorDate is null);
|
||||
|
||||
var maybeEnumeratorState = collectionEnumerators.GroupBy(e => e.Key, e => e.Value.State).ToDictionary(
|
||||
mcs => mcs.Key,
|
||||
mcs => mcs.Head());
|
||||
var maybeEnumeratorState = collectionEnumerators.ToDictionary(e => e.Key, e => e.Value.State);
|
||||
|
||||
PlayoutProgramScheduleAnchor scheduleAnchor = maybeExisting.Match(
|
||||
existing =>
|
||||
@@ -658,10 +656,10 @@ public class PlayoutBuilder : IPlayoutBuilder
|
||||
result.Add(scheduleAnchor);
|
||||
}
|
||||
|
||||
foreach (PlayoutProgramScheduleAnchor continueAnchor in playout.ProgramScheduleAnchors.Where(
|
||||
foreach (PlayoutProgramScheduleAnchor checkpointAnchor in playout.ProgramScheduleAnchors.Where(
|
||||
a => a.AnchorDate is not null))
|
||||
{
|
||||
result.Add(continueAnchor);
|
||||
result.Add(checkpointAnchor);
|
||||
}
|
||||
|
||||
return result;
|
||||
@@ -675,7 +673,7 @@ public class PlayoutBuilder : IPlayoutBuilder
|
||||
bool randomStartPoint)
|
||||
{
|
||||
Option<PlayoutProgramScheduleAnchor> maybeAnchor = playout.ProgramScheduleAnchors
|
||||
.OrderByDescending(a => a.AnchorDate is null)
|
||||
.OrderByDescending(a => a.AnchorDate ?? DateTime.MaxValue)
|
||||
.FirstOrDefault(
|
||||
a => a.ProgramScheduleId == playout.ProgramScheduleId
|
||||
&& a.CollectionType == collectionKey.CollectionType
|
||||
|
||||
@@ -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,93 @@ 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 -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]
|
||||
public void Aac_6_Channel_Should_Specify_Audio_Channels()
|
||||
{
|
||||
var videoInputFile = new VideoInputFile(
|
||||
"/tmp/whatever.mkv",
|
||||
new List<VideoStream>
|
||||
{ new(0, VideoFormat.H264, new PixelFormatYuv420P(), new FrameSize(1920, 1080), "24", false) });
|
||||
|
||||
var audioInputFile = new AudioInputFile(
|
||||
"/tmp/whatever.mkv",
|
||||
new List<AudioStream> { new(1, AudioFormat.Aac, 6) },
|
||||
new AudioState(
|
||||
AudioFormat.Aac,
|
||||
6,
|
||||
320,
|
||||
640,
|
||||
48,
|
||||
Option<TimeSpan>.None,
|
||||
false));
|
||||
|
||||
var desiredState = new FrameState(
|
||||
true,
|
||||
false,
|
||||
VideoFormat.Hevc,
|
||||
new PixelFormatYuv420P(),
|
||||
new FrameSize(1920, 1080),
|
||||
new FrameSize(1920, 1080),
|
||||
Option<int>.None,
|
||||
2000,
|
||||
4000,
|
||||
90_000,
|
||||
false);
|
||||
|
||||
var ffmpegState = new FFmpegState(
|
||||
false,
|
||||
HardwareAccelerationMode.None,
|
||||
HardwareAccelerationMode.None,
|
||||
Option<string>.None,
|
||||
Option<string>.None,
|
||||
TimeSpan.FromSeconds(1),
|
||||
Option<TimeSpan>.None,
|
||||
false,
|
||||
Option<string>.None,
|
||||
Option<string>.None,
|
||||
Option<string>.None,
|
||||
OutputFormatKind.MpegTs,
|
||||
Option<string>.None,
|
||||
Option<string>.None,
|
||||
0,
|
||||
Option<int>.None);
|
||||
|
||||
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 6 -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 +165,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 +212,7 @@ public class PipelineGeneratorTests
|
||||
var ffmpegState = new FFmpegState(
|
||||
false,
|
||||
HardwareAccelerationMode.None,
|
||||
HardwareAccelerationMode.None,
|
||||
Option<string>.None,
|
||||
Option<string>.None,
|
||||
Option<TimeSpan>.None,
|
||||
@@ -145,7 +227,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 +261,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,85 @@
|
||||
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 ArchitectureCacheKey = "ffmpeg.hardware.nvidia.architecture";
|
||||
private const string ModelCacheKey = "ffmpeg.hardware.nvidia.model";
|
||||
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(ArchitectureCacheKey, out int cachedArchitecture)
|
||||
&& _memoryCache.TryGetValue(ModelCacheKey, out string cachedModel))
|
||||
{
|
||||
return new NvidiaHardwareCapabilities(cachedArchitecture, cachedModel);
|
||||
}
|
||||
|
||||
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 ARCHITECTURE_PATTERN = @"SM\s+(\d\.\d)";
|
||||
Match match = Regex.Match(line, ARCHITECTURE_PATTERN);
|
||||
if (match.Success && int.TryParse(match.Groups[1].Value.Replace(".", string.Empty), out int architecture))
|
||||
{
|
||||
const string MODEL_PATTERN = @"(GTX\s+[0-9a-zA-Z]+[\sTtIi]+)";
|
||||
Match modelMatch = Regex.Match(line, MODEL_PATTERN);
|
||||
string model = modelMatch.Success ? modelMatch.Groups[1].Value.Trim() : "unknown";
|
||||
_logger.LogInformation(
|
||||
"Detected NVIDIA GPU model {Model} architecture SM {Architecture}",
|
||||
model,
|
||||
architecture);
|
||||
_memoryCache.Set(ArchitectureCacheKey, architecture);
|
||||
_memoryCache.Set(ModelCacheKey, model);
|
||||
return new NvidiaHardwareCapabilities(architecture, model);
|
||||
}
|
||||
}
|
||||
|
||||
_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,54 @@
|
||||
using ErsatzTV.FFmpeg.Format;
|
||||
|
||||
namespace ErsatzTV.FFmpeg.Capabilities;
|
||||
|
||||
public class NvidiaHardwareCapabilities : IHardwareCapabilities
|
||||
{
|
||||
private readonly int _architecture;
|
||||
private readonly List<string> _maxwellGm206 = new() { "GTX 750", "GTX 950", "GTX 960", "GTX 965M" };
|
||||
private readonly string _model;
|
||||
|
||||
public NvidiaHardwareCapabilities(int architecture, string model)
|
||||
{
|
||||
_architecture = architecture;
|
||||
_model = model;
|
||||
}
|
||||
|
||||
public bool CanDecode(string videoFormat, Option<IPixelFormat> maybePixelFormat)
|
||||
{
|
||||
int bitDepth = maybePixelFormat.Map(pf => pf.BitDepth).IfNone(8);
|
||||
|
||||
return videoFormat switch
|
||||
{
|
||||
// some second gen maxwell can decode hevc, otherwise pascal is required
|
||||
VideoFormat.Hevc => _architecture == 52 && _maxwellGm206.Contains(_model) || _architecture >= 60,
|
||||
|
||||
// pascal is required to decode vp9 10-bit
|
||||
VideoFormat.Vp9 when bitDepth == 10 => _architecture >= 60,
|
||||
|
||||
// some second gen maxwell can decode vp9, otherwise pascal is required
|
||||
VideoFormat.Vp9 => _architecture == 52 && _maxwellGm206.Contains(_model) || _architecture >= 60,
|
||||
|
||||
_ => 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
|
||||
};
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user