Compare commits
44 Commits
v0.7.1-bet
...
v0.7.4-bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c309ab430e | ||
|
|
13e21bbcce | ||
|
|
0eb36f0ce1 | ||
|
|
6429f0f064 | ||
|
|
7412ac6fc9 | ||
|
|
e58e3c786d | ||
|
|
93fc1e4eb4 | ||
|
|
cacde26796 | ||
|
|
0a3db92c60 | ||
|
|
8bb0cd5ab5 | ||
|
|
e497dc4e36 | ||
|
|
2689a67eb8 | ||
|
|
3d821043bb | ||
|
|
e69c58e615 | ||
|
|
a21b6f9f4e | ||
|
|
99b8038852 | ||
|
|
ef8ca9f8c6 | ||
|
|
d9186df157 | ||
|
|
aca6bfb0bb | ||
|
|
587fc3a98f | ||
|
|
ab1c67e60e | ||
|
|
e271f43066 | ||
|
|
6bf8feb26e | ||
|
|
ffd66f6a21 | ||
|
|
3b135df4c1 | ||
|
|
4369d04940 | ||
|
|
faaa78fed7 | ||
|
|
6bea1660ea | ||
|
|
8d46676c25 | ||
|
|
4c75e638a2 | ||
|
|
dd73a3803a | ||
|
|
f6c345d7cf | ||
|
|
585b56a668 | ||
|
|
f18f3b4f35 | ||
|
|
eb7871a048 | ||
|
|
000fc78fd3 | ||
|
|
ba676ef956 | ||
|
|
36ea88e2d6 | ||
|
|
5237e6fa50 | ||
|
|
99bde1819c | ||
|
|
f5d7ec2890 | ||
|
|
13c65435d3 | ||
|
|
315420f1a5 | ||
|
|
ab7051f075 |
18
.github/workflows/artifacts.yml
vendored
18
.github/workflows/artifacts.yml
vendored
@@ -33,10 +33,10 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- os: macos-latest
|
||||
- os: macos-11
|
||||
kind: macOS
|
||||
target: osx-x64
|
||||
- os: macos-latest
|
||||
- os: macos-11
|
||||
kind: macOS
|
||||
target: osx-arm64
|
||||
steps:
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
node-version: '14'
|
||||
|
||||
- name: Cache NPM dependencies
|
||||
uses: bahmutov/npm-install@v1.4.5
|
||||
uses: bahmutov/npm-install@v1.8.28
|
||||
with:
|
||||
working-directory: ErsatzTV/client-app
|
||||
|
||||
@@ -83,8 +83,8 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
sed -i '' '/Scanner/d' ErsatzTV/ErsatzTV.csproj
|
||||
dotnet publish ErsatzTV.Scanner/ErsatzTV.Scanner.csproj --framework net7.0 --runtime "${{ matrix.target }}" -c Release -o publish -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
|
||||
dotnet publish ErsatzTV/ErsatzTV.csproj --framework net7.0 --runtime "${{ matrix.target }}" -c Release -o publish -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
|
||||
dotnet publish ErsatzTV.Scanner/ErsatzTV.Scanner.csproj --framework net7.0 --runtime "${{ matrix.target }}" -c Release -o publish -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=false -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
|
||||
dotnet publish ErsatzTV/ErsatzTV.csproj --framework net7.0 --runtime "${{ matrix.target }}" -c Release -o publish -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=false -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
|
||||
|
||||
- name: Bundle
|
||||
shell: bash
|
||||
@@ -133,7 +133,7 @@ jobs:
|
||||
rm -r ErsatzTV.app
|
||||
|
||||
- name: Delete old release assets
|
||||
uses: asfernandes/delete-release-assets@update-libraries-and-node
|
||||
uses: mknejp/delete-release-assets@v1
|
||||
if: ${{ inputs.release_tag == 'develop' }}
|
||||
with:
|
||||
token: ${{ secrets.gh_token }}
|
||||
@@ -193,7 +193,7 @@ jobs:
|
||||
if: ${{ matrix.kind == 'windows' }}
|
||||
|
||||
- name: Cache NPM dependencies
|
||||
uses: bahmutov/npm-install@v1.4.5
|
||||
uses: bahmutov/npm-install@v1.8.28
|
||||
with:
|
||||
working-directory: ErsatzTV/client-app
|
||||
|
||||
@@ -203,7 +203,7 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: dotnet restore -r "${{ matrix.target }}"
|
||||
|
||||
- uses: suisei-cn/actions-download-file@v1
|
||||
- uses: suisei-cn/actions-download-file@v1.3.0
|
||||
if: ${{ matrix.kind == 'windows' }}
|
||||
id: downloadffmpeg
|
||||
name: Download ffmpeg
|
||||
@@ -250,7 +250,7 @@ jobs:
|
||||
AC_PASSWORD: ${{ secrets.ac_password }}
|
||||
|
||||
- name: Delete old release assets
|
||||
uses: asfernandes/delete-release-assets@update-libraries-and-node
|
||||
uses: mknejp/delete-release-assets@v1
|
||||
if: ${{ inputs.release_tag == 'develop' }}
|
||||
with:
|
||||
token: ${{ secrets.gh_token }}
|
||||
|
||||
2
.github/workflows/pr.yml
vendored
2
.github/workflows/pr.yml
vendored
@@ -63,7 +63,7 @@ jobs:
|
||||
- name: Test
|
||||
run: dotnet test --no-restore --verbosity normal
|
||||
build_and_test_mac:
|
||||
runs-on: macos-latest
|
||||
runs-on: macos-11
|
||||
steps:
|
||||
- name: Get the sources
|
||||
uses: actions/checkout@v3
|
||||
|
||||
74
CHANGELOG.md
74
CHANGELOG.md
@@ -5,6 +5,75 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.7.4-beta] - 2023-02-12
|
||||
### Added
|
||||
- Add button to copy/clone schedule from schedules table
|
||||
- Synchronize episode tags and genres from Jellyfin, Emby and Local show libraries
|
||||
- Add `Deep Scan` button to Jellyfin and Emby libraries
|
||||
- This is now required to update some metadata for existing libraries, when targeted updates are not possible
|
||||
- For example, if you already have tags and genres on your episodes in Jellyfin or Emby, you will need to deep scan each library to update that metadata on existing items in ErsatzTV
|
||||
|
||||
### Fixed
|
||||
- Fix many QSV pipeline bugs
|
||||
- Fix MPEG2 video format with QSV and VAAPI acceleration
|
||||
- Fix playback of content with undefined colorspace
|
||||
- Fix NVIDIA color normalization with VP9 sources
|
||||
- Fix fallback filler looping
|
||||
- Fix bug where some libraries would never scan
|
||||
- Fix filler ordering so post-roll is properly scheduled after padded mid-roll
|
||||
- Fix pre/post-roll filler padding when used with mid-roll
|
||||
- This caused overlapping schedule items, fallback filler that was too long, etc.
|
||||
|
||||
### Changed
|
||||
- Merge generated `Other Video` folder tags with tags from sidecar NFO
|
||||
- Prioritize audio streams that are flagged as "default" when multiple candidate streams are available
|
||||
- For example, a video with a stereo commentary track and a stereo "default" track will now prefer the "default" track
|
||||
|
||||
## [0.7.3-beta] - 2023-01-25
|
||||
### Added
|
||||
- Attempt to release memory periodically
|
||||
- Add OpenID Connect (OIDC) support (e.g. Keycloak, Authelia, Auth0)
|
||||
- This only protects the management UI; all streaming endpoints will continue to allow anonymous access
|
||||
- This can be configured with the following env vars (note the double underscore separator `__`)
|
||||
- `OIDC__AUTHORITY`
|
||||
- `OIDC__CLIENTID`
|
||||
- `OIDC__CLIENTSECRET`
|
||||
- `OIDC__LOGOUTURI` (optional, needed for Auth0, use `https://{auth0-domain}/v2/logout?client_id={auth0-client-id}` with proper values for domain and client-id)
|
||||
- Add *experimental* alternate schedule system
|
||||
- This allows a single playout to dynamically select a schedule based on date criteria, for example:
|
||||
- Weekday vs weekend schedules
|
||||
- Summer vs fall schedules
|
||||
- Shark week schedules
|
||||
- Alternate schedules can be managed by clicking the calendar icon in the playout list
|
||||
- Playouts contain a prioritized (top to bottom) list of alternate schedules
|
||||
- Whenever a playout is built for a given day, ErsatzTV will check for a matching schedule from top to bottom
|
||||
- A given day must match all alternate schedule parameters; wildcards (`*any*`) will always match
|
||||
- Day of week
|
||||
- Day of month
|
||||
- Month
|
||||
- The lowest priority (bottom) item will always match all parameters, and can be considered a "default" or "fallback" schedule
|
||||
|
||||
### Fixed
|
||||
- Fix schedule editor crashing due to bad music video artist data
|
||||
- Fix bug where playouts would not maintain smart collection progress on schedules that use multiple smart collections
|
||||
- Fix library scanning on osx-arm64
|
||||
- Fix ability to remove some media server libraries from ErsatzTV
|
||||
|
||||
### Changed
|
||||
- Always use software pipeline for error display
|
||||
- This ensures errors will display even when hardware acceleration is misconfigured
|
||||
- Call scanner process only when scanning is required based on library refresh interval
|
||||
- Use lower process priority for scanner process with unforced (automatic) library scans
|
||||
- Disable V2 UI and APIs by default
|
||||
- V2 UI can be re-enabled by setting the env var `ETV_UI_V2` to any value
|
||||
|
||||
## [0.7.2-beta] - 2023-01-05
|
||||
### Fixed
|
||||
- Fix VAAPI encoding in docker by switching to non-free driver
|
||||
|
||||
### Changed
|
||||
- Rewrite log page to read directly from log files instead of sqlite
|
||||
|
||||
## [0.7.1-beta] - 2023-01-03
|
||||
### Added
|
||||
- Add new music video credit templates
|
||||
@@ -1456,7 +1525,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
- Initial release to facilitate testing outside of Docker.
|
||||
|
||||
|
||||
[Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.7.1-beta...HEAD
|
||||
[Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.7.4-beta...HEAD
|
||||
[0.7.4-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.7.3-beta...v0.7.4-beta
|
||||
[0.7.3-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.7.2-beta...v0.7.3-beta
|
||||
[0.7.2-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.7.1-beta...v0.7.2-beta
|
||||
[0.7.1-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.7.0-beta...v0.7.1-beta
|
||||
[0.7.0-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.6.9-beta...v0.7.0-beta
|
||||
[0.6.9-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.6.8-beta...v0.6.9-beta
|
||||
|
||||
@@ -1,22 +1,44 @@
|
||||
using ErsatzTV.Application.MediaItems;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using static ErsatzTV.Application.MediaItems.Mapper;
|
||||
|
||||
namespace ErsatzTV.Application.Artists;
|
||||
|
||||
public class GetAllArtistsHandler : IRequestHandler<GetAllArtists, List<NamedMediaItemViewModel>>
|
||||
{
|
||||
private readonly IArtistRepository _artistRepository;
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
|
||||
public GetAllArtistsHandler(IArtistRepository artistRepository) => _artistRepository = artistRepository;
|
||||
public GetAllArtistsHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
}
|
||||
|
||||
public Task<List<NamedMediaItemViewModel>> Handle(
|
||||
public async Task<List<NamedMediaItemViewModel>> Handle(
|
||||
GetAllArtists request,
|
||||
CancellationToken cancellationToken) =>
|
||||
_artistRepository.GetAllArtists()
|
||||
.Map(
|
||||
list => list.Filter(
|
||||
a => !string.IsNullOrWhiteSpace(
|
||||
a.ArtistMetadata.HeadOrNone().Match(am => am.Title, () => string.Empty))))
|
||||
.Map(list => list.Map(ProjectToViewModel).ToList());
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
List<Artist> allArtists = await dbContext.Artists
|
||||
.AsNoTracking()
|
||||
.Include(a => a.ArtistMetadata)
|
||||
.ToListAsync(cancellationToken: cancellationToken);
|
||||
|
||||
return allArtists.Bind(a => ProjectArtist(a)).ToList();
|
||||
}
|
||||
|
||||
private static Option<NamedMediaItemViewModel> ProjectArtist(Artist a)
|
||||
{
|
||||
foreach (ArtistMetadata metadata in a.ArtistMetadata.HeadOrNone())
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(metadata.Title))
|
||||
{
|
||||
return ProjectToViewModel(a);
|
||||
}
|
||||
}
|
||||
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,13 +15,13 @@ namespace ErsatzTV.Application.Channels;
|
||||
public class UpdateChannelHandler : IRequestHandler<UpdateChannel, Either<BaseError, ChannelViewModel>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly ChannelWriter<ISubtitleWorkerRequest> _ffmpegWorkerChannel;
|
||||
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
|
||||
|
||||
public UpdateChannelHandler(
|
||||
ChannelWriter<ISubtitleWorkerRequest> ffmpegWorkerChannel,
|
||||
ChannelWriter<IBackgroundServiceRequest> workerChannel,
|
||||
IDbContextFactory<TvContext> dbContextFactory)
|
||||
{
|
||||
_ffmpegWorkerChannel = ffmpegWorkerChannel;
|
||||
_workerChannel = workerChannel;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ public class UpdateChannelHandler : IRequestHandler<UpdateChannel, Either<BaseEr
|
||||
|
||||
foreach (Playout playout in maybePlayout)
|
||||
{
|
||||
await _ffmpegWorkerChannel.WriteAsync(new ExtractEmbeddedSubtitles(playout.Id));
|
||||
await _workerChannel.WriteAsync(new ExtractEmbeddedSubtitles(playout.Id));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public record GetChannelNameByPlayoutId(int PlayoutId) : IRequest<Option<string>>;
|
||||
@@ -0,0 +1,24 @@
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class GetChannelNameByPlayoutIdHandler : IRequestHandler<GetChannelNameByPlayoutId, Option<string>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
|
||||
public GetChannelNameByPlayoutIdHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
}
|
||||
|
||||
public async Task<Option<string>> Handle(GetChannelNameByPlayoutId request, CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
return await dbContext.Playouts
|
||||
.Include(p => p.Channel)
|
||||
.SelectOneAsync(p => p.Id, p => p.Id == request.PlayoutId)
|
||||
.MapT(p => p.Channel.Name);
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,26 @@
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Libraries;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.FFmpeg.Runtime;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Emby;
|
||||
|
||||
public class CallEmbyLibraryScannerHandler : CallLibraryScannerHandler,
|
||||
public class CallEmbyLibraryScannerHandler : CallLibraryScannerHandler<ISynchronizeEmbyLibraryById>,
|
||||
IRequestHandler<ForceSynchronizeEmbyLibraryById, Either<BaseError, string>>,
|
||||
IRequestHandler<SynchronizeEmbyLibraryByIdIfNeeded, Either<BaseError, string>>
|
||||
{
|
||||
public CallEmbyLibraryScannerHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IConfigElementRepository configElementRepository,
|
||||
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel,
|
||||
IMediator mediator,
|
||||
IRuntimeInfo runtimeInfo)
|
||||
: base(channel, mediator, runtimeInfo)
|
||||
: base(dbContextFactory, configElementRepository, channel, mediator, runtimeInfo)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -29,10 +36,18 @@ public class CallEmbyLibraryScannerHandler : CallLibraryScannerHandler,
|
||||
ISynchronizeEmbyLibraryById request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Validation<BaseError, string> validation = Validate();
|
||||
Validation<BaseError, string> validation = await Validate(request);
|
||||
return await validation.Match(
|
||||
scanner => PerformScan(scanner, request, cancellationToken),
|
||||
error => Task.FromResult<Either<BaseError, string>>(error.Join()));
|
||||
error =>
|
||||
{
|
||||
foreach (ScanIsNotRequired scanIsNotRequired in error.OfType<ScanIsNotRequired>())
|
||||
{
|
||||
return Task.FromResult<Either<BaseError, string>>(scanIsNotRequired);
|
||||
}
|
||||
|
||||
return Task.FromResult<Either<BaseError, string>>(error.Join());
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, string>> PerformScan(
|
||||
@@ -50,6 +65,34 @@ public class CallEmbyLibraryScannerHandler : CallLibraryScannerHandler,
|
||||
arguments.Add("--force");
|
||||
}
|
||||
|
||||
if (request.DeepScan)
|
||||
{
|
||||
arguments.Add("--deep");
|
||||
}
|
||||
|
||||
return await base.PerformScan(scanner, arguments, cancellationToken);
|
||||
}
|
||||
|
||||
protected override async Task<DateTimeOffset> GetLastScan(
|
||||
TvContext dbContext,
|
||||
ISynchronizeEmbyLibraryById request)
|
||||
{
|
||||
return await dbContext.EmbyLibraries
|
||||
.SelectOneAsync(l => l.Id, l => l.Id == request.EmbyLibraryId)
|
||||
.Match(l => l.LastScan ?? SystemTime.MinValueUtc, () => SystemTime.MaxValueUtc);
|
||||
}
|
||||
|
||||
protected override bool ScanIsRequired(
|
||||
DateTimeOffset lastScan,
|
||||
int libraryRefreshInterval,
|
||||
ISynchronizeEmbyLibraryById request)
|
||||
{
|
||||
if (lastScan == SystemTime.MaxValueUtc)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(libraryRefreshInterval);
|
||||
return request.ForceScan || (libraryRefreshInterval > 0 && nextScan < DateTimeOffset.Now);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,19 +2,20 @@
|
||||
|
||||
namespace ErsatzTV.Application.Emby;
|
||||
|
||||
public interface ISynchronizeEmbyLibraryById : IRequest<Either<BaseError, string>>,
|
||||
IEmbyBackgroundServiceRequest
|
||||
public interface ISynchronizeEmbyLibraryById : IRequest<Either<BaseError, string>>, IEmbyBackgroundServiceRequest
|
||||
{
|
||||
int EmbyLibraryId { get; }
|
||||
bool ForceScan { get; }
|
||||
bool DeepScan { get; }
|
||||
}
|
||||
|
||||
public record SynchronizeEmbyLibraryByIdIfNeeded(int EmbyLibraryId) : ISynchronizeEmbyLibraryById
|
||||
{
|
||||
public bool ForceScan => false;
|
||||
public bool DeepScan => false;
|
||||
}
|
||||
|
||||
public record ForceSynchronizeEmbyLibraryById(int EmbyLibraryId) : ISynchronizeEmbyLibraryById
|
||||
public record ForceSynchronizeEmbyLibraryById(int EmbyLibraryId, bool DeepScan) : ISynchronizeEmbyLibraryById
|
||||
{
|
||||
public bool ForceScan => true;
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<PackageReference Include="Humanizer.Core" Version="2.14.1" />
|
||||
<PackageReference Include="MediatR" Version="11.1.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="7.0.0" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.4.27">
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.5.22">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
|
||||
@@ -36,7 +36,12 @@ public class
|
||||
p.QsvExtraHardwareFrames = update.QsvExtraHardwareFrames;
|
||||
p.ResolutionId = update.ResolutionId;
|
||||
p.VideoFormat = update.VideoFormat;
|
||||
p.BitDepth = update.BitDepth;
|
||||
|
||||
// mpeg2video only supports 8-bit content
|
||||
p.BitDepth = update.VideoFormat == FFmpegProfileVideoFormat.Mpeg2Video
|
||||
? FFmpegProfileBitDepth.EightBit
|
||||
: update.BitDepth;
|
||||
|
||||
p.VideoBitrate = update.VideoBitrate;
|
||||
p.VideoBufferSize = update.VideoBufferSize;
|
||||
p.AudioFormat = update.AudioFormat;
|
||||
|
||||
@@ -52,7 +52,7 @@ public class UpdateFFmpegSettingsHandler : IRequestHandler<UpdateFFmpegSettings,
|
||||
UseShellExecute = false
|
||||
};
|
||||
|
||||
var test = new Process
|
||||
using var test = new Process
|
||||
{
|
||||
StartInfo = startInfo
|
||||
};
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
namespace ErsatzTV.Application;
|
||||
|
||||
public interface ISubtitleWorkerRequest
|
||||
{
|
||||
}
|
||||
@@ -1,19 +1,26 @@
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Libraries;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.FFmpeg.Runtime;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Jellyfin;
|
||||
|
||||
public class CallJellyfinLibraryScannerHandler : CallLibraryScannerHandler,
|
||||
public class CallJellyfinLibraryScannerHandler : CallLibraryScannerHandler<ISynchronizeJellyfinLibraryById>,
|
||||
IRequestHandler<ForceSynchronizeJellyfinLibraryById, Either<BaseError, string>>,
|
||||
IRequestHandler<SynchronizeJellyfinLibraryByIdIfNeeded, Either<BaseError, string>>
|
||||
{
|
||||
public CallJellyfinLibraryScannerHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IConfigElementRepository configElementRepository,
|
||||
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel,
|
||||
IMediator mediator,
|
||||
IRuntimeInfo runtimeInfo)
|
||||
: base(channel, mediator, runtimeInfo)
|
||||
: base(dbContextFactory, configElementRepository, channel, mediator, runtimeInfo)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -29,10 +36,18 @@ public class CallJellyfinLibraryScannerHandler : CallLibraryScannerHandler,
|
||||
ISynchronizeJellyfinLibraryById request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Validation<BaseError, string> validation = Validate();
|
||||
Validation<BaseError, string> validation = await Validate(request);
|
||||
return await validation.Match(
|
||||
scanner => PerformScan(scanner, request, cancellationToken),
|
||||
error => Task.FromResult<Either<BaseError, string>>(error.Join()));
|
||||
error =>
|
||||
{
|
||||
foreach (ScanIsNotRequired scanIsNotRequired in error.OfType<ScanIsNotRequired>())
|
||||
{
|
||||
return Task.FromResult<Either<BaseError, string>>(scanIsNotRequired);
|
||||
}
|
||||
|
||||
return Task.FromResult<Either<BaseError, string>>(error.Join());
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, string>> PerformScan(
|
||||
@@ -49,7 +64,35 @@ public class CallJellyfinLibraryScannerHandler : CallLibraryScannerHandler,
|
||||
{
|
||||
arguments.Add("--force");
|
||||
}
|
||||
|
||||
if (request.DeepScan)
|
||||
{
|
||||
arguments.Add("--deep");
|
||||
}
|
||||
|
||||
return await base.PerformScan(scanner, arguments, cancellationToken);
|
||||
}
|
||||
|
||||
protected override async Task<DateTimeOffset> GetLastScan(
|
||||
TvContext dbContext,
|
||||
ISynchronizeJellyfinLibraryById request)
|
||||
{
|
||||
return await dbContext.JellyfinLibraries
|
||||
.SelectOneAsync(l => l.Id, l => l.Id == request.JellyfinLibraryId)
|
||||
.Match(l => l.LastScan ?? SystemTime.MinValueUtc, () => SystemTime.MaxValueUtc);
|
||||
}
|
||||
|
||||
protected override bool ScanIsRequired(
|
||||
DateTimeOffset lastScan,
|
||||
int libraryRefreshInterval,
|
||||
ISynchronizeJellyfinLibraryById request)
|
||||
{
|
||||
if (lastScan == SystemTime.MaxValueUtc)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(libraryRefreshInterval);
|
||||
return request.ForceScan || (libraryRefreshInterval > 0 && nextScan < DateTimeOffset.Now);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,14 +7,17 @@ public interface ISynchronizeJellyfinLibraryById : IRequest<Either<BaseError, st
|
||||
{
|
||||
int JellyfinLibraryId { get; }
|
||||
bool ForceScan { get; }
|
||||
bool DeepScan { get; }
|
||||
}
|
||||
|
||||
public record SynchronizeJellyfinLibraryByIdIfNeeded(int JellyfinLibraryId) : ISynchronizeJellyfinLibraryById
|
||||
{
|
||||
public bool ForceScan => false;
|
||||
public bool DeepScan => false;
|
||||
}
|
||||
|
||||
public record ForceSynchronizeJellyfinLibraryById(int JellyfinLibraryId) : ISynchronizeJellyfinLibraryById
|
||||
public record ForceSynchronizeJellyfinLibraryById
|
||||
(int JellyfinLibraryId, bool DeepScan) : ISynchronizeJellyfinLibraryById
|
||||
{
|
||||
public bool ForceScan => true;
|
||||
}
|
||||
|
||||
@@ -1,30 +1,41 @@
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading.Channels;
|
||||
using CliWrap;
|
||||
using ErsatzTV.Application.Search;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.MediaSources;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
using ErsatzTV.FFmpeg.Runtime;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Newtonsoft.Json;
|
||||
using Serilog;
|
||||
using Serilog.Events;
|
||||
using Serilog.Formatting.Compact.Reader;
|
||||
|
||||
namespace ErsatzTV.Application.Libraries;
|
||||
|
||||
public abstract class CallLibraryScannerHandler
|
||||
public abstract class CallLibraryScannerHandler<TRequest>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
private readonly ChannelWriter<ISearchIndexBackgroundServiceRequest> _channel;
|
||||
private readonly IMediator _mediator;
|
||||
private readonly IRuntimeInfo _runtimeInfo;
|
||||
private string _libraryName;
|
||||
|
||||
protected CallLibraryScannerHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IConfigElementRepository configElementRepository,
|
||||
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel,
|
||||
IMediator mediator,
|
||||
IRuntimeInfo runtimeInfo)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_configElementRepository = configElementRepository;
|
||||
_channel = channel;
|
||||
_mediator = mediator;
|
||||
_runtimeInfo = runtimeInfo;
|
||||
@@ -69,7 +80,17 @@ public abstract class CallLibraryScannerHandler
|
||||
{
|
||||
try
|
||||
{
|
||||
Log.Write(LogEventReader.ReadFromString(s));
|
||||
// make a new log event to force using local time
|
||||
// because the compact json writer used by the scanner
|
||||
// writes in UTC
|
||||
LogEvent logEvent = LogEventReader.ReadFromString(s);
|
||||
Log.Write(
|
||||
new LogEvent(
|
||||
logEvent.Timestamp.ToLocalTime(),
|
||||
logEvent.Level,
|
||||
logEvent.Exception,
|
||||
logEvent.MessageTemplate,
|
||||
logEvent.Properties.Map(pair => new LogEventProperty(pair.Key, pair.Value))));
|
||||
}
|
||||
catch
|
||||
{
|
||||
@@ -121,13 +142,30 @@ public abstract class CallLibraryScannerHandler
|
||||
}
|
||||
}
|
||||
|
||||
protected Validation<BaseError, string> Validate()
|
||||
protected abstract Task<DateTimeOffset> GetLastScan(TvContext dbContext, TRequest request);
|
||||
protected abstract bool ScanIsRequired(DateTimeOffset lastScan, int libraryRefreshInterval, TRequest request);
|
||||
|
||||
protected async Task<Validation<BaseError, string>> Validate(TRequest request)
|
||||
{
|
||||
int libraryRefreshInterval = await _configElementRepository
|
||||
.GetValue<int>(ConfigElementKey.LibraryRefreshInterval)
|
||||
.IfNoneAsync(0);
|
||||
|
||||
libraryRefreshInterval = Math.Clamp(libraryRefreshInterval, 0, 999_999);
|
||||
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
|
||||
DateTimeOffset lastScan = await GetLastScan(dbContext, request);
|
||||
if (!ScanIsRequired(lastScan, libraryRefreshInterval, request))
|
||||
{
|
||||
return new ScanIsNotRequired();
|
||||
}
|
||||
|
||||
string executable = _runtimeInfo.IsOSPlatform(OSPlatform.Windows)
|
||||
? "ErsatzTV.Scanner.exe"
|
||||
: "ErsatzTV.Scanner";
|
||||
|
||||
string processFileName = Process.GetCurrentProcess().MainModule?.FileName ?? string.Empty;
|
||||
string processFileName = Environment.ProcessPath ?? string.Empty;
|
||||
if (!string.IsNullOrWhiteSpace(processFileName))
|
||||
{
|
||||
string localFileName = Path.Combine(Path.GetDirectoryName(processFileName) ?? string.Empty, executable);
|
||||
|
||||
8
ErsatzTV.Application/Logs/LogEntryViewModel.cs
Normal file
8
ErsatzTV.Application/Logs/LogEntryViewModel.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
using Serilog.Events;
|
||||
|
||||
namespace ErsatzTV.Application.Logs;
|
||||
|
||||
public record LogEntryViewModel(
|
||||
DateTimeOffset Timestamp,
|
||||
LogEventLevel Level,
|
||||
string Message);
|
||||
31
ErsatzTV.Application/Logs/Mapper.cs
Normal file
31
ErsatzTV.Application/Logs/Mapper.cs
Normal file
@@ -0,0 +1,31 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using Serilog.Events;
|
||||
|
||||
namespace ErsatzTV.Application.Logs;
|
||||
|
||||
internal partial class Mapper
|
||||
{
|
||||
[GeneratedRegex(@"(.*)\[(DBG|INF|WRN|ERR|FTL)\](.*)")]
|
||||
private static partial Regex LogEntryRegex();
|
||||
|
||||
internal static Option<LogEntryViewModel> ProjectToViewModel(string line)
|
||||
{
|
||||
Match match = LogEntryRegex().Match(line);
|
||||
if (!match.Success)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
var timestamp = DateTimeOffset.Parse(match.Groups[1].Value);
|
||||
LogEventLevel level = match.Groups[2].Value switch
|
||||
{
|
||||
"FTL" => LogEventLevel.Fatal,
|
||||
"ERR" => LogEventLevel.Error,
|
||||
"WRN" => LogEventLevel.Warning,
|
||||
"INF" => LogEventLevel.Information,
|
||||
_ => LogEventLevel.Debug
|
||||
};
|
||||
|
||||
return new LogEntryViewModel(timestamp, level, match.Groups[3].Value);
|
||||
}
|
||||
}
|
||||
3
ErsatzTV.Application/Logs/PagedLogEntriesViewModel.cs
Normal file
3
ErsatzTV.Application/Logs/PagedLogEntriesViewModel.cs
Normal file
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.Logs;
|
||||
|
||||
public record PagedLogEntriesViewModel(int TotalCount, List<LogEntryViewModel> Page);
|
||||
9
ErsatzTV.Application/Logs/Queries/GetRecentLogEntries.cs
Normal file
9
ErsatzTV.Application/Logs/Queries/GetRecentLogEntries.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
using System.Linq.Expressions;
|
||||
|
||||
namespace ErsatzTV.Application.Logs;
|
||||
|
||||
public record GetRecentLogEntries(int PageNum, int PageSize, string Filter) : IRequest<PagedLogEntriesViewModel>
|
||||
{
|
||||
public Expression<Func<LogEntryViewModel, object>> SortExpression { get; init; }
|
||||
public Option<bool> SortDescending { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using static ErsatzTV.Application.Logs.Mapper;
|
||||
|
||||
namespace ErsatzTV.Application.Logs;
|
||||
|
||||
public class GetRecentLogEntriesHandler : IRequestHandler<GetRecentLogEntries, PagedLogEntriesViewModel>
|
||||
{
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
|
||||
public GetRecentLogEntriesHandler(ILocalFileSystem localFileSystem)
|
||||
{
|
||||
_localFileSystem = localFileSystem;
|
||||
}
|
||||
|
||||
public Task<PagedLogEntriesViewModel> Handle(
|
||||
GetRecentLogEntries request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// get most recent file
|
||||
string logFileName = _localFileSystem.ListFiles(FileSystemLayout.LogsFolder)
|
||||
.OrderDescending()
|
||||
.FirstOrDefault();
|
||||
|
||||
if (logFileName is not null)
|
||||
{
|
||||
IQueryable<LogEntryViewModel> entries = ReadFrom(logFileName)
|
||||
.Bind(line => ProjectToViewModel(line))
|
||||
.AsQueryable();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Filter))
|
||||
{
|
||||
entries = entries.Filter(
|
||||
le => le.Level.ToString().Contains(request.Filter, StringComparison.OrdinalIgnoreCase) ||
|
||||
le.Message.Contains(request.Filter, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
int count = entries.Count();
|
||||
|
||||
IOrderedQueryable<LogEntryViewModel> ordered = request.SortDescending.Match(
|
||||
descending => descending
|
||||
? entries.OrderByDescending(request.SortExpression).ThenByDescending(le => le.Timestamp)
|
||||
: entries.OrderBy(request.SortExpression).ThenByDescending(le => le.Timestamp),
|
||||
() => entries.OrderByDescending(le => le.Timestamp));
|
||||
|
||||
var page = ordered
|
||||
.Skip(request.PageNum * request.PageSize)
|
||||
.Take(request.PageSize)
|
||||
.ToList();
|
||||
|
||||
return new PagedLogEntriesViewModel(count, page).AsTask();
|
||||
}
|
||||
|
||||
return new PagedLogEntriesViewModel(0, new List<LogEntryViewModel>()).AsTask();
|
||||
}
|
||||
|
||||
private static IEnumerable<string> ReadFrom(string file)
|
||||
{
|
||||
using FileStream fs = File.Open(file, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
|
||||
using var reader = new StreamReader(fs);
|
||||
while (reader.ReadLine() is { } line)
|
||||
{
|
||||
yield return line;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace ErsatzTV.Application.Maintenance;
|
||||
|
||||
public record ReleaseMemory(bool ForceAggressive) : IRequest<Unit>, IBackgroundServiceRequest
|
||||
{
|
||||
public DateTimeOffset RequestTime = DateTimeOffset.Now;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Application.Maintenance;
|
||||
|
||||
public class ReleaseMemoryHandler : IRequestHandler<ReleaseMemory, Unit>
|
||||
{
|
||||
private static long _lastRelease;
|
||||
|
||||
private readonly IFFmpegSegmenterService _ffmpegSegmenterService;
|
||||
private readonly ILogger<ReleaseMemoryHandler> _logger;
|
||||
|
||||
public ReleaseMemoryHandler(
|
||||
IFFmpegSegmenterService ffmpegSegmenterService,
|
||||
ILogger<ReleaseMemoryHandler> logger)
|
||||
{
|
||||
_ffmpegSegmenterService = ffmpegSegmenterService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Task<Unit> Handle(ReleaseMemory request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!request.ForceAggressive && _lastRelease > request.RequestTime.Ticks)
|
||||
{
|
||||
// we've already released since the request was created, so don't bother
|
||||
return Task.FromResult(Unit.Default);
|
||||
}
|
||||
|
||||
bool hasActiveWorkers = _ffmpegSegmenterService.SessionWorkers.Any() || FFmpegProcess.ProcessCount > 0;
|
||||
if (request.ForceAggressive || !hasActiveWorkers)
|
||||
{
|
||||
_logger.LogDebug("Starting aggressive garbage collection");
|
||||
GC.Collect(2, GCCollectionMode.Aggressive, blocking: true, compacting: true);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("Starting garbage collection");
|
||||
GC.Collect(2, GCCollectionMode.Forced, blocking: false);
|
||||
}
|
||||
|
||||
GC.WaitForPendingFinalizers();
|
||||
GC.Collect();
|
||||
|
||||
_logger.LogDebug("Completed garbage collection");
|
||||
Interlocked.Exchange(ref _lastRelease, DateTimeOffset.Now.Ticks);
|
||||
|
||||
return Task.FromResult(Unit.Default);
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,25 @@
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Libraries;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.FFmpeg.Runtime;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.MediaSources;
|
||||
|
||||
public class CallLocalLibraryScannerHandler : CallLibraryScannerHandler,
|
||||
public class CallLocalLibraryScannerHandler : CallLibraryScannerHandler<IScanLocalLibrary>,
|
||||
IRequestHandler<ForceScanLocalLibrary, Either<BaseError, string>>,
|
||||
IRequestHandler<ScanLocalLibraryIfNeeded, Either<BaseError, string>>
|
||||
{
|
||||
public CallLocalLibraryScannerHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IConfigElementRepository configElementRepository,
|
||||
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel,
|
||||
IMediator mediator,
|
||||
IRuntimeInfo runtimeInfo)
|
||||
: base(channel, mediator, runtimeInfo)
|
||||
: base(dbContextFactory, configElementRepository, channel, mediator, runtimeInfo)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -27,10 +33,18 @@ public class CallLocalLibraryScannerHandler : CallLibraryScannerHandler,
|
||||
|
||||
private async Task<Either<BaseError, string>> Handle(IScanLocalLibrary request, CancellationToken cancellationToken)
|
||||
{
|
||||
Validation<BaseError, string> validation = Validate();
|
||||
Validation<BaseError, string> validation = await Validate(request);
|
||||
return await validation.Match(
|
||||
scanner => PerformScan(scanner, request, cancellationToken),
|
||||
error => Task.FromResult<Either<BaseError, string>>(error.Join()));
|
||||
error =>
|
||||
{
|
||||
foreach (ScanIsNotRequired scanIsNotRequired in error.OfType<ScanIsNotRequired>())
|
||||
{
|
||||
return Task.FromResult<Either<BaseError, string>>(scanIsNotRequired);
|
||||
}
|
||||
|
||||
return Task.FromResult<Either<BaseError, string>>(error.Join());
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, string>> PerformScan(
|
||||
@@ -50,4 +64,31 @@ public class CallLocalLibraryScannerHandler : CallLibraryScannerHandler,
|
||||
|
||||
return await base.PerformScan(scanner, arguments, cancellationToken);
|
||||
}
|
||||
|
||||
protected override async Task<DateTimeOffset> GetLastScan(TvContext dbContext, IScanLocalLibrary request)
|
||||
{
|
||||
var libraryPaths = await dbContext.LibraryPaths
|
||||
.Filter(lp => lp.LibraryId == request.LibraryId)
|
||||
.ToListAsync();
|
||||
|
||||
var minDateTime = libraryPaths.Any()
|
||||
? libraryPaths.Min(lp => lp.LastScan ?? SystemTime.MinValueUtc)
|
||||
: SystemTime.MaxValueUtc;
|
||||
|
||||
return new DateTimeOffset(minDateTime, TimeSpan.Zero);
|
||||
}
|
||||
|
||||
protected override bool ScanIsRequired(
|
||||
DateTimeOffset lastScan,
|
||||
int libraryRefreshInterval,
|
||||
IScanLocalLibrary request)
|
||||
{
|
||||
if (lastScan == SystemTime.MaxValueUtc)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(libraryRefreshInterval);
|
||||
return request.ForceScan || (libraryRefreshInterval > 0 && nextScan < DateTimeOffset.Now);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
|
||||
private readonly IClient _client;
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly IFFmpegSegmenterService _ffmpegSegmenterService;
|
||||
private readonly ChannelWriter<ISubtitleWorkerRequest> _ffmpegWorkerChannel;
|
||||
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
|
||||
private readonly IPlayoutBuilder _playoutBuilder;
|
||||
|
||||
public BuildPlayoutHandler(
|
||||
@@ -25,13 +25,13 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IPlayoutBuilder playoutBuilder,
|
||||
IFFmpegSegmenterService ffmpegSegmenterService,
|
||||
ChannelWriter<ISubtitleWorkerRequest> ffmpegWorkerChannel)
|
||||
ChannelWriter<IBackgroundServiceRequest> workerChannel)
|
||||
{
|
||||
_client = client;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_playoutBuilder = playoutBuilder;
|
||||
_ffmpegSegmenterService = ffmpegSegmenterService;
|
||||
_ffmpegWorkerChannel = ffmpegWorkerChannel;
|
||||
_workerChannel = workerChannel;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Unit>> Handle(BuildPlayout request, CancellationToken cancellationToken)
|
||||
@@ -56,7 +56,7 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
|
||||
_ffmpegSegmenterService.PlayoutUpdated(playout.Channel.Number);
|
||||
}
|
||||
|
||||
await _ffmpegWorkerChannel.WriteAsync(new ExtractEmbeddedSubtitles(playout.Id));
|
||||
await _workerChannel.WriteAsync(new ExtractEmbeddedSubtitles(playout.Id));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -75,8 +75,41 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
|
||||
dbContext.Playouts
|
||||
.Include(p => p.Channel)
|
||||
.Include(p => p.Items)
|
||||
|
||||
.Include(p => p.ProgramScheduleAlternates)
|
||||
.ThenInclude(a => a.ProgramSchedule)
|
||||
.ThenInclude(ps => ps.Items)
|
||||
.ThenInclude(psi => psi.Collection)
|
||||
.Include(p => p.ProgramScheduleAlternates)
|
||||
.ThenInclude(a => a.ProgramSchedule)
|
||||
.ThenInclude(ps => ps.Items)
|
||||
.ThenInclude(psi => psi.MediaItem)
|
||||
.Include(p => p.ProgramScheduleAlternates)
|
||||
.ThenInclude(a => a.ProgramSchedule)
|
||||
.ThenInclude(ps => ps.Items)
|
||||
.ThenInclude(psi => psi.PreRollFiller)
|
||||
.Include(p => p.ProgramScheduleAlternates)
|
||||
.ThenInclude(a => a.ProgramSchedule)
|
||||
.ThenInclude(ps => ps.Items)
|
||||
.ThenInclude(psi => psi.MidRollFiller)
|
||||
.Include(p => p.ProgramScheduleAlternates)
|
||||
.ThenInclude(a => a.ProgramSchedule)
|
||||
.ThenInclude(ps => ps.Items)
|
||||
.ThenInclude(psi => psi.PostRollFiller)
|
||||
.Include(p => p.ProgramScheduleAlternates)
|
||||
.ThenInclude(a => a.ProgramSchedule)
|
||||
.ThenInclude(ps => ps.Items)
|
||||
.ThenInclude(psi => psi.TailFiller)
|
||||
.Include(p => p.ProgramScheduleAlternates)
|
||||
.ThenInclude(a => a.ProgramSchedule)
|
||||
.ThenInclude(ps => ps.Items)
|
||||
.ThenInclude(psi => psi.FallbackFiller)
|
||||
|
||||
.Include(p => p.ProgramScheduleAnchors)
|
||||
.ThenInclude(psa => psa.EnumeratorState)
|
||||
.Include(p => p.ProgramScheduleAnchors)
|
||||
.ThenInclude(a => a.MediaItem)
|
||||
|
||||
.Include(p => p.ProgramSchedule)
|
||||
.ThenInclude(ps => ps.Items)
|
||||
.ThenInclude(psi => psi.Collection)
|
||||
@@ -98,6 +131,7 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
|
||||
.Include(p => p.ProgramSchedule)
|
||||
.ThenInclude(ps => ps.Items)
|
||||
.ThenInclude(psi => psi.FallbackFiller)
|
||||
|
||||
.SelectOneAsync(p => p.Id, p => p.Id == buildPlayout.PlayoutId)
|
||||
.Map(o => o.ToValidation<BaseError>("Playout does not exist."));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace ErsatzTV.Application.Playouts;
|
||||
|
||||
public record ReplacePlayoutAlternateSchedule(
|
||||
int Id,
|
||||
int Index,
|
||||
int ProgramScheduleId,
|
||||
List<DayOfWeek> DaysOfWeek,
|
||||
List<int> DaysOfMonth,
|
||||
List<int> MonthsOfYear);
|
||||
@@ -0,0 +1,6 @@
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Playouts;
|
||||
|
||||
public record ReplacePlayoutAlternateScheduleItems
|
||||
(int PlayoutId, List<ReplacePlayoutAlternateSchedule> Items) : IRequest<Either<BaseError, Unit>>;
|
||||
@@ -0,0 +1,158 @@
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Application.Playouts;
|
||||
|
||||
public class ReplacePlayoutAlternateScheduleItemsHandler :
|
||||
IRequestHandler<ReplacePlayoutAlternateScheduleItems, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
|
||||
private readonly ILogger<ReplacePlayoutAlternateScheduleItemsHandler> _logger;
|
||||
|
||||
public ReplacePlayoutAlternateScheduleItemsHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ChannelWriter<IBackgroundServiceRequest> channel,
|
||||
ILogger<ReplacePlayoutAlternateScheduleItemsHandler> logger)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_channel = channel;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Unit>> Handle(
|
||||
ReplacePlayoutAlternateScheduleItems request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// TODO: validate that items is not empty
|
||||
|
||||
try
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
Option<Playout> maybePlayout = await dbContext.Playouts
|
||||
.Include(p => p.ProgramSchedule)
|
||||
.Include(p => p.ProgramScheduleAlternates)
|
||||
.ThenInclude(p => p.ProgramSchedule)
|
||||
.SelectOneAsync(p => p.Id, p => p.Id == request.PlayoutId);
|
||||
|
||||
foreach (Playout playout in maybePlayout)
|
||||
{
|
||||
var existingScheduleMap = new Dictionary<DateTimeOffset, ProgramSchedule>();
|
||||
var daysToCheck = new List<DateTimeOffset>();
|
||||
|
||||
Option<PlayoutItem> maybeLastPlayoutItem = await dbContext.PlayoutItems
|
||||
.Filter(pi => pi.PlayoutId == request.PlayoutId)
|
||||
.OrderByDescending(pi => pi.Start)
|
||||
.FirstOrDefaultAsync(cancellationToken)
|
||||
.Map(Optional);
|
||||
|
||||
foreach (PlayoutItem lastPlayoutItem in maybeLastPlayoutItem)
|
||||
{
|
||||
DateTimeOffset start = DateTimeOffset.Now;
|
||||
daysToCheck = Enumerable.Range(0, (lastPlayoutItem.StartOffset - start).Days + 1)
|
||||
.Select(d => start.AddDays(d))
|
||||
.ToList();
|
||||
|
||||
foreach (DateTimeOffset dayToCheck in daysToCheck)
|
||||
{
|
||||
ProgramSchedule schedule = PlayoutScheduleSelector.GetProgramScheduleFor(
|
||||
playout.ProgramSchedule,
|
||||
playout.ProgramScheduleAlternates,
|
||||
dayToCheck);
|
||||
|
||||
existingScheduleMap.Add(dayToCheck, schedule);
|
||||
}
|
||||
}
|
||||
|
||||
// exclude highest index
|
||||
int maxIndex = request.Items.Map(x => x.Index).Max();
|
||||
ReplacePlayoutAlternateSchedule highest = request.Items.First(x => x.Index == maxIndex);
|
||||
|
||||
ProgramScheduleAlternate[] existing = playout.ProgramScheduleAlternates.ToArray();
|
||||
|
||||
var incoming = request.Items.Except(new[] { highest }).ToList();
|
||||
|
||||
var toAdd = incoming.Filter(x => existing.All(e => e.Id != x.Id)).ToList();
|
||||
var toRemove = existing.Filter(e => incoming.All(m => m.Id != e.Id)).ToList();
|
||||
var toUpdate = incoming.Except(toAdd).ToList();
|
||||
|
||||
playout.ProgramScheduleAlternates.RemoveAll(toRemove.Contains);
|
||||
|
||||
foreach (ReplacePlayoutAlternateSchedule add in toAdd)
|
||||
{
|
||||
playout.ProgramScheduleAlternates.Add(
|
||||
new ProgramScheduleAlternate
|
||||
{
|
||||
PlayoutId = playout.Id,
|
||||
Index = add.Index,
|
||||
ProgramScheduleId = add.ProgramScheduleId,
|
||||
DaysOfWeek = add.DaysOfWeek,
|
||||
DaysOfMonth = add.DaysOfMonth,
|
||||
MonthsOfYear = add.MonthsOfYear
|
||||
});
|
||||
}
|
||||
|
||||
foreach (ReplacePlayoutAlternateSchedule update in toUpdate)
|
||||
{
|
||||
foreach (ProgramScheduleAlternate ex in existing.Filter(x => x.Id == update.Id))
|
||||
{
|
||||
ex.Index = update.Index;
|
||||
ex.ProgramScheduleId = update.ProgramScheduleId;
|
||||
ex.DaysOfWeek = update.DaysOfWeek;
|
||||
ex.DaysOfMonth = update.DaysOfMonth;
|
||||
ex.MonthsOfYear = update.MonthsOfYear;
|
||||
}
|
||||
}
|
||||
|
||||
// save highest index directly to playout
|
||||
if (playout.ProgramScheduleId != highest.ProgramScheduleId)
|
||||
{
|
||||
playout.ProgramScheduleId = highest.ProgramScheduleId;
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
foreach (PlayoutItem _ in maybeLastPlayoutItem)
|
||||
{
|
||||
foreach (DateTimeOffset dayToCheck in daysToCheck)
|
||||
{
|
||||
ProgramSchedule schedule = PlayoutScheduleSelector.GetProgramScheduleFor(
|
||||
playout.ProgramSchedule,
|
||||
playout.ProgramScheduleAlternates,
|
||||
dayToCheck);
|
||||
|
||||
if (existingScheduleMap.TryGetValue(dayToCheck, out ProgramSchedule existingValue) &&
|
||||
existingValue.Id != schedule.Id)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Alternate schedule change detected for day {Day}, schedule {One} => {Two}; will refresh playout",
|
||||
dayToCheck,
|
||||
existingValue.Name,
|
||||
schedule.Name);
|
||||
|
||||
await _channel.WriteAsync(
|
||||
new BuildPlayout(request.PlayoutId, PlayoutBuildMode.Refresh),
|
||||
cancellationToken);
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error saving alternate schedule items");
|
||||
return BaseError.New(ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,16 @@ internal static class Mapper
|
||||
playoutItem.StartOffset,
|
||||
GetDisplayDuration(playoutItem.FinishOffset - playoutItem.StartOffset));
|
||||
|
||||
internal static PlayoutAlternateScheduleViewModel ProjectToViewModel(
|
||||
ProgramScheduleAlternate programScheduleAlternate) =>
|
||||
new(
|
||||
programScheduleAlternate.Id,
|
||||
programScheduleAlternate.Index,
|
||||
programScheduleAlternate.ProgramScheduleId,
|
||||
programScheduleAlternate.DaysOfWeek,
|
||||
programScheduleAlternate.DaysOfMonth,
|
||||
programScheduleAlternate.MonthsOfYear);
|
||||
|
||||
private static string GetDisplayTitle(PlayoutItem playoutItem)
|
||||
{
|
||||
switch (playoutItem.MediaItem)
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace ErsatzTV.Application.Playouts;
|
||||
|
||||
public record PlayoutAlternateScheduleViewModel(
|
||||
int Id,
|
||||
int Index,
|
||||
int ProgramScheduleId,
|
||||
ICollection<DayOfWeek> DaysOfWeek,
|
||||
ICollection<int> DaysOfMonth,
|
||||
ICollection<int> MonthsOfYear);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.Playouts;
|
||||
|
||||
public record GetPlayoutAlternateSchedules(int PlayoutId) : IRequest<List<PlayoutAlternateScheduleViewModel>>;
|
||||
@@ -0,0 +1,53 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using static ErsatzTV.Application.Playouts.Mapper;
|
||||
|
||||
namespace ErsatzTV.Application.Playouts;
|
||||
|
||||
public class GetPlayoutAlternateSchedulesHandler :
|
||||
IRequestHandler<GetPlayoutAlternateSchedules, List<PlayoutAlternateScheduleViewModel>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
|
||||
public GetPlayoutAlternateSchedulesHandler(IDbContextFactory<TvContext> dbContextFactory) =>
|
||||
_dbContextFactory = dbContextFactory;
|
||||
|
||||
public async Task<List<PlayoutAlternateScheduleViewModel>> Handle(
|
||||
GetPlayoutAlternateSchedules request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
List<PlayoutAlternateScheduleViewModel> result = await dbContext.ProgramScheduleAlternates
|
||||
.Filter(psa => psa.PlayoutId == request.PlayoutId)
|
||||
.Include(psa => psa.ProgramSchedule)
|
||||
.Map(psa => ProjectToViewModel(psa))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
Option<ProgramSchedule> maybeDefaultSchedule = await dbContext.Playouts
|
||||
.Include(p => p.ProgramSchedule)
|
||||
.SelectOneAsync(p => p.Id, p => p.Id == request.PlayoutId)
|
||||
.MapT(p => p.ProgramSchedule);
|
||||
|
||||
foreach (ProgramSchedule defaultSchedule in maybeDefaultSchedule)
|
||||
{
|
||||
var psa = new ProgramScheduleAlternate
|
||||
{
|
||||
Id = -1,
|
||||
PlayoutId = request.PlayoutId,
|
||||
ProgramScheduleId = defaultSchedule.Id,
|
||||
ProgramSchedule = defaultSchedule,
|
||||
Index = result.Map(i => i.Index).DefaultIfEmpty().Max() + 1,
|
||||
DaysOfMonth = ProgramScheduleAlternate.AllDaysOfMonth(),
|
||||
DaysOfWeek = ProgramScheduleAlternate.AllDaysOfWeek(),
|
||||
MonthsOfYear = ProgramScheduleAlternate.AllMonthsOfYear()
|
||||
};
|
||||
|
||||
result.Add(ProjectToViewModel(psa));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,26 @@
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Libraries;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.FFmpeg.Runtime;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Plex;
|
||||
|
||||
public class CallPlexLibraryScannerHandler : CallLibraryScannerHandler,
|
||||
public class CallPlexLibraryScannerHandler : CallLibraryScannerHandler<ISynchronizePlexLibraryById>,
|
||||
IRequestHandler<ForceSynchronizePlexLibraryById, Either<BaseError, string>>,
|
||||
IRequestHandler<SynchronizePlexLibraryByIdIfNeeded, Either<BaseError, string>>
|
||||
{
|
||||
public CallPlexLibraryScannerHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IConfigElementRepository configElementRepository,
|
||||
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel,
|
||||
IMediator mediator,
|
||||
IRuntimeInfo runtimeInfo)
|
||||
: base(channel, mediator, runtimeInfo)
|
||||
: base(dbContextFactory, configElementRepository, channel, mediator, runtimeInfo)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -29,10 +36,18 @@ public class CallPlexLibraryScannerHandler : CallLibraryScannerHandler,
|
||||
ISynchronizePlexLibraryById request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Validation<BaseError, string> validation = Validate();
|
||||
Validation<BaseError, string> validation = await Validate(request);
|
||||
return await validation.Match(
|
||||
scanner => PerformScan(scanner, request, cancellationToken),
|
||||
error => Task.FromResult<Either<BaseError, string>>(error.Join()));
|
||||
error =>
|
||||
{
|
||||
foreach (ScanIsNotRequired scanIsNotRequired in error.OfType<ScanIsNotRequired>())
|
||||
{
|
||||
return Task.FromResult<Either<BaseError, string>>(scanIsNotRequired);
|
||||
}
|
||||
|
||||
return Task.FromResult<Either<BaseError, string>>(error.Join());
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, string>> PerformScan(
|
||||
@@ -57,4 +72,27 @@ public class CallPlexLibraryScannerHandler : CallLibraryScannerHandler,
|
||||
|
||||
return await base.PerformScan(scanner, arguments, cancellationToken);
|
||||
}
|
||||
|
||||
protected override async Task<DateTimeOffset> GetLastScan(
|
||||
TvContext dbContext,
|
||||
ISynchronizePlexLibraryById request)
|
||||
{
|
||||
return await dbContext.PlexLibraries
|
||||
.SelectOneAsync(l => l.Id, l => l.Id == request.PlexLibraryId)
|
||||
.Match(l => l.LastScan ?? SystemTime.MinValueUtc, () => SystemTime.MaxValueUtc);
|
||||
}
|
||||
|
||||
protected override bool ScanIsRequired(
|
||||
DateTimeOffset lastScan,
|
||||
int libraryRefreshInterval,
|
||||
ISynchronizePlexLibraryById request)
|
||||
{
|
||||
if (lastScan == SystemTime.MaxValueUtc)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(libraryRefreshInterval);
|
||||
return request.ForceScan || (libraryRefreshInterval > 0 && nextScan < DateTimeOffset.Now);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.ProgramSchedules;
|
||||
|
||||
public record CopyProgramSchedule
|
||||
(int ProgramScheduleId, string Name) : IRequest<Either<BaseError, ProgramScheduleViewModel>>;
|
||||
@@ -0,0 +1,103 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using static ErsatzTV.Application.ProgramSchedules.Mapper;
|
||||
|
||||
namespace ErsatzTV.Application.ProgramSchedules;
|
||||
|
||||
public class
|
||||
CopyProgramScheduleHandler : IRequestHandler<CopyProgramSchedule, Either<BaseError, ProgramScheduleViewModel>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
|
||||
public CopyProgramScheduleHandler(IDbContextFactory<TvContext> dbContextFactory) =>
|
||||
_dbContextFactory = dbContextFactory;
|
||||
|
||||
public async Task<Either<BaseError, ProgramScheduleViewModel>> Handle(
|
||||
CopyProgramSchedule request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, ProgramSchedule> validation = await Validate(dbContext, request);
|
||||
return await validation.Apply(p => PerformCopy(dbContext, p, request, cancellationToken));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BaseError.New(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ProgramScheduleViewModel> PerformCopy(
|
||||
TvContext dbContext,
|
||||
ProgramSchedule schedule,
|
||||
CopyProgramSchedule request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
DetachEntity(dbContext, schedule);
|
||||
schedule.Name = request.Name;
|
||||
|
||||
// no playouts, no alternates
|
||||
schedule.Playouts = new List<Playout>();
|
||||
schedule.ProgramScheduleAlternates = new List<ProgramScheduleAlternate>();
|
||||
|
||||
foreach (ProgramScheduleItem item in schedule.Items)
|
||||
{
|
||||
DetachEntity(dbContext, item);
|
||||
item.ProgramScheduleId = 0;
|
||||
item.ProgramSchedule = schedule;
|
||||
}
|
||||
|
||||
await dbContext.ProgramSchedules.AddAsync(schedule, cancellationToken);
|
||||
await dbContext.ProgramScheduleItems.AddRangeAsync(schedule.Items, cancellationToken);
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return ProjectToViewModel(schedule);
|
||||
}
|
||||
|
||||
private static async Task<Validation<BaseError, ProgramSchedule>> Validate(
|
||||
TvContext dbContext,
|
||||
CopyProgramSchedule request) =>
|
||||
(await ScheduleMustExist(dbContext, request), await ValidateName(dbContext, request))
|
||||
.Apply((programSchedule, _) => programSchedule);
|
||||
|
||||
private static Task<Validation<BaseError, ProgramSchedule>> ScheduleMustExist(
|
||||
TvContext dbContext,
|
||||
CopyProgramSchedule request) =>
|
||||
dbContext.ProgramSchedules
|
||||
.AsNoTracking()
|
||||
.Include(ps => ps.Items)
|
||||
.SelectOneAsync(p => p.Id, p => p.Id == request.ProgramScheduleId)
|
||||
.Map(o => o.ToValidation<BaseError>("Schedule does not exist."));
|
||||
|
||||
private static async Task<Validation<BaseError, string>> ValidateName(
|
||||
TvContext dbContext,
|
||||
CopyProgramSchedule request)
|
||||
{
|
||||
List<string> allNames = await dbContext.ProgramSchedules
|
||||
.Map(ps => ps.Name)
|
||||
.ToListAsync();
|
||||
|
||||
Validation<BaseError, string> result1 = request.NotEmpty(c => c.Name)
|
||||
.Bind(_ => request.NotLongerThan(50)(c => c.Name));
|
||||
|
||||
var result2 = Optional(request.Name)
|
||||
.Where(name => !allNames.Contains(name))
|
||||
.ToValidation<BaseError>("Schedule name must be unique");
|
||||
|
||||
return (result1, result2).Apply((_, _) => request.Name);
|
||||
}
|
||||
|
||||
private static void DetachEntity<T>(DbContext db, T entity) where T : class
|
||||
{
|
||||
db.Entry(entity).State = EntityState.Detached;
|
||||
if (entity.GetType().GetProperty("Id") is not null)
|
||||
{
|
||||
entity.GetType().GetProperty("Id")!.SetValue(entity, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,10 +17,10 @@ public class CreateProgramScheduleHandler :
|
||||
CreateProgramSchedule request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
Validation<BaseError, ProgramSchedule> validation = await Validate(dbContext, request);
|
||||
return await LanguageExtensions.Apply(validation, ps => PersistProgramSchedule(dbContext, ps));
|
||||
return await validation.Apply(ps => PersistProgramSchedule(dbContext, ps));
|
||||
}
|
||||
|
||||
private static async Task<CreateProgramScheduleResult> PersistProgramSchedule(
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using System.Diagnostics;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Maintenance;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
@@ -14,6 +16,7 @@ namespace ErsatzTV.Application.Streaming;
|
||||
public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
|
||||
private readonly IFFmpegSegmenterService _ffmpegSegmenterService;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly ILogger<StartFFmpegSessionHandler> _logger;
|
||||
@@ -24,13 +27,15 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit
|
||||
ILogger<StartFFmpegSessionHandler> logger,
|
||||
IServiceScopeFactory serviceScopeFactory,
|
||||
IFFmpegSegmenterService ffmpegSegmenterService,
|
||||
IConfigElementRepository configElementRepository)
|
||||
IConfigElementRepository configElementRepository,
|
||||
ChannelWriter<IBackgroundServiceRequest> workerChannel)
|
||||
{
|
||||
_localFileSystem = localFileSystem;
|
||||
_logger = logger;
|
||||
_serviceScopeFactory = serviceScopeFactory;
|
||||
_ffmpegSegmenterService = ffmpegSegmenterService;
|
||||
_configElementRepository = configElementRepository;
|
||||
_workerChannel = workerChannel;
|
||||
}
|
||||
|
||||
public Task<Either<BaseError, Unit>> Handle(StartFFmpegSession request, CancellationToken cancellationToken) =>
|
||||
@@ -54,9 +59,14 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit
|
||||
// fire and forget worker
|
||||
_ = worker.Run(request.ChannelNumber, idleTimeout, cancellationToken)
|
||||
.ContinueWith(
|
||||
_ => _ffmpegSegmenterService.SessionWorkers.TryRemove(
|
||||
request.ChannelNumber,
|
||||
out IHlsSessionWorker _),
|
||||
_ =>
|
||||
{
|
||||
_ffmpegSegmenterService.SessionWorkers.TryRemove(
|
||||
request.ChannelNumber,
|
||||
out IHlsSessionWorker _);
|
||||
|
||||
_workerChannel.TryWrite(new ReleaseMemory(false));
|
||||
},
|
||||
TaskScheduler.Default);
|
||||
|
||||
string playlistFileName = Path.Combine(
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
namespace ErsatzTV.Application.Subtitles;
|
||||
|
||||
public record ExtractEmbeddedSubtitles(Option<int> PlayoutId) : IRequest<Either<BaseError, Unit>>,
|
||||
ISubtitleWorkerRequest;
|
||||
IBackgroundServiceRequest;
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Threading.Channels;
|
||||
using CliWrap;
|
||||
using CliWrap.Buffered;
|
||||
using CliWrap.Builders;
|
||||
using ErsatzTV.Application.Maintenance;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Extensions;
|
||||
@@ -21,6 +23,7 @@ public class ExtractEmbeddedSubtitlesHandler : IRequestHandler<ExtractEmbeddedSu
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly IEmbyPathReplacementService _embyPathReplacementService;
|
||||
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
|
||||
private readonly IJellyfinPathReplacementService _jellyfinPathReplacementService;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly ILogger<ExtractEmbeddedSubtitlesHandler> _logger;
|
||||
@@ -32,6 +35,7 @@ public class ExtractEmbeddedSubtitlesHandler : IRequestHandler<ExtractEmbeddedSu
|
||||
IPlexPathReplacementService plexPathReplacementService,
|
||||
IJellyfinPathReplacementService jellyfinPathReplacementService,
|
||||
IEmbyPathReplacementService embyPathReplacementService,
|
||||
ChannelWriter<IBackgroundServiceRequest> workerChannel,
|
||||
ILogger<ExtractEmbeddedSubtitlesHandler> logger)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
@@ -39,6 +43,7 @@ public class ExtractEmbeddedSubtitlesHandler : IRequestHandler<ExtractEmbeddedSu
|
||||
_plexPathReplacementService = plexPathReplacementService;
|
||||
_jellyfinPathReplacementService = jellyfinPathReplacementService;
|
||||
_embyPathReplacementService = embyPathReplacementService;
|
||||
_workerChannel = workerChannel;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -49,7 +54,12 @@ public class ExtractEmbeddedSubtitlesHandler : IRequestHandler<ExtractEmbeddedSu
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, string> validation = await FFmpegPathMustExist(dbContext);
|
||||
return await validation.Match(
|
||||
ffmpegPath => ExtractAll(dbContext, request, ffmpegPath, cancellationToken),
|
||||
async ffmpegPath =>
|
||||
{
|
||||
Either<BaseError, Unit> result = await ExtractAll(dbContext, request, ffmpegPath, cancellationToken);
|
||||
await _workerChannel.WriteAsync(new ReleaseMemory(false), cancellationToken);
|
||||
return result;
|
||||
},
|
||||
error => Task.FromResult<Either<BaseError, Unit>>(error.Join()));
|
||||
}
|
||||
|
||||
@@ -68,6 +78,7 @@ public class ExtractEmbeddedSubtitlesHandler : IRequestHandler<ExtractEmbeddedSu
|
||||
|
||||
// only check the requested playout if subtitles are enabled
|
||||
Option<Playout> requestedPlayout = await dbContext.Playouts
|
||||
.AsNoTracking()
|
||||
.Filter(
|
||||
p => p.Channel.SubtitleMode != ChannelSubtitleMode.None ||
|
||||
p.ProgramSchedule.Items.Any(psi => psi.SubtitleMode != ChannelSubtitleMode.None))
|
||||
@@ -79,6 +90,7 @@ public class ExtractEmbeddedSubtitlesHandler : IRequestHandler<ExtractEmbeddedSu
|
||||
if (request.PlayoutId.IsNone)
|
||||
{
|
||||
playoutIdsToCheck = dbContext.Playouts
|
||||
.AsNoTracking()
|
||||
.Filter(
|
||||
p => p.Channel.SubtitleMode != ChannelSubtitleMode.None ||
|
||||
p.ProgramSchedule.Items.Any(psi => psi.SubtitleMode != ChannelSubtitleMode.None))
|
||||
@@ -104,6 +116,7 @@ public class ExtractEmbeddedSubtitlesHandler : IRequestHandler<ExtractEmbeddedSu
|
||||
|
||||
// find all playout items in the next hour
|
||||
List<PlayoutItem> playoutItems = await dbContext.PlayoutItems
|
||||
.AsNoTracking()
|
||||
.Filter(pi => playoutIdsToCheck.Contains(pi.PlayoutId))
|
||||
.Filter(pi => pi.Finish >= DateTime.UtcNow)
|
||||
.Filter(pi => pi.Start <= until)
|
||||
@@ -170,6 +183,7 @@ public class ExtractEmbeddedSubtitlesHandler : IRequestHandler<ExtractEmbeddedSu
|
||||
try
|
||||
{
|
||||
List<int> episodeIds = await dbContext.EpisodeMetadata
|
||||
.AsNoTracking()
|
||||
.Filter(em => mediaItemIds.Contains(em.EpisodeId))
|
||||
.Filter(
|
||||
em => em.Subtitles.Any(
|
||||
@@ -180,6 +194,7 @@ public class ExtractEmbeddedSubtitlesHandler : IRequestHandler<ExtractEmbeddedSu
|
||||
result.AddRange(episodeIds);
|
||||
|
||||
List<int> movieIds = await dbContext.MovieMetadata
|
||||
.AsNoTracking()
|
||||
.Filter(mm => mediaItemIds.Contains(mm.MovieId))
|
||||
.Filter(
|
||||
mm => mm.Subtitles.Any(
|
||||
@@ -190,6 +205,7 @@ public class ExtractEmbeddedSubtitlesHandler : IRequestHandler<ExtractEmbeddedSu
|
||||
result.AddRange(movieIds);
|
||||
|
||||
List<int> musicVideoIds = await dbContext.MusicVideoMetadata
|
||||
.AsNoTracking()
|
||||
.Filter(mm => mediaItemIds.Contains(mm.MusicVideoId))
|
||||
.Filter(
|
||||
mm => mm.Subtitles.Any(
|
||||
@@ -200,6 +216,7 @@ public class ExtractEmbeddedSubtitlesHandler : IRequestHandler<ExtractEmbeddedSu
|
||||
result.AddRange(musicVideoIds);
|
||||
|
||||
List<int> otherVideoIds = await dbContext.OtherVideoMetadata
|
||||
.AsNoTracking()
|
||||
.Filter(ovm => mediaItemIds.Contains(ovm.OtherVideoId))
|
||||
.Filter(
|
||||
ovm => ovm.Subtitles.Any(
|
||||
|
||||
@@ -9,15 +9,15 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Bugsnag" Version="3.1.0" />
|
||||
<PackageReference Include="CliWrap" Version="3.6.0" />
|
||||
<PackageReference Include="FluentAssertions" Version="6.8.0" />
|
||||
<PackageReference Include="LanguageExt.Core" Version="4.4.0" />
|
||||
<PackageReference Include="FluentAssertions" Version="6.10.0" />
|
||||
<PackageReference Include="LanguageExt.Core" Version="4.4.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="7.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="7.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging" Version="7.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="7.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="7.0.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.4.1" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.4.27">
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.5.22">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
@@ -34,16 +34,4 @@
|
||||
<ProjectReference Include="..\ErsatzTV.Infrastructure\ErsatzTV.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="Resources\ErsatzTV.png">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Include="Resources\test.sup">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Include="Resources\test.srt">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -15,7 +15,7 @@ public class FakeMediaCollectionRepository : IMediaCollectionRepository
|
||||
|
||||
public Task<List<MediaItem>> GetItems(int id) => _data[id].ToList().AsTask();
|
||||
public Task<List<MediaItem>> GetMultiCollectionItems(int id) => throw new NotSupportedException();
|
||||
public Task<List<MediaItem>> GetSmartCollectionItems(int id) => throw new NotSupportedException();
|
||||
public Task<List<MediaItem>> GetSmartCollectionItems(int id) => _data[id].ToList().AsTask();
|
||||
|
||||
public Task<List<CollectionWithItems>> GetMultiCollectionCollections(int id) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
@@ -59,6 +59,8 @@ public class FakeTelevisionRepository : ITelevisionRepository
|
||||
public Task<List<int>> DeleteEmptyShows(LibraryPath libraryPath) => throw new NotSupportedException();
|
||||
|
||||
public Task<bool> AddGenre(ShowMetadata metadata, Genre genre) => throw new NotSupportedException();
|
||||
public Task<bool> AddGenre(EpisodeMetadata metadata, Genre genre) => throw new NotSupportedException();
|
||||
|
||||
public Task<bool> AddTag(Domain.Metadata metadata, Tag tag) => throw new NotSupportedException();
|
||||
|
||||
public Task<bool> AddStudio(ShowMetadata metadata, Studio studio) => throw new NotSupportedException();
|
||||
|
||||
@@ -543,7 +543,8 @@ public class PlayoutBuilderTests
|
||||
},
|
||||
Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" },
|
||||
ProgramScheduleAnchors = new List<PlayoutProgramScheduleAnchor>(),
|
||||
Items = new List<PlayoutItem>()
|
||||
Items = new List<PlayoutItem>(),
|
||||
ProgramScheduleAlternates = new List<ProgramScheduleAlternate>()
|
||||
};
|
||||
|
||||
var configRepo = new Mock<IConfigElementRepository>();
|
||||
@@ -639,7 +640,8 @@ public class PlayoutBuilderTests
|
||||
},
|
||||
Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" },
|
||||
ProgramScheduleAnchors = new List<PlayoutProgramScheduleAnchor>(),
|
||||
Items = new List<PlayoutItem>()
|
||||
Items = new List<PlayoutItem>(),
|
||||
ProgramScheduleAlternates = new List<ProgramScheduleAlternate>()
|
||||
};
|
||||
|
||||
var configRepo = new Mock<IConfigElementRepository>();
|
||||
@@ -783,7 +785,8 @@ public class PlayoutBuilderTests
|
||||
},
|
||||
Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" },
|
||||
ProgramScheduleAnchors = new List<PlayoutProgramScheduleAnchor>(),
|
||||
Items = new List<PlayoutItem>()
|
||||
Items = new List<PlayoutItem>(),
|
||||
ProgramScheduleAlternates = new List<ProgramScheduleAlternate>()
|
||||
};
|
||||
|
||||
var configRepo = new Mock<IConfigElementRepository>();
|
||||
@@ -885,7 +888,8 @@ public class PlayoutBuilderTests
|
||||
},
|
||||
Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" },
|
||||
ProgramScheduleAnchors = new List<PlayoutProgramScheduleAnchor>(),
|
||||
Items = new List<PlayoutItem>()
|
||||
Items = new List<PlayoutItem>(),
|
||||
ProgramScheduleAlternates = new List<ProgramScheduleAlternate>()
|
||||
};
|
||||
|
||||
var configRepo = new Mock<IConfigElementRepository>();
|
||||
@@ -996,7 +1000,8 @@ public class PlayoutBuilderTests
|
||||
InFlood = true
|
||||
},
|
||||
ProgramScheduleAnchors = new List<PlayoutProgramScheduleAnchor>(),
|
||||
Items = new List<PlayoutItem>()
|
||||
Items = new List<PlayoutItem>(),
|
||||
ProgramScheduleAlternates = new List<ProgramScheduleAlternate>()
|
||||
};
|
||||
|
||||
var configRepo = new Mock<IConfigElementRepository>();
|
||||
@@ -1100,7 +1105,8 @@ public class PlayoutBuilderTests
|
||||
},
|
||||
Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" },
|
||||
ProgramScheduleAnchors = new List<PlayoutProgramScheduleAnchor>(),
|
||||
Items = new List<PlayoutItem>()
|
||||
Items = new List<PlayoutItem>(),
|
||||
ProgramScheduleAlternates = new List<ProgramScheduleAlternate>()
|
||||
};
|
||||
|
||||
var configRepo = new Mock<IConfigElementRepository>();
|
||||
@@ -1208,7 +1214,8 @@ public class PlayoutBuilderTests
|
||||
},
|
||||
Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" },
|
||||
ProgramScheduleAnchors = new List<PlayoutProgramScheduleAnchor>(),
|
||||
Items = new List<PlayoutItem>()
|
||||
Items = new List<PlayoutItem>(),
|
||||
ProgramScheduleAlternates = new List<ProgramScheduleAlternate>()
|
||||
};
|
||||
|
||||
var configRepo = new Mock<IConfigElementRepository>();
|
||||
@@ -1321,7 +1328,8 @@ public class PlayoutBuilderTests
|
||||
MultipleRemaining = 2
|
||||
},
|
||||
ProgramScheduleAnchors = new List<PlayoutProgramScheduleAnchor>(),
|
||||
Items = new List<PlayoutItem>()
|
||||
Items = new List<PlayoutItem>(),
|
||||
ProgramScheduleAlternates = new List<ProgramScheduleAlternate>()
|
||||
};
|
||||
|
||||
var configRepo = new Mock<IConfigElementRepository>();
|
||||
@@ -1423,7 +1431,8 @@ public class PlayoutBuilderTests
|
||||
},
|
||||
Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" },
|
||||
ProgramScheduleAnchors = new List<PlayoutProgramScheduleAnchor>(),
|
||||
Items = new List<PlayoutItem>()
|
||||
Items = new List<PlayoutItem>(),
|
||||
ProgramScheduleAlternates = new List<ProgramScheduleAlternate>()
|
||||
};
|
||||
|
||||
var configRepo = new Mock<IConfigElementRepository>();
|
||||
@@ -1536,7 +1545,8 @@ public class PlayoutBuilderTests
|
||||
DurationFinish = HoursAfterMidnight(3).UtcDateTime
|
||||
},
|
||||
ProgramScheduleAnchors = new List<PlayoutProgramScheduleAnchor>(),
|
||||
Items = new List<PlayoutItem>()
|
||||
Items = new List<PlayoutItem>(),
|
||||
ProgramScheduleAlternates = new List<ProgramScheduleAlternate>()
|
||||
};
|
||||
|
||||
var configRepo = new Mock<IConfigElementRepository>();
|
||||
@@ -1660,7 +1670,8 @@ public class PlayoutBuilderTests
|
||||
},
|
||||
Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" },
|
||||
ProgramScheduleAnchors = new List<PlayoutProgramScheduleAnchor>(),
|
||||
Items = new List<PlayoutItem>()
|
||||
Items = new List<PlayoutItem>(),
|
||||
ProgramScheduleAlternates = new List<ProgramScheduleAlternate>()
|
||||
};
|
||||
|
||||
var configRepo = new Mock<IConfigElementRepository>();
|
||||
@@ -1776,7 +1787,8 @@ public class PlayoutBuilderTests
|
||||
},
|
||||
Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" },
|
||||
ProgramScheduleAnchors = new List<PlayoutProgramScheduleAnchor>(),
|
||||
Items = new List<PlayoutItem>()
|
||||
Items = new List<PlayoutItem>(),
|
||||
ProgramScheduleAlternates = new List<ProgramScheduleAlternate>()
|
||||
};
|
||||
|
||||
var configRepo = new Mock<IConfigElementRepository>();
|
||||
@@ -1852,7 +1864,8 @@ public class PlayoutBuilderTests
|
||||
},
|
||||
Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" },
|
||||
ProgramScheduleAnchors = new List<PlayoutProgramScheduleAnchor>(),
|
||||
Items = new List<PlayoutItem>()
|
||||
Items = new List<PlayoutItem>(),
|
||||
ProgramScheduleAlternates = new List<ProgramScheduleAlternate>()
|
||||
};
|
||||
|
||||
var configRepo = new Mock<IConfigElementRepository>();
|
||||
@@ -2049,7 +2062,8 @@ public class PlayoutBuilderTests
|
||||
},
|
||||
|
||||
ProgramScheduleAnchors = new List<PlayoutProgramScheduleAnchor>(),
|
||||
Items = new List<PlayoutItem>()
|
||||
Items = new List<PlayoutItem>(),
|
||||
ProgramScheduleAlternates = new List<ProgramScheduleAlternate>()
|
||||
};
|
||||
|
||||
playout.ProgramScheduleAnchors.Add(
|
||||
@@ -2366,6 +2380,91 @@ public class PlayoutBuilderTests
|
||||
// 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 ShuffleFlood_MultipleSmartCollections_Should_MaintainRandomSeed()
|
||||
{
|
||||
var mediaItems = new List<MediaItem>
|
||||
{
|
||||
TestMovie(1, TimeSpan.FromHours(1), DateTime.Today),
|
||||
TestMovie(2, TimeSpan.FromHours(1), DateTime.Today.AddHours(1)),
|
||||
TestMovie(3, TimeSpan.FromHours(1), DateTime.Today.AddHours(3))
|
||||
};
|
||||
|
||||
(PlayoutBuilder builder, Playout playout) =
|
||||
TestDataFloodForSmartCollectionItems(mediaItems, PlaybackOrder.Shuffle);
|
||||
DateTimeOffset start = HoursAfterMidnight(0);
|
||||
DateTimeOffset finish = start + TimeSpan.FromHours(6);
|
||||
|
||||
Playout result = await builder.Build(playout, PlayoutBuildMode.Reset, start, finish);
|
||||
|
||||
result.Items.Count.Should().Be(6);
|
||||
result.ProgramScheduleAnchors.Count.Should().Be(2);
|
||||
PlayoutProgramScheduleAnchor primaryAnchor = result.ProgramScheduleAnchors.First(a => a.SmartCollectionId == 1);
|
||||
primaryAnchor.EnumeratorState.Seed.Should().BeGreaterThan(0);
|
||||
primaryAnchor.EnumeratorState.Index.Should().Be(0);
|
||||
|
||||
int firstSeedValue = primaryAnchor.EnumeratorState.Seed;
|
||||
|
||||
DateTimeOffset start2 = HoursAfterMidnight(0);
|
||||
DateTimeOffset finish2 = start2 + TimeSpan.FromHours(6);
|
||||
|
||||
Playout result2 = await builder.Build(result, PlayoutBuildMode.Continue, start2, finish2);
|
||||
|
||||
primaryAnchor = result2.ProgramScheduleAnchors.First(a => a.SmartCollectionId == 1);
|
||||
int secondSeedValue = primaryAnchor.EnumeratorState.Seed;
|
||||
|
||||
firstSeedValue.Should().Be(secondSeedValue);
|
||||
|
||||
primaryAnchor.EnumeratorState.Index.Should().Be(0);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ShuffleFlood_MultipleSmartCollections_Should_MaintainRandomSeed_MultipleDays()
|
||||
{
|
||||
var mediaItems = new List<MediaItem>();
|
||||
for (int i = 1; i <= 100; i++)
|
||||
{
|
||||
mediaItems.Add(TestMovie(i, TimeSpan.FromMinutes(55), DateTime.Today.AddHours(i)));
|
||||
}
|
||||
|
||||
(PlayoutBuilder builder, Playout playout) =
|
||||
TestDataFloodForSmartCollectionItems(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(4);
|
||||
|
||||
result.ProgramScheduleAnchors.All(x => x.AnchorDate is not null).Should().BeTrue();
|
||||
PlayoutProgramScheduleAnchor lastCheckpoint = result.ProgramScheduleAnchors
|
||||
.Filter(psa => psa.SmartCollectionId == 1)
|
||||
.OrderByDescending(a => a.AnchorDate ?? DateTime.MinValue)
|
||||
.First();
|
||||
lastCheckpoint.EnumeratorState.Seed.Should().BeGreaterThan(0);
|
||||
lastCheckpoint.EnumeratorState.Index.Should().Be(53);
|
||||
|
||||
int firstSeedValue = lastCheckpoint.EnumeratorState.Seed;
|
||||
|
||||
for (var i = 1; i < 20; i++)
|
||||
{
|
||||
DateTimeOffset start2 = start.AddHours(i);
|
||||
DateTimeOffset finish2 = start2 + TimeSpan.FromDays(2);
|
||||
|
||||
Playout result2 = await builder.Build(result, PlayoutBuildMode.Continue, start2, finish2);
|
||||
|
||||
PlayoutProgramScheduleAnchor continueAnchor =
|
||||
result2.ProgramScheduleAnchors
|
||||
.Filter(psa => psa.SmartCollectionId == 1)
|
||||
.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()
|
||||
@@ -2437,7 +2536,8 @@ public class PlayoutBuilderTests
|
||||
InFlood = true
|
||||
},
|
||||
ProgramScheduleAnchors = new List<PlayoutProgramScheduleAnchor>(),
|
||||
Items = new List<PlayoutItem>()
|
||||
Items = new List<PlayoutItem>(),
|
||||
ProgramScheduleAlternates = new List<ProgramScheduleAlternate>()
|
||||
};
|
||||
|
||||
var configRepo = new Mock<IConfigElementRepository>();
|
||||
@@ -2548,7 +2648,8 @@ public class PlayoutBuilderTests
|
||||
MultipleRemaining = 2
|
||||
},
|
||||
ProgramScheduleAnchors = new List<PlayoutProgramScheduleAnchor>(),
|
||||
Items = new List<PlayoutItem>()
|
||||
Items = new List<PlayoutItem>(),
|
||||
ProgramScheduleAlternates = new List<ProgramScheduleAlternate>()
|
||||
};
|
||||
|
||||
var configRepo = new Mock<IConfigElementRepository>();
|
||||
@@ -2659,7 +2760,8 @@ public class PlayoutBuilderTests
|
||||
DurationFinish = HoursAfterMidnight(3).UtcDateTime
|
||||
},
|
||||
ProgramScheduleAnchors = new List<PlayoutProgramScheduleAnchor>(),
|
||||
Items = new List<PlayoutItem>()
|
||||
Items = new List<PlayoutItem>(),
|
||||
ProgramScheduleAlternates = new List<ProgramScheduleAlternate>()
|
||||
};
|
||||
|
||||
var configRepo = new Mock<IConfigElementRepository>();
|
||||
@@ -2710,10 +2812,34 @@ public class PlayoutBuilderTests
|
||||
{
|
||||
Id = 1,
|
||||
Index = 1,
|
||||
CollectionType = ProgramScheduleItemCollectionType.Collection,
|
||||
Collection = mediaCollection,
|
||||
CollectionId = mediaCollection.Id,
|
||||
StartTime = null,
|
||||
PlaybackOrder = playbackOrder
|
||||
PlaybackOrder = playbackOrder,
|
||||
};
|
||||
|
||||
private static ProgramScheduleItem Flood(
|
||||
SmartCollection smartCollection,
|
||||
SmartCollection fillerCollection,
|
||||
PlaybackOrder playbackOrder) =>
|
||||
new ProgramScheduleItemFlood
|
||||
{
|
||||
Id = 1,
|
||||
Index = 1,
|
||||
CollectionType = ProgramScheduleItemCollectionType.SmartCollection,
|
||||
SmartCollection = smartCollection,
|
||||
SmartCollectionId = smartCollection.Id,
|
||||
StartTime = null,
|
||||
PlaybackOrder = playbackOrder,
|
||||
FallbackFiller = new FillerPreset
|
||||
{
|
||||
Id = 1,
|
||||
CollectionType = ProgramScheduleItemCollectionType.SmartCollection,
|
||||
SmartCollection = fillerCollection,
|
||||
SmartCollectionId = fillerCollection.Id,
|
||||
FillerKind = FillerKind.Fallback
|
||||
}
|
||||
};
|
||||
|
||||
private static Movie TestMovie(int id, TimeSpan duration, DateTime aired) =>
|
||||
@@ -2768,7 +2894,61 @@ public class PlayoutBuilderTests
|
||||
ProgramSchedule = new ProgramSchedule { Items = items },
|
||||
Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" },
|
||||
Items = new List<PlayoutItem>(),
|
||||
ProgramScheduleAnchors = new List<PlayoutProgramScheduleAnchor>()
|
||||
ProgramScheduleAnchors = new List<PlayoutProgramScheduleAnchor>(),
|
||||
ProgramScheduleAlternates = new List<ProgramScheduleAlternate>()
|
||||
};
|
||||
|
||||
return new TestData(builder, playout);
|
||||
}
|
||||
|
||||
private TestData TestDataFloodForSmartCollectionItems(
|
||||
List<MediaItem> mediaItems,
|
||||
PlaybackOrder playbackOrder,
|
||||
Mock<IConfigElementRepository> configMock = null)
|
||||
{
|
||||
var mediaCollection = new SmartCollection
|
||||
{
|
||||
Id = 1,
|
||||
Query = "asdf"
|
||||
};
|
||||
|
||||
var fillerCollection = new SmartCollection
|
||||
{
|
||||
Id = 2,
|
||||
Query = "ghjk"
|
||||
};
|
||||
|
||||
Mock<IConfigElementRepository> configRepo = configMock ?? new Mock<IConfigElementRepository>();
|
||||
|
||||
var collectionRepo = new FakeMediaCollectionRepository(
|
||||
Map(
|
||||
(mediaCollection.Id, mediaItems),
|
||||
(fillerCollection.Id, mediaItems.Take(1).ToList())
|
||||
)
|
||||
);
|
||||
var televisionRepo = new FakeTelevisionRepository();
|
||||
var artistRepo = new Mock<IArtistRepository>();
|
||||
var factory = new Mock<IMultiEpisodeShuffleCollectionEnumeratorFactory>();
|
||||
var localFileSystem = new Mock<ILocalFileSystem>();
|
||||
var builder = new PlayoutBuilder(
|
||||
configRepo.Object,
|
||||
collectionRepo,
|
||||
televisionRepo,
|
||||
artistRepo.Object,
|
||||
factory.Object,
|
||||
localFileSystem.Object,
|
||||
_logger);
|
||||
|
||||
var items = new List<ProgramScheduleItem> { Flood(mediaCollection, fillerCollection, playbackOrder) };
|
||||
|
||||
var playout = new Playout
|
||||
{
|
||||
Id = 1,
|
||||
ProgramSchedule = new ProgramSchedule { Items = items },
|
||||
Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" },
|
||||
Items = new List<PlayoutItem>(),
|
||||
ProgramScheduleAnchors = new List<PlayoutProgramScheduleAnchor>(),
|
||||
ProgramScheduleAlternates = new List<ProgramScheduleAlternate>()
|
||||
};
|
||||
|
||||
return new TestData(builder, playout);
|
||||
|
||||
@@ -356,6 +356,214 @@ public class PlayoutModeSchedulerBaseTests : SchedulerTestBase
|
||||
playoutItems[2].MediaItemId.Should().Be(1);
|
||||
playoutItems[2].StartOffset.Should().Be(startState.CurrentTime + TimeSpan.FromMinutes(11));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_Schedule_Post_Roll_After_Padded_Mid_Roll()
|
||||
{
|
||||
// content 45 min, mid roll pad to 60, post roll 5 min
|
||||
// content + post = 50 min, mid roll will add two 5 min items
|
||||
// content + mid + post = 60 min
|
||||
|
||||
Collection collectionOne = TwoItemCollection(1, 2, TimeSpan.FromMinutes(45));
|
||||
Collection collectionTwo = TwoItemCollection(3, 4, TimeSpan.FromMinutes(5));
|
||||
Collection collectionThree = TwoItemCollection(5, 6, TimeSpan.FromMinutes(5));
|
||||
|
||||
var scheduleItem = new ProgramScheduleItemOne
|
||||
{
|
||||
Id = 1,
|
||||
Index = 1,
|
||||
Collection = collectionOne,
|
||||
CollectionId = collectionOne.Id,
|
||||
StartTime = null,
|
||||
PlaybackOrder = PlaybackOrder.Chronological,
|
||||
TailFiller = null,
|
||||
FallbackFiller = null,
|
||||
MidRollFiller = new FillerPreset
|
||||
{
|
||||
FillerKind = FillerKind.MidRoll,
|
||||
FillerMode = FillerMode.Pad,
|
||||
PadToNearestMinute = 60,
|
||||
CollectionId = 2,
|
||||
Collection = collectionTwo
|
||||
},
|
||||
PostRollFiller = new FillerPreset
|
||||
{
|
||||
FillerKind = FillerKind.PostRoll,
|
||||
FillerMode = FillerMode.Count,
|
||||
Count = 1,
|
||||
CollectionId = 3,
|
||||
Collection = collectionThree
|
||||
}
|
||||
};
|
||||
|
||||
var scheduleItemsEnumerator = new OrderedScheduleItemsEnumerator(
|
||||
new List<ProgramScheduleItem> { scheduleItem },
|
||||
new CollectionEnumeratorState());
|
||||
|
||||
var enumerator = new ChronologicalMediaCollectionEnumerator(
|
||||
collectionOne.MediaItems,
|
||||
new CollectionEnumeratorState());
|
||||
|
||||
var midRollFillerEnumerator = new ChronologicalMediaCollectionEnumerator(
|
||||
collectionTwo.MediaItems,
|
||||
new CollectionEnumeratorState());
|
||||
|
||||
var postRollFillerEnumerator = new ChronologicalMediaCollectionEnumerator(
|
||||
collectionThree.MediaItems,
|
||||
new CollectionEnumeratorState());
|
||||
|
||||
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
|
||||
|
||||
Dictionary<CollectionKey, IMediaCollectionEnumerator> enumerators = CollectionEnumerators(
|
||||
scheduleItem,
|
||||
enumerator);
|
||||
|
||||
enumerators.Add(CollectionKey.ForFillerPreset(scheduleItem.MidRollFiller), midRollFillerEnumerator);
|
||||
enumerators.Add(CollectionKey.ForFillerPreset(scheduleItem.PostRollFiller), postRollFillerEnumerator);
|
||||
|
||||
List<PlayoutItem> playoutItems = Scheduler()
|
||||
.AddFiller(
|
||||
startState,
|
||||
enumerators,
|
||||
scheduleItem,
|
||||
new PlayoutItem
|
||||
{
|
||||
MediaItemId = 1,
|
||||
Start = startState.CurrentTime.UtcDateTime,
|
||||
Finish = startState.CurrentTime.AddHours(1).UtcDateTime
|
||||
},
|
||||
new List<MediaChapter>
|
||||
{
|
||||
new() { StartTime = TimeSpan.Zero, EndTime = TimeSpan.FromMinutes(6) },
|
||||
new() { StartTime = TimeSpan.FromMinutes(6), EndTime = TimeSpan.FromMinutes(45) }
|
||||
});
|
||||
|
||||
playoutItems.Count.Should().Be(5);
|
||||
|
||||
// content chapter 1
|
||||
playoutItems[0].MediaItemId.Should().Be(1);
|
||||
playoutItems[0].StartOffset.Should().Be(startState.CurrentTime);
|
||||
|
||||
// mid-roll 1
|
||||
playoutItems[1].MediaItemId.Should().Be(3);
|
||||
playoutItems[1].StartOffset.Should().Be(startState.CurrentTime + TimeSpan.FromMinutes(6));
|
||||
|
||||
// mid-roll 2
|
||||
playoutItems[2].MediaItemId.Should().Be(4);
|
||||
playoutItems[2].StartOffset.Should().Be(startState.CurrentTime + TimeSpan.FromMinutes(11));
|
||||
|
||||
// content chapter 2
|
||||
playoutItems[3].MediaItemId.Should().Be(1);
|
||||
playoutItems[3].StartOffset.Should().Be(startState.CurrentTime + TimeSpan.FromMinutes(16));
|
||||
|
||||
// post-roll
|
||||
playoutItems[4].MediaItemId.Should().Be(5);
|
||||
playoutItems[4].StartOffset.Should().Be(startState.CurrentTime + TimeSpan.FromMinutes(55));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_Schedule_Padded_Post_Roll_After_Mid_Roll_Count()
|
||||
{
|
||||
// content 45 min, mid roll 5 min, post roll pad to 60
|
||||
// content + mid = 50 min, post roll will add two 5 min items
|
||||
// content + mid + post = 60 min
|
||||
|
||||
Collection collectionOne = TwoItemCollection(1, 2, TimeSpan.FromMinutes(45));
|
||||
Collection collectionTwo = TwoItemCollection(3, 4, TimeSpan.FromMinutes(5));
|
||||
Collection collectionThree = TwoItemCollection(5, 6, TimeSpan.FromMinutes(5));
|
||||
|
||||
var scheduleItem = new ProgramScheduleItemOne
|
||||
{
|
||||
Id = 1,
|
||||
Index = 1,
|
||||
Collection = collectionOne,
|
||||
CollectionId = collectionOne.Id,
|
||||
StartTime = null,
|
||||
PlaybackOrder = PlaybackOrder.Chronological,
|
||||
TailFiller = null,
|
||||
FallbackFiller = null,
|
||||
MidRollFiller = new FillerPreset
|
||||
{
|
||||
FillerKind = FillerKind.MidRoll,
|
||||
FillerMode = FillerMode.Count,
|
||||
Count = 1,
|
||||
CollectionId = 2,
|
||||
Collection = collectionTwo
|
||||
},
|
||||
PostRollFiller = new FillerPreset
|
||||
{
|
||||
FillerKind = FillerKind.PostRoll,
|
||||
FillerMode = FillerMode.Pad,
|
||||
PadToNearestMinute = 60,
|
||||
CollectionId = 3,
|
||||
Collection = collectionThree
|
||||
}
|
||||
};
|
||||
|
||||
var scheduleItemsEnumerator = new OrderedScheduleItemsEnumerator(
|
||||
new List<ProgramScheduleItem> { scheduleItem },
|
||||
new CollectionEnumeratorState());
|
||||
|
||||
var enumerator = new ChronologicalMediaCollectionEnumerator(
|
||||
collectionOne.MediaItems,
|
||||
new CollectionEnumeratorState());
|
||||
|
||||
var midRollFillerEnumerator = new ChronologicalMediaCollectionEnumerator(
|
||||
collectionTwo.MediaItems,
|
||||
new CollectionEnumeratorState());
|
||||
|
||||
var postRollFillerEnumerator = new ChronologicalMediaCollectionEnumerator(
|
||||
collectionThree.MediaItems,
|
||||
new CollectionEnumeratorState());
|
||||
|
||||
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
|
||||
|
||||
Dictionary<CollectionKey, IMediaCollectionEnumerator> enumerators = CollectionEnumerators(
|
||||
scheduleItem,
|
||||
enumerator);
|
||||
|
||||
enumerators.Add(CollectionKey.ForFillerPreset(scheduleItem.MidRollFiller), midRollFillerEnumerator);
|
||||
enumerators.Add(CollectionKey.ForFillerPreset(scheduleItem.PostRollFiller), postRollFillerEnumerator);
|
||||
|
||||
List<PlayoutItem> playoutItems = Scheduler()
|
||||
.AddFiller(
|
||||
startState,
|
||||
enumerators,
|
||||
scheduleItem,
|
||||
new PlayoutItem
|
||||
{
|
||||
MediaItemId = 1,
|
||||
Start = startState.CurrentTime.UtcDateTime,
|
||||
Finish = startState.CurrentTime.AddHours(1).UtcDateTime
|
||||
},
|
||||
new List<MediaChapter>
|
||||
{
|
||||
new() { StartTime = TimeSpan.Zero, EndTime = TimeSpan.FromMinutes(6) },
|
||||
new() { StartTime = TimeSpan.FromMinutes(6), EndTime = TimeSpan.FromMinutes(45) }
|
||||
});
|
||||
|
||||
playoutItems.Count.Should().Be(5);
|
||||
|
||||
// content chapter 1
|
||||
playoutItems[0].MediaItemId.Should().Be(1);
|
||||
playoutItems[0].StartOffset.Should().Be(startState.CurrentTime);
|
||||
|
||||
// mid-roll 1
|
||||
playoutItems[1].MediaItemId.Should().Be(3);
|
||||
playoutItems[1].StartOffset.Should().Be(startState.CurrentTime + TimeSpan.FromMinutes(6));
|
||||
|
||||
// content chapter 2
|
||||
playoutItems[2].MediaItemId.Should().Be(1);
|
||||
playoutItems[2].StartOffset.Should().Be(startState.CurrentTime + TimeSpan.FromMinutes(11));
|
||||
|
||||
// post-roll 1
|
||||
playoutItems[3].MediaItemId.Should().Be(5);
|
||||
playoutItems[3].StartOffset.Should().Be(startState.CurrentTime + TimeSpan.FromMinutes(50));
|
||||
|
||||
// post-roll 2
|
||||
playoutItems[4].MediaItemId.Should().Be(6);
|
||||
playoutItems[4].StartOffset.Should().Be(startState.CurrentTime + TimeSpan.FromMinutes(55));
|
||||
}
|
||||
}
|
||||
|
||||
[TestFixture]
|
||||
|
||||
@@ -3,12 +3,17 @@ using Dapper;
|
||||
using Destructurama;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Repositories.Caching;
|
||||
using ErsatzTV.Core.Interfaces.Scheduling;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Data.Repositories;
|
||||
using ErsatzTV.Infrastructure.Data.Repositories.Caching;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using ErsatzTV.Infrastructure.Search;
|
||||
using LanguageExt.UnsafeValueAccess;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
@@ -28,7 +33,7 @@ public class ScheduleIntegrationTests
|
||||
public ScheduleIntegrationTests()
|
||||
{
|
||||
Log.Logger = new LoggerConfiguration()
|
||||
.MinimumLevel.Information()
|
||||
.MinimumLevel.Debug()
|
||||
.MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
|
||||
.MinimumLevel.Override("System.Net.Http.HttpClient", LogEventLevel.Warning)
|
||||
.WriteTo.Console()
|
||||
@@ -37,7 +42,123 @@ public class ScheduleIntegrationTests
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Test()
|
||||
public async Task TestExistingData()
|
||||
{
|
||||
const string DB_FILE_NAME = "/tmp/whatever.sqlite3";
|
||||
const int PLAYOUT_ID = 39;
|
||||
|
||||
var start = new DateTimeOffset(2023, 1, 18, 11, 0, 0, TimeSpan.FromHours(-5));
|
||||
DateTimeOffset finish = start.AddDays(2);
|
||||
|
||||
IServiceCollection services = new ServiceCollection()
|
||||
.AddLogging();
|
||||
|
||||
var connectionString = $"Data Source={DB_FILE_NAME};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()));
|
||||
|
||||
services.AddScoped<ISearchRepository, SearchRepository>();
|
||||
services.AddScoped<ICachingSearchRepository, CachingSearchRepository>();
|
||||
services.AddScoped<IConfigElementRepository, ConfigElementRepository>();
|
||||
services.AddScoped<IFallbackMetadataProvider, FallbackMetadataProvider>();
|
||||
|
||||
services.AddSingleton<ISearchIndex, SearchIndex>();
|
||||
|
||||
services.AddSingleton(_ => new Mock<IClient>().Object);
|
||||
|
||||
ServiceProvider provider = services.BuildServiceProvider();
|
||||
|
||||
IDbContextFactory<TvContext> factory = provider.GetRequiredService<IDbContextFactory<TvContext>>();
|
||||
|
||||
ILogger<ScheduleIntegrationTests> logger = provider.GetRequiredService<ILogger<ScheduleIntegrationTests>>();
|
||||
logger.LogInformation("Database is at {File}", DB_FILE_NAME);
|
||||
|
||||
await using TvContext dbContext = await factory.CreateDbContextAsync(CancellationToken.None);
|
||||
await dbContext.Database.MigrateAsync(CancellationToken.None);
|
||||
await DbInitializer.Initialize(dbContext, CancellationToken.None);
|
||||
|
||||
ISearchIndex searchIndex = provider.GetRequiredService<ISearchIndex>();
|
||||
await searchIndex.Initialize(
|
||||
new LocalFileSystem(
|
||||
provider.GetRequiredService<IClient>(),
|
||||
provider.GetRequiredService<ILogger<LocalFileSystem>>()),
|
||||
provider.GetRequiredService<IConfigElementRepository>());
|
||||
|
||||
await searchIndex.Rebuild(
|
||||
provider.GetRequiredService<ICachingSearchRepository>(),
|
||||
provider.GetRequiredService<IFallbackMetadataProvider>());
|
||||
|
||||
var builder = new PlayoutBuilder(
|
||||
new ConfigElementRepository(factory),
|
||||
new MediaCollectionRepository(new Mock<IClient>().Object, searchIndex, factory),
|
||||
new TelevisionRepository(factory),
|
||||
new ArtistRepository(factory),
|
||||
new Mock<IMultiEpisodeShuffleCollectionEnumeratorFactory>().Object,
|
||||
new Mock<ILocalFileSystem>().Object,
|
||||
provider.GetRequiredService<ILogger<PlayoutBuilder>>());
|
||||
|
||||
{
|
||||
await using TvContext context = await factory.CreateDbContextAsync();
|
||||
|
||||
Option<Playout> maybePlayout = await GetPlayout(context, PLAYOUT_ID);
|
||||
Playout playout = maybePlayout.ValueUnsafe();
|
||||
|
||||
await builder.Build(playout, PlayoutBuildMode.Reset, start, finish);
|
||||
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
for (var i = 1; i <= (24 * 1); i++)
|
||||
{
|
||||
await using TvContext context = await factory.CreateDbContextAsync();
|
||||
|
||||
Option<Playout> maybePlayout = await GetPlayout(context, PLAYOUT_ID);
|
||||
Playout playout = maybePlayout.ValueUnsafe();
|
||||
|
||||
await builder.Build(playout, PlayoutBuildMode.Continue, start.AddHours(i), finish.AddHours(i));
|
||||
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
for (var i = 25; i <= 26; i++)
|
||||
{
|
||||
await using TvContext context = await factory.CreateDbContextAsync();
|
||||
|
||||
Option<Playout> maybePlayout = await GetPlayout(context, PLAYOUT_ID);
|
||||
Playout playout = maybePlayout.ValueUnsafe();
|
||||
|
||||
await builder.Build(playout, PlayoutBuildMode.Continue, start.AddHours(i), finish.AddHours(i));
|
||||
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task TestMockData()
|
||||
{
|
||||
string dbFileName = Path.GetTempFileName() + ".sqlite3";
|
||||
|
||||
@@ -221,8 +342,41 @@ public class ScheduleIntegrationTests
|
||||
return await dbContext.Playouts
|
||||
.Include(p => p.Channel)
|
||||
.Include(p => p.Items)
|
||||
|
||||
.Include(p => p.ProgramScheduleAlternates)
|
||||
.ThenInclude(a => a.ProgramSchedule)
|
||||
.ThenInclude(ps => ps.Items)
|
||||
.ThenInclude(psi => psi.Collection)
|
||||
.Include(p => p.ProgramScheduleAlternates)
|
||||
.ThenInclude(a => a.ProgramSchedule)
|
||||
.ThenInclude(ps => ps.Items)
|
||||
.ThenInclude(psi => psi.MediaItem)
|
||||
.Include(p => p.ProgramScheduleAlternates)
|
||||
.ThenInclude(a => a.ProgramSchedule)
|
||||
.ThenInclude(ps => ps.Items)
|
||||
.ThenInclude(psi => psi.PreRollFiller)
|
||||
.Include(p => p.ProgramScheduleAlternates)
|
||||
.ThenInclude(a => a.ProgramSchedule)
|
||||
.ThenInclude(ps => ps.Items)
|
||||
.ThenInclude(psi => psi.MidRollFiller)
|
||||
.Include(p => p.ProgramScheduleAlternates)
|
||||
.ThenInclude(a => a.ProgramSchedule)
|
||||
.ThenInclude(ps => ps.Items)
|
||||
.ThenInclude(psi => psi.PostRollFiller)
|
||||
.Include(p => p.ProgramScheduleAlternates)
|
||||
.ThenInclude(a => a.ProgramSchedule)
|
||||
.ThenInclude(ps => ps.Items)
|
||||
.ThenInclude(psi => psi.TailFiller)
|
||||
.Include(p => p.ProgramScheduleAlternates)
|
||||
.ThenInclude(a => a.ProgramSchedule)
|
||||
.ThenInclude(ps => ps.Items)
|
||||
.ThenInclude(psi => psi.FallbackFiller)
|
||||
|
||||
.Include(p => p.ProgramScheduleAnchors)
|
||||
.ThenInclude(a => a.EnumeratorState)
|
||||
.Include(p => p.ProgramScheduleAnchors)
|
||||
.ThenInclude(a => a.MediaItem)
|
||||
|
||||
.Include(p => p.ProgramSchedule)
|
||||
.ThenInclude(ps => ps.Items)
|
||||
.ThenInclude(psi => psi.Collection)
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
namespace ErsatzTV.Core.Domain;
|
||||
|
||||
public record LogEntry(
|
||||
int Id,
|
||||
DateTime Timestamp,
|
||||
string Level,
|
||||
string Exception,
|
||||
string RenderedMessage,
|
||||
string Properties);
|
||||
@@ -7,6 +7,7 @@ public class Playout
|
||||
public Channel Channel { get; set; }
|
||||
public int ProgramScheduleId { get; set; }
|
||||
public ProgramSchedule ProgramSchedule { get; set; }
|
||||
public List<ProgramScheduleAlternate> ProgramScheduleAlternates { get; set; }
|
||||
public ProgramSchedulePlayoutType ProgramSchedulePlayoutType { get; set; }
|
||||
public List<PlayoutItem> Items { get; set; }
|
||||
public PlayoutAnchor Anchor { get; set; }
|
||||
|
||||
@@ -10,4 +10,5 @@ public class ProgramSchedule
|
||||
public bool RandomStartPoint { get; set; }
|
||||
public List<ProgramScheduleItem> Items { get; set; }
|
||||
public List<Playout> Playouts { get; set; }
|
||||
public List<ProgramScheduleAlternate> ProgramScheduleAlternates { get; set; }
|
||||
}
|
||||
|
||||
28
ErsatzTV.Core/Domain/ProgramScheduleAlternate.cs
Normal file
28
ErsatzTV.Core/Domain/ProgramScheduleAlternate.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
namespace ErsatzTV.Core.Domain;
|
||||
|
||||
public class ProgramScheduleAlternate
|
||||
{
|
||||
public static List<DayOfWeek> AllDaysOfWeek() => new()
|
||||
{
|
||||
DayOfWeek.Monday,
|
||||
DayOfWeek.Tuesday,
|
||||
DayOfWeek.Wednesday,
|
||||
DayOfWeek.Thursday,
|
||||
DayOfWeek.Friday,
|
||||
DayOfWeek.Saturday,
|
||||
DayOfWeek.Sunday
|
||||
};
|
||||
|
||||
public static List<int> AllDaysOfMonth() => Enumerable.Range(1, 31).ToList();
|
||||
public static List<int> AllMonthsOfYear() => Enumerable.Range(1, 12).ToList();
|
||||
|
||||
public int Id { get; set; }
|
||||
public int PlayoutId { get; set; }
|
||||
public Playout Playout { get; set; }
|
||||
public int ProgramScheduleId { get; set; }
|
||||
public ProgramSchedule ProgramSchedule { get; set; }
|
||||
public int Index { get; set; }
|
||||
public ICollection<DayOfWeek> DaysOfWeek { get; set; }
|
||||
public ICollection<int> DaysOfMonth { get; set; }
|
||||
public ICollection<int> MonthsOfYear { get; set; }
|
||||
}
|
||||
8
ErsatzTV.Core/Errors/ScanIsNotRequired.cs
Normal file
8
ErsatzTV.Core/Errors/ScanIsNotRequired.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace ErsatzTV.Core.Errors;
|
||||
|
||||
public class ScanIsNotRequired : BaseError
|
||||
{
|
||||
public ScanIsNotRequired() : base("Scan is not required")
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -10,15 +10,15 @@
|
||||
<PackageReference Include="Bugsnag" Version="3.1.0" />
|
||||
<PackageReference Include="Destructurama.Attributed" Version="3.0.0" />
|
||||
<PackageReference Include="Flurl" Version="3.0.7" />
|
||||
<PackageReference Include="LanguageExt.Core" Version="4.4.0" />
|
||||
<PackageReference Include="LanguageExt.Transformers" Version="4.4.0" />
|
||||
<PackageReference Include="LanguageExt.Core" Version="4.4.2" />
|
||||
<PackageReference Include="LanguageExt.Transformers" Version="4.4.2" />
|
||||
<PackageReference Include="MediatR" Version="11.1.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="7.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="7.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="7.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="7.0.0" />
|
||||
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="2.2.1" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.4.27">
|
||||
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="2.3.1" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.5.22">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
|
||||
@@ -216,7 +216,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
|
||||
var desiredState = new FrameState(
|
||||
playbackSettings.RealtimeOutput,
|
||||
false, // TODO: fallback filler needs to loop
|
||||
fillerKind == FillerKind.Fallback,
|
||||
videoFormat,
|
||||
Optional(videoStream.Profile),
|
||||
Optional(desiredPixelFormat),
|
||||
|
||||
@@ -51,7 +51,7 @@ public class FFmpegLocator : IFFmpegLocator
|
||||
? $"{executableBase}.exe"
|
||||
: executableBase;
|
||||
|
||||
string processFileName = Process.GetCurrentProcess().MainModule?.FileName ?? string.Empty;
|
||||
string processFileName = Environment.ProcessPath ?? string.Empty;
|
||||
if (!string.IsNullOrWhiteSpace(processFileName))
|
||||
{
|
||||
string localFileName = Path.Combine(Path.GetDirectoryName(processFileName) ?? string.Empty, executable);
|
||||
|
||||
@@ -39,12 +39,6 @@ public class FFmpegPlaybackSettingsCalculator
|
||||
"+igndts"
|
||||
};
|
||||
|
||||
public FFmpegPlaybackSettings ConcatSettings => new()
|
||||
{
|
||||
ThreadCount = 1,
|
||||
FormatFlags = CommonFormatFlags
|
||||
};
|
||||
|
||||
public FFmpegPlaybackSettings CalculateSettings(
|
||||
StreamingMode streamingMode,
|
||||
FFmpegProfile ffmpegProfile,
|
||||
@@ -147,9 +141,9 @@ public class FFmpegPlaybackSettingsCalculator
|
||||
|
||||
result.PixelFormat = ffmpegProfile.BitDepth switch
|
||||
{
|
||||
FFmpegProfileBitDepth.TenBit => new PixelFormatYuv420P10Le(),
|
||||
FFmpegProfileBitDepth.TenBit when ffmpegProfile.VideoFormat != FFmpegProfileVideoFormat.Mpeg2Video
|
||||
=> new PixelFormatYuv420P10Le(),
|
||||
_ => new PixelFormatYuv420P()
|
||||
// _ => new PixelFormatYuv420P10Le()
|
||||
};
|
||||
|
||||
result.AudioFormat = ffmpegProfile.AudioFormat;
|
||||
@@ -181,7 +175,8 @@ public class FFmpegPlaybackSettingsCalculator
|
||||
bool hlsRealtime) =>
|
||||
new()
|
||||
{
|
||||
HardwareAcceleration = ffmpegProfile.HardwareAcceleration,
|
||||
// HardwareAcceleration = ffmpegProfile.HardwareAcceleration,
|
||||
HardwareAcceleration = HardwareAccelerationKind.None,
|
||||
FormatFlags = CommonFormatFlags,
|
||||
VideoFormat = ffmpegProfile.VideoFormat,
|
||||
VideoBitrate = ffmpegProfile.VideoBitrate,
|
||||
|
||||
20
ErsatzTV.Core/FFmpeg/FFmpegProcess.cs
Normal file
20
ErsatzTV.Core/FFmpeg/FFmpegProcess.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace ErsatzTV.Core.FFmpeg;
|
||||
|
||||
public class FFmpegProcess : Process
|
||||
{
|
||||
public static int ProcessCount;
|
||||
|
||||
public FFmpegProcess()
|
||||
{
|
||||
Interlocked.Increment(ref ProcessCount);
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
base.Dispose(disposing);
|
||||
|
||||
Interlocked.Decrement(ref ProcessCount);
|
||||
}
|
||||
}
|
||||
@@ -233,7 +233,7 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector
|
||||
if (string.IsNullOrWhiteSpace(title))
|
||||
{
|
||||
_logger.LogDebug("No audio title has been specified; selecting stream with most channels");
|
||||
return streams.OrderByDescending(s => s.Channels).Head();
|
||||
return streams.OrderByDescending(s => s.Channels).ThenByDescending(s => s.Default).Head();
|
||||
}
|
||||
|
||||
// prioritize matching titles
|
||||
@@ -247,14 +247,14 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector
|
||||
matchingTitle.Count,
|
||||
title);
|
||||
|
||||
return matchingTitle.OrderByDescending(s => s.Channels).Head();
|
||||
return matchingTitle.OrderByDescending(s => s.Channels).ThenByDescending(s => s.Default).Head();
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"Unable to find audio stream with preferred title {Title}; selecting stream with most channels",
|
||||
title);
|
||||
|
||||
return streams.OrderByDescending(s => s.Channels).Head();
|
||||
return streams.OrderByDescending(s => s.Channels).ThenByDescending(s => s.Default).Head();
|
||||
}
|
||||
|
||||
private async Task<Option<MediaStream>> SelectEpisodeAudioStream(
|
||||
|
||||
@@ -10,5 +10,6 @@ public interface IEmbyMovieLibraryScanner
|
||||
EmbyLibrary library,
|
||||
string ffmpegPath,
|
||||
string ffprobePath,
|
||||
bool deepScan,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -10,5 +10,6 @@ public interface IEmbyTelevisionLibraryScanner
|
||||
EmbyLibrary library,
|
||||
string ffmpegPath,
|
||||
string ffprobePath,
|
||||
bool deepScan,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -10,5 +10,6 @@ public interface IJellyfinMovieLibraryScanner
|
||||
JellyfinLibrary library,
|
||||
string ffmpegPath,
|
||||
string ffprobePath,
|
||||
bool deepScan,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -10,5 +10,6 @@ public interface IJellyfinTelevisionLibraryScanner
|
||||
JellyfinLibrary library,
|
||||
string ffmpegPath,
|
||||
string ffprobePath,
|
||||
bool deepScan,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -19,5 +19,4 @@ public interface IArtistRepository
|
||||
Task<bool> AddStyle(ArtistMetadata metadata, Style style);
|
||||
Task<bool> AddMood(ArtistMetadata metadata, Mood mood);
|
||||
Task<List<MusicVideo>> GetArtistItems(int artistId);
|
||||
Task<List<Artist>> GetAllArtists();
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ public interface IMediaServerTelevisionRepository<in TLibrary, TShow, TSeason, T
|
||||
Task<List<TEtag>> GetExistingEpisodes(TLibrary library, TSeason season);
|
||||
Task<Either<BaseError, MediaItemScanResult<TShow>>> GetOrAdd(TLibrary library, TShow item);
|
||||
Task<Either<BaseError, MediaItemScanResult<TSeason>>> GetOrAdd(TLibrary library, TSeason item);
|
||||
Task<Either<BaseError, MediaItemScanResult<TEpisode>>> GetOrAdd(TLibrary library, TEpisode item);
|
||||
Task<Either<BaseError, MediaItemScanResult<TEpisode>>> GetOrAdd(TLibrary library, TEpisode item, bool deepScan);
|
||||
Task<Unit> SetEtag(TShow show, string etag);
|
||||
Task<Unit> SetEtag(TSeason season, string etag);
|
||||
Task<Unit> SetEtag(TEpisode episode, string etag);
|
||||
|
||||
@@ -30,6 +30,7 @@ public interface ITelevisionRepository
|
||||
Task<Unit> DeleteEmptySeasons(LibraryPath libraryPath);
|
||||
Task<List<int>> DeleteEmptyShows(LibraryPath libraryPath);
|
||||
Task<bool> AddGenre(ShowMetadata metadata, Genre genre);
|
||||
Task<bool> AddGenre(EpisodeMetadata metadata, Genre genre);
|
||||
Task<bool> AddTag(Domain.Metadata metadata, Tag tag);
|
||||
Task<bool> AddStudio(ShowMetadata metadata, Studio studio);
|
||||
Task<bool> AddActor(ShowMetadata metadata, Actor actor);
|
||||
|
||||
@@ -203,7 +203,7 @@ public class PlayoutBuilder : IPlayoutBuilder
|
||||
parameters.Start,
|
||||
parameters.Finish,
|
||||
parameters.CollectionMediaItems,
|
||||
playout.ProgramSchedule.RandomStartPoint);
|
||||
true);
|
||||
|
||||
return playout;
|
||||
}
|
||||
@@ -237,11 +237,7 @@ public class PlayoutBuilder : IPlayoutBuilder
|
||||
Map<CollectionKey, List<MediaItem>> collectionMediaItems = await GetCollectionMediaItems(playout);
|
||||
if (!collectionMediaItems.Any())
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Playout {Playout} schedule {Schedule} has no items",
|
||||
playout.Channel.Name,
|
||||
playout.ProgramSchedule.Name);
|
||||
|
||||
_logger.LogWarning("Playout {Playout} has no items", playout.Channel.Name);
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -365,13 +361,21 @@ public class PlayoutBuilder : IPlayoutBuilder
|
||||
bool saveAnchorDate,
|
||||
bool randomStartPoint)
|
||||
{
|
||||
var sortedScheduleItems = playout.ProgramSchedule.Items.OrderBy(i => i.Index).ToList();
|
||||
ProgramSchedule activeSchedule = PlayoutScheduleSelector.GetProgramScheduleFor(
|
||||
playout.ProgramSchedule,
|
||||
playout.ProgramScheduleAlternates,
|
||||
playoutStart);
|
||||
|
||||
// random start points are disabled in some scenarios, so ensure it's enabled and active
|
||||
randomStartPoint = randomStartPoint && activeSchedule.RandomStartPoint;
|
||||
|
||||
var sortedScheduleItems = activeSchedule.Items.OrderBy(i => i.Index).ToList();
|
||||
CollectionEnumeratorState scheduleItemsEnumeratorState =
|
||||
playout.Anchor?.ScheduleItemsEnumeratorState ?? new CollectionEnumeratorState
|
||||
{ Seed = Random.Next(), Index = 0 };
|
||||
IScheduleItemsEnumerator scheduleItemsEnumerator = playout.ProgramSchedule.ShuffleScheduleItems
|
||||
? new ShuffledScheduleItemsEnumerator(playout.ProgramSchedule.Items, scheduleItemsEnumeratorState)
|
||||
: new OrderedScheduleItemsEnumerator(playout.ProgramSchedule.Items, scheduleItemsEnumeratorState);
|
||||
IScheduleItemsEnumerator scheduleItemsEnumerator = activeSchedule.ShuffleScheduleItems
|
||||
? new ShuffledScheduleItemsEnumerator(activeSchedule.Items, scheduleItemsEnumeratorState)
|
||||
: new OrderedScheduleItemsEnumerator(activeSchedule.Items, scheduleItemsEnumeratorState);
|
||||
var collectionEnumerators = new Dictionary<CollectionKey, IMediaCollectionEnumerator>();
|
||||
foreach ((CollectionKey collectionKey, List<MediaItem> mediaItems) in collectionMediaItems)
|
||||
{
|
||||
@@ -381,7 +385,13 @@ public class PlayoutBuilder : IPlayoutBuilder
|
||||
PlaybackOrder playbackOrder = maybeScheduleItem
|
||||
.Match(item => item.PlaybackOrder, () => PlaybackOrder.Shuffle);
|
||||
IMediaCollectionEnumerator enumerator =
|
||||
await GetMediaCollectionEnumerator(playout, collectionKey, mediaItems, playbackOrder, randomStartPoint);
|
||||
await GetMediaCollectionEnumerator(
|
||||
playout,
|
||||
activeSchedule,
|
||||
collectionKey,
|
||||
mediaItems,
|
||||
playbackOrder,
|
||||
randomStartPoint);
|
||||
collectionEnumerators.Add(collectionKey, enumerator);
|
||||
}
|
||||
|
||||
@@ -533,7 +543,11 @@ public class PlayoutBuilder : IPlayoutBuilder
|
||||
}
|
||||
|
||||
// build program schedule anchors
|
||||
playout.ProgramScheduleAnchors = BuildProgramScheduleAnchors(playout, collectionEnumerators, saveAnchorDate);
|
||||
playout.ProgramScheduleAnchors = BuildProgramScheduleAnchors(
|
||||
playout,
|
||||
activeSchedule,
|
||||
collectionEnumerators,
|
||||
saveAnchorDate);
|
||||
|
||||
return playout;
|
||||
}
|
||||
@@ -541,6 +555,8 @@ public class PlayoutBuilder : IPlayoutBuilder
|
||||
private async Task<Map<CollectionKey, List<MediaItem>>> GetCollectionMediaItems(Playout playout)
|
||||
{
|
||||
var collectionKeys = playout.ProgramSchedule.Items
|
||||
.Append(playout.ProgramScheduleAlternates.Bind(psa => psa.ProgramSchedule.Items))
|
||||
.DistinctBy(i => i.Id)
|
||||
.SelectMany(CollectionKeysForItem)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
@@ -640,6 +656,7 @@ public class PlayoutBuilder : IPlayoutBuilder
|
||||
|
||||
private static List<PlayoutProgramScheduleAnchor> BuildProgramScheduleAnchors(
|
||||
Playout playout,
|
||||
ProgramSchedule activeSchedule,
|
||||
Dictionary<CollectionKey, IMediaCollectionEnumerator> collectionEnumerators,
|
||||
bool saveAnchorDate)
|
||||
{
|
||||
@@ -651,6 +668,8 @@ public class PlayoutBuilder : IPlayoutBuilder
|
||||
a => a.CollectionType == collectionKey.CollectionType
|
||||
&& a.CollectionId == collectionKey.CollectionId
|
||||
&& a.MediaItemId == collectionKey.MediaItemId
|
||||
&& a.SmartCollectionId == collectionKey.SmartCollectionId
|
||||
&& a.MultiCollectionId == collectionKey.MultiCollectionId
|
||||
&& a.AnchorDate is null);
|
||||
|
||||
var maybeEnumeratorState = collectionEnumerators.ToDictionary(e => e.Key, e => e.Value.State);
|
||||
@@ -665,8 +684,8 @@ public class PlayoutBuilder : IPlayoutBuilder
|
||||
{
|
||||
Playout = playout,
|
||||
PlayoutId = playout.Id,
|
||||
ProgramSchedule = playout.ProgramSchedule,
|
||||
ProgramScheduleId = playout.ProgramScheduleId,
|
||||
ProgramSchedule = activeSchedule,
|
||||
ProgramScheduleId = activeSchedule.Id,
|
||||
CollectionType = collectionKey.CollectionType,
|
||||
CollectionId = collectionKey.CollectionId,
|
||||
MultiCollectionId = collectionKey.MultiCollectionId,
|
||||
@@ -694,6 +713,7 @@ public class PlayoutBuilder : IPlayoutBuilder
|
||||
|
||||
private async Task<IMediaCollectionEnumerator> GetMediaCollectionEnumerator(
|
||||
Playout playout,
|
||||
ProgramSchedule activeSchedule,
|
||||
CollectionKey collectionKey,
|
||||
List<MediaItem> mediaItems,
|
||||
PlaybackOrder playbackOrder,
|
||||
@@ -702,34 +722,38 @@ public class PlayoutBuilder : IPlayoutBuilder
|
||||
Option<PlayoutProgramScheduleAnchor> maybeAnchor = playout.ProgramScheduleAnchors
|
||||
.OrderByDescending(a => a.AnchorDate ?? DateTime.MaxValue)
|
||||
.FirstOrDefault(
|
||||
a => a.ProgramScheduleId == playout.ProgramScheduleId
|
||||
a => a.ProgramScheduleId == activeSchedule.Id
|
||||
&& a.CollectionType == collectionKey.CollectionType
|
||||
&& a.CollectionId == collectionKey.CollectionId
|
||||
&& a.MultiCollectionId == collectionKey.MultiCollectionId
|
||||
&& a.SmartCollectionId == collectionKey.SmartCollectionId
|
||||
&& a.MediaItemId == collectionKey.MediaItemId);
|
||||
|
||||
// foreach (PlayoutProgramScheduleAnchor anchor in maybeAnchor)
|
||||
// {
|
||||
// _logger.LogDebug("Selecting anchor {@Anchor}", anchor);
|
||||
// }
|
||||
CollectionEnumeratorState state = null;
|
||||
|
||||
CollectionEnumeratorState state = maybeAnchor.Match(
|
||||
anchor => anchor.EnumeratorState ??
|
||||
(anchor.EnumeratorState = new CollectionEnumeratorState { Seed = Random.Next(), Index = 0 }),
|
||||
() => new CollectionEnumeratorState { Seed = Random.Next(), Index = 0 });
|
||||
|
||||
if (await _mediaCollectionRepository.IsCustomPlaybackOrder(collectionKey.CollectionId ?? 0))
|
||||
foreach (PlayoutProgramScheduleAnchor anchor in maybeAnchor)
|
||||
{
|
||||
Option<Collection> collectionWithItems =
|
||||
await _mediaCollectionRepository.GetCollectionWithCollectionItemsUntracked(
|
||||
collectionKey.CollectionId ?? 0);
|
||||
// _logger.LogDebug("Selecting anchor {@Anchor}", anchor);
|
||||
|
||||
if (collectionKey.CollectionType == ProgramScheduleItemCollectionType.Collection &&
|
||||
collectionWithItems.IsSome)
|
||||
anchor.EnumeratorState ??= new CollectionEnumeratorState { Seed = Random.Next(), Index = 0 };
|
||||
|
||||
state = anchor.EnumeratorState;
|
||||
}
|
||||
|
||||
state ??= new CollectionEnumeratorState { Seed = Random.Next(), Index = 0 };
|
||||
|
||||
int collectionId = collectionKey.CollectionId ?? 0;
|
||||
|
||||
if (collectionKey.CollectionType == ProgramScheduleItemCollectionType.Collection &&
|
||||
await _mediaCollectionRepository.IsCustomPlaybackOrder(collectionId))
|
||||
{
|
||||
Option<Collection> maybeCollectionWithItems =
|
||||
await _mediaCollectionRepository.GetCollectionWithCollectionItemsUntracked(collectionId);
|
||||
|
||||
foreach (Collection collectionWithItems in maybeCollectionWithItems)
|
||||
{
|
||||
return new CustomOrderCollectionEnumerator(
|
||||
collectionWithItems.ValueUnsafe(),
|
||||
collectionWithItems,
|
||||
mediaItems,
|
||||
state);
|
||||
}
|
||||
@@ -757,7 +781,7 @@ public class PlayoutBuilder : IPlayoutBuilder
|
||||
return new ShuffleInOrderCollectionEnumerator(
|
||||
await GetCollectionItemsForShuffleInOrder(collectionKey),
|
||||
state,
|
||||
playout.ProgramSchedule.RandomStartPoint);
|
||||
activeSchedule.RandomStartPoint);
|
||||
case PlaybackOrder.MultiEpisodeShuffle when
|
||||
collectionKey.CollectionType == ProgramScheduleItemCollectionType.TelevisionShow &&
|
||||
collectionKey.MediaItemId.HasValue:
|
||||
@@ -795,7 +819,7 @@ public class PlayoutBuilder : IPlayoutBuilder
|
||||
case PlaybackOrder.MultiEpisodeShuffle:
|
||||
case PlaybackOrder.Shuffle:
|
||||
return new ShuffledMediaCollectionEnumerator(
|
||||
await GetGroupedMediaItemsForShuffle(playout, mediaItems, collectionKey),
|
||||
await GetGroupedMediaItemsForShuffle(activeSchedule, mediaItems, collectionKey),
|
||||
state);
|
||||
default:
|
||||
// TODO: handle this error case differently?
|
||||
@@ -804,7 +828,7 @@ public class PlayoutBuilder : IPlayoutBuilder
|
||||
}
|
||||
|
||||
private async Task<List<GroupedMediaItem>> GetGroupedMediaItemsForShuffle(
|
||||
Playout playout,
|
||||
ProgramSchedule activeSchedule,
|
||||
List<MediaItem> mediaItems,
|
||||
CollectionKey collectionKey)
|
||||
{
|
||||
@@ -816,10 +840,8 @@ public class PlayoutBuilder : IPlayoutBuilder
|
||||
return MultiCollectionGrouper.GroupMediaItems(collections);
|
||||
}
|
||||
|
||||
return playout.ProgramSchedule.KeepMultiPartEpisodesTogether
|
||||
? MultiPartEpisodeGrouper.GroupMediaItems(
|
||||
mediaItems,
|
||||
playout.ProgramSchedule.TreatCollectionsAsShows)
|
||||
return activeSchedule.KeepMultiPartEpisodesTogether
|
||||
? MultiPartEpisodeGrouper.GroupMediaItems(mediaItems, activeSchedule.TreatCollectionsAsShows)
|
||||
: mediaItems.Map(mi => new GroupedMediaItem(mi, null)).ToList();
|
||||
}
|
||||
|
||||
|
||||
@@ -461,10 +461,14 @@ public abstract class PlayoutModeSchedulerBase<T> : IPlayoutModeScheduler<T> whe
|
||||
foreach (FillerPreset padFiller in Optional(
|
||||
allFiller.FirstOrDefault(f => f.FillerMode == FillerMode.Pad && f.PadToNearestMinute.HasValue)))
|
||||
{
|
||||
var totalDuration =
|
||||
TimeSpan.FromMilliseconds(
|
||||
result.Sum(pi => (pi.Finish - pi.Start).TotalMilliseconds) +
|
||||
var totalDuration = TimeSpan.FromMilliseconds(result.Sum(pi => (pi.Finish - pi.Start).TotalMilliseconds));
|
||||
|
||||
// add primary content to totalDuration only if it hasn't already been added
|
||||
if (result.All(pi => pi.MediaItemId != playoutItem.MediaItemId))
|
||||
{
|
||||
totalDuration += TimeSpan.FromMilliseconds(
|
||||
effectiveChapters.Sum(c => (c.EndTime - c.StartTime).TotalMilliseconds));
|
||||
}
|
||||
|
||||
int currentMinute = (playoutItem.StartOffset + totalDuration).Minute;
|
||||
// ReSharper disable once PossibleInvalidOperationException
|
||||
@@ -533,6 +537,11 @@ public abstract class PlayoutModeSchedulerBase<T> : IPlayoutModeScheduler<T> whe
|
||||
? remainingToFill
|
||||
: remainingToFill / (effectiveChapters.Count - 1);
|
||||
TimeSpan filled = TimeSpan.Zero;
|
||||
|
||||
// remove post-roll to add after mid-roll/content
|
||||
var postRoll = result.Where(i => i.FillerKind == FillerKind.PostRoll).ToList();
|
||||
result.RemoveAll(i => i.FillerKind == FillerKind.PostRoll);
|
||||
|
||||
for (var i = 0; i < effectiveChapters.Count; i++)
|
||||
{
|
||||
result.Add(playoutItem.ForChapter(effectiveChapters[i]));
|
||||
@@ -573,6 +582,8 @@ public abstract class PlayoutModeSchedulerBase<T> : IPlayoutModeScheduler<T> whe
|
||||
}
|
||||
}
|
||||
|
||||
result.AddRange(postRoll);
|
||||
|
||||
break;
|
||||
case FillerKind.PostRoll:
|
||||
IMediaCollectionEnumerator post1 = enumerators[CollectionKey.ForFillerPreset(padFiller)];
|
||||
|
||||
@@ -91,15 +91,18 @@ public class PlayoutModeSchedulerFlood : PlayoutModeSchedulerBase<ProgramSchedul
|
||||
AddFiller(nextState, collectionEnumerators, scheduleItem, playoutItem, itemChapters));
|
||||
// LogScheduledItem(scheduleItem, mediaItem, itemStartTime);
|
||||
|
||||
DateTimeOffset actualEndTime = playoutItems.Max(p => p.FinishOffset);
|
||||
if (Math.Abs((itemEndTimeWithFiller - actualEndTime).TotalSeconds) > 1)
|
||||
if (playoutItems.Count > 0)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Filler prediction failure: predicted {PredictedDuration} doesn't match actual {ActualDuration}",
|
||||
itemEndTimeWithFiller,
|
||||
actualEndTime);
|
||||
DateTimeOffset actualEndTime = playoutItems.Max(p => p.FinishOffset);
|
||||
if (Math.Abs((itemEndTimeWithFiller - actualEndTime).TotalSeconds) > 1)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Filler prediction failure: predicted {PredictedDuration} doesn't match actual {ActualDuration}",
|
||||
itemEndTimeWithFiller,
|
||||
actualEndTime);
|
||||
|
||||
// _logger.LogWarning("Playout items: {@PlayoutItems}", playoutItems);
|
||||
// _logger.LogWarning("Playout items: {@PlayoutItems}", playoutItems);
|
||||
}
|
||||
}
|
||||
|
||||
nextState = nextState with
|
||||
|
||||
40
ErsatzTV.Core/Scheduling/PlayoutScheduleSelector.cs
Normal file
40
ErsatzTV.Core/Scheduling/PlayoutScheduleSelector.cs
Normal file
@@ -0,0 +1,40 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Core.Scheduling;
|
||||
|
||||
public static class PlayoutScheduleSelector
|
||||
{
|
||||
public static ProgramSchedule GetProgramScheduleFor(
|
||||
ProgramSchedule defaultSchedule,
|
||||
IEnumerable<ProgramScheduleAlternate> alternates,
|
||||
DateTimeOffset date)
|
||||
{
|
||||
foreach (ProgramScheduleAlternate alternate in alternates.OrderBy(x => x.Index))
|
||||
{
|
||||
bool daysOfWeek = alternate.DaysOfWeek.Count is 0 or 7 ||
|
||||
alternate.DaysOfWeek.Contains(date.DayOfWeek);
|
||||
|
||||
if (!daysOfWeek)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
bool daysOfMonth = alternate.DaysOfMonth.Count is 0 or 31 ||
|
||||
alternate.DaysOfMonth.Contains(date.Day);
|
||||
if (!daysOfMonth)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
bool monthOfYear = alternate.MonthsOfYear.Count is 0 or 12 ||
|
||||
alternate.MonthsOfYear.Contains(date.Month);
|
||||
|
||||
if (monthOfYear)
|
||||
{
|
||||
return alternate.ProgramSchedule;
|
||||
}
|
||||
}
|
||||
|
||||
return defaultSchedule;
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="6.8.0" />
|
||||
<PackageReference Include="FluentAssertions" Version="6.10.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="7.0.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.4.1" />
|
||||
<PackageReference Include="Moq" Version="4.18.4" />
|
||||
|
||||
@@ -4,7 +4,18 @@ namespace ErsatzTV.FFmpeg.Capabilities;
|
||||
|
||||
public class DefaultHardwareCapabilities : IHardwareCapabilities
|
||||
{
|
||||
public bool CanDecode(string videoFormat, Option<string> videoProfile, Option<IPixelFormat> maybePixelFormat) => true;
|
||||
public bool CanDecode(string videoFormat, Option<string> videoProfile, Option<IPixelFormat> maybePixelFormat)
|
||||
{
|
||||
int bitDepth = maybePixelFormat.Map(pf => pf.BitDepth).IfNone(8);
|
||||
|
||||
return (videoFormat, bitDepth) switch
|
||||
{
|
||||
// 10-bit h264 decoding is likely not support by any hardware
|
||||
(VideoFormat.H264, 10) => false,
|
||||
|
||||
_ => true
|
||||
};
|
||||
}
|
||||
|
||||
public bool CanEncode(string videoFormat, Option<string> videoProfile, Option<IPixelFormat> maybePixelFormat)
|
||||
{
|
||||
|
||||
@@ -107,6 +107,10 @@ public class VaapiHardwareCapabilities : IHardwareCapabilities
|
||||
_profileEntrypoints.Contains(
|
||||
new VaapiProfileEntrypoint(VaapiProfile.HevcMain, VaapiEntrypoint.Encode)),
|
||||
|
||||
VideoFormat.Mpeg2Video =>
|
||||
_profileEntrypoints.Contains(
|
||||
new VaapiProfileEntrypoint(VaapiProfile.Mpeg2Main, VaapiEntrypoint.Encode)),
|
||||
|
||||
_ => false
|
||||
};
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ namespace ErsatzTV.FFmpeg;
|
||||
public record ColorParams(string ColorRange, string ColorSpace, string ColorTransfer, string ColorPrimaries)
|
||||
{
|
||||
public static readonly ColorParams Default = new("tv", "bt709", "bt709", "bt709");
|
||||
public static readonly ColorParams Unknown = new("tv", string.Empty, string.Empty, string.Empty);
|
||||
|
||||
public bool IsHdr => ColorTransfer is "arib-std-b67" or "smpte2084";
|
||||
|
||||
|
||||
@@ -16,7 +16,12 @@ public class DecoderVaapi : DecoderBase
|
||||
FrameState nextState = base.NextState(currentState);
|
||||
|
||||
return currentState.PixelFormat.Match(
|
||||
pixelFormat => nextState with { PixelFormat = new PixelFormatNv12(pixelFormat.Name) },
|
||||
pixelFormat =>
|
||||
{
|
||||
return pixelFormat.BitDepth == 8
|
||||
? nextState with { PixelFormat = new PixelFormatNv12(pixelFormat.Name) }
|
||||
: nextState with { PixelFormat = new PixelFormatVaapi(pixelFormat.Name) };
|
||||
},
|
||||
() => nextState);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,27 @@
|
||||
namespace ErsatzTV.FFmpeg.Decoder.Qsv;
|
||||
using ErsatzTV.FFmpeg.Format;
|
||||
|
||||
namespace ErsatzTV.FFmpeg.Decoder.Qsv;
|
||||
|
||||
public class DecoderHevcQsv : DecoderBase
|
||||
{
|
||||
public override string Name => "hevc_qsv";
|
||||
|
||||
protected override FrameDataLocation OutputFrameDataLocation => FrameDataLocation.Hardware;
|
||||
|
||||
public override FrameState NextState(FrameState currentState)
|
||||
{
|
||||
FrameState nextState = base.NextState(currentState);
|
||||
|
||||
return currentState.PixelFormat.Match(
|
||||
pixelFormat =>
|
||||
{
|
||||
if (pixelFormat.BitDepth == 10)
|
||||
{
|
||||
return nextState with { PixelFormat = new PixelFormatP010() };
|
||||
}
|
||||
|
||||
return nextState with { PixelFormat = new PixelFormatNv12(pixelFormat.Name) };
|
||||
},
|
||||
() => nextState);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,27 @@
|
||||
namespace ErsatzTV.FFmpeg.Decoder.Qsv;
|
||||
using ErsatzTV.FFmpeg.Format;
|
||||
|
||||
namespace ErsatzTV.FFmpeg.Decoder.Qsv;
|
||||
|
||||
public class DecoderVp9Qsv : DecoderBase
|
||||
{
|
||||
public override string Name => "vp9_qsv";
|
||||
|
||||
protected override FrameDataLocation OutputFrameDataLocation => FrameDataLocation.Hardware;
|
||||
|
||||
public override FrameState NextState(FrameState currentState)
|
||||
{
|
||||
FrameState nextState = base.NextState(currentState);
|
||||
|
||||
return currentState.PixelFormat.Match(
|
||||
pixelFormat =>
|
||||
{
|
||||
if (pixelFormat.BitDepth == 10)
|
||||
{
|
||||
return nextState with { PixelFormat = new PixelFormatP010() };
|
||||
}
|
||||
|
||||
return nextState with { PixelFormat = new PixelFormatNv12(pixelFormat.Name) };
|
||||
},
|
||||
() => nextState);
|
||||
}
|
||||
}
|
||||
|
||||
18
ErsatzTV.FFmpeg/Encoder/Qsv/EncoderMpeg2Qsv.cs
Normal file
18
ErsatzTV.FFmpeg/Encoder/Qsv/EncoderMpeg2Qsv.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
using ErsatzTV.FFmpeg.Format;
|
||||
|
||||
namespace ErsatzTV.FFmpeg.Encoder.Qsv;
|
||||
|
||||
public class EncoderMpeg2Qsv : EncoderBase
|
||||
{
|
||||
public override string Name => "mpeg2_qsv";
|
||||
public override StreamKind Kind => StreamKind.Video;
|
||||
|
||||
public override IList<string> OutputOptions =>
|
||||
new[] { "-c:v", "mpeg2_qsv", "-low_power", "0", "-look_ahead", "0" };
|
||||
|
||||
public override FrameState NextState(FrameState currentState) => currentState with
|
||||
{
|
||||
VideoFormat = VideoFormat.Mpeg2Video,
|
||||
FrameDataLocation = FrameDataLocation.Hardware
|
||||
};
|
||||
}
|
||||
14
ErsatzTV.FFmpeg/Encoder/Vaapi/EncoderMpeg2Vaapi.cs
Normal file
14
ErsatzTV.FFmpeg/Encoder/Vaapi/EncoderMpeg2Vaapi.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
using ErsatzTV.FFmpeg.Format;
|
||||
|
||||
namespace ErsatzTV.FFmpeg.Encoder.Vaapi;
|
||||
|
||||
public class EncoderMpeg2Vaapi : EncoderBase
|
||||
{
|
||||
public override string Name => "mpeg2_vaapi";
|
||||
public override StreamKind Kind => StreamKind.Video;
|
||||
public override FrameState NextState(FrameState currentState) => currentState with
|
||||
{
|
||||
VideoFormat = VideoFormat.Mpeg2Video
|
||||
// don't change the frame data location
|
||||
};
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CliWrap" Version="3.6.0" />
|
||||
<PackageReference Include="LanguageExt.Core" Version="4.4.0" />
|
||||
<PackageReference Include="LanguageExt.Core" Version="4.4.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="7.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="7.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -65,9 +65,12 @@ public class ColorspaceFilter : BaseFilter
|
||||
string primaries = string.IsNullOrWhiteSpace(cp.ColorPrimaries)
|
||||
? "bt709"
|
||||
: cp.ColorPrimaries;
|
||||
string space = string.IsNullOrWhiteSpace(cp.ColorSpace)
|
||||
? "bt709"
|
||||
: cp.ColorSpace;
|
||||
|
||||
inputOverrides =
|
||||
$"irange={range}:ispace={cp.ColorSpace}:itrc={transfer}:iprimaries={primaries}:";
|
||||
$"irange={range}:ispace={space}:itrc={transfer}:iprimaries={primaries}:";
|
||||
}
|
||||
|
||||
string colorspace = _desiredPixelFormat.BitDepth switch
|
||||
|
||||
8
ErsatzTV.FFmpeg/Format/PixelFormatP010.cs
Normal file
8
ErsatzTV.FFmpeg/Format/PixelFormatP010.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace ErsatzTV.FFmpeg.Format;
|
||||
|
||||
public class PixelFormatP010 : IPixelFormat
|
||||
{
|
||||
public string Name => "p010le";
|
||||
public string FFmpegName => "p010";
|
||||
public int BitDepth => 10;
|
||||
}
|
||||
12
ErsatzTV.FFmpeg/Format/PixelFormatVaapi.cs
Normal file
12
ErsatzTV.FFmpeg/Format/PixelFormatVaapi.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace ErsatzTV.FFmpeg.Format;
|
||||
|
||||
public class PixelFormatVaapi : IPixelFormat
|
||||
{
|
||||
public PixelFormatVaapi(string name) => Name = name;
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
public string FFmpegName => "vaapi";
|
||||
|
||||
public int BitDepth => 8;
|
||||
}
|
||||
@@ -266,6 +266,13 @@ public class NvidiaPipelineBuilder : SoftwarePipelineBuilder
|
||||
}
|
||||
}
|
||||
|
||||
// vp9 seems to lose color metadata through the ffmpeg pipeline
|
||||
// clearing color params will force it to be re-added
|
||||
if (videoStream.Codec == "vp9")
|
||||
{
|
||||
videoStream = videoStream with { ColorParams = ColorParams.Unknown };
|
||||
}
|
||||
|
||||
if (!videoStream.ColorParams.IsBt709)
|
||||
{
|
||||
_logger.LogDebug("Adding colorspace filter");
|
||||
|
||||
@@ -121,9 +121,9 @@ public class QsvPipelineBuilder : SoftwarePipelineBuilder
|
||||
ScaledSize = videoStream.FrameSize,
|
||||
PaddedSize = videoStream.FrameSize,
|
||||
|
||||
// consider hardware frames to be wrapped in nv12
|
||||
// consider 8-bit hardware frames to be wrapped in nv12
|
||||
PixelFormat = ffmpegState.DecoderHardwareAccelerationMode == HardwareAccelerationMode.Qsv
|
||||
? videoStream.PixelFormat.Map(pf => (IPixelFormat)new PixelFormatNv12(pf.Name))
|
||||
? videoStream.PixelFormat.Map(pf => pf.BitDepth == 8 ? new PixelFormatNv12(pf.Name) : pf)
|
||||
: videoStream.PixelFormat,
|
||||
|
||||
IsAnamorphic = videoStream.IsAnamorphic,
|
||||
@@ -137,7 +137,10 @@ public class QsvPipelineBuilder : SoftwarePipelineBuilder
|
||||
{
|
||||
IPixelFormat pixelFormat = desiredState.PixelFormat.IfNone(
|
||||
context.Is10BitOutput ? new PixelFormatYuv420P10Le() : new PixelFormatYuv420P());
|
||||
desiredState = desiredState with { PixelFormat = new PixelFormatNv12(pixelFormat.Name) };
|
||||
desiredState = desiredState with
|
||||
{
|
||||
PixelFormat = Some(context.Is10BitOutput ? pixelFormat : new PixelFormatNv12(pixelFormat.Name))
|
||||
};
|
||||
}
|
||||
|
||||
// _logger.LogDebug("After decode: {PixelFormat}", currentState.PixelFormat);
|
||||
@@ -184,6 +187,7 @@ public class QsvPipelineBuilder : SoftwarePipelineBuilder
|
||||
{
|
||||
(HardwareAccelerationMode.Qsv, VideoFormat.Hevc) => new EncoderHevcQsv(),
|
||||
(HardwareAccelerationMode.Qsv, VideoFormat.H264) => new EncoderH264Qsv(),
|
||||
(HardwareAccelerationMode.Qsv, VideoFormat.Mpeg2Video) => new EncoderMpeg2Qsv(),
|
||||
|
||||
(_, _) => GetSoftwareEncoder(currentState, desiredState)
|
||||
};
|
||||
@@ -238,11 +242,78 @@ public class QsvPipelineBuilder : SoftwarePipelineBuilder
|
||||
|
||||
IPixelFormat formatForDownload = pixelFormat;
|
||||
|
||||
bool usesVppQsv = videoInputFile.FilterSteps.Any(f => f is QsvFormatFilter or ScaleQsvFilter);
|
||||
bool usesVppQsv =
|
||||
videoInputFile.FilterSteps.Any(f => f is QsvFormatFilter or ScaleQsvFilter or DeinterlaceQsvFilter);
|
||||
|
||||
// if we have no filters, check whether we need to convert pixel format
|
||||
// since qsv doesn't seem to like doing that at the encoder
|
||||
if (!videoInputFile.FilterSteps.Any(f => f is not IEncoder))
|
||||
{
|
||||
foreach (IPixelFormat currentPixelFormat in currentState.PixelFormat)
|
||||
{
|
||||
bool requiresConversion = false;
|
||||
|
||||
if (currentPixelFormat is PixelFormatNv12 nv)
|
||||
{
|
||||
foreach (IPixelFormat pf in AvailablePixelFormats.ForPixelFormat(nv.Name, null))
|
||||
{
|
||||
requiresConversion = pf.FFmpegName != format.FFmpegName;
|
||||
|
||||
if (!requiresConversion)
|
||||
{
|
||||
currentState = currentState with { PixelFormat = Some(pf) };
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
requiresConversion = currentPixelFormat.FFmpegName != format.FFmpegName;
|
||||
}
|
||||
|
||||
if (requiresConversion)
|
||||
{
|
||||
if (currentState.FrameDataLocation == FrameDataLocation.Hardware)
|
||||
{
|
||||
var filter = new QsvFormatFilter(currentPixelFormat);
|
||||
result.Add(filter);
|
||||
currentState = filter.NextState(currentState);
|
||||
|
||||
// if we need to convert 8-bit to 10-bit, do it here
|
||||
if (currentPixelFormat.BitDepth == 8 && context.Is10BitOutput)
|
||||
{
|
||||
var p010Filter = new QsvFormatFilter(new PixelFormatP010());
|
||||
result.Add(p010Filter);
|
||||
currentState = p010Filter.NextState(currentState);
|
||||
}
|
||||
|
||||
usesVppQsv = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!videoStream.ColorParams.IsBt709 || usesVppQsv)
|
||||
{
|
||||
_logger.LogDebug("Adding colorspace filter");
|
||||
|
||||
// force p010/nv12 if we're still in hardware
|
||||
if (currentState.FrameDataLocation == FrameDataLocation.Hardware)
|
||||
{
|
||||
foreach (int bitDepth in currentState.PixelFormat.Map(pf => pf.BitDepth))
|
||||
{
|
||||
if (bitDepth is 10 && formatForDownload is not PixelFormatYuv420P10Le)
|
||||
{
|
||||
formatForDownload = new PixelFormatYuv420P10Le();
|
||||
currentState = currentState with { PixelFormat = Some(formatForDownload) };
|
||||
}
|
||||
else if (bitDepth is 8 && formatForDownload is not PixelFormatNv12)
|
||||
{
|
||||
formatForDownload = new PixelFormatNv12(formatForDownload.Name);
|
||||
currentState = currentState with { PixelFormat = Some(formatForDownload) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// vpp_qsv seems to strip color info, so if we use that at all, force overriding input color info
|
||||
var colorspace = new ColorspaceFilter(
|
||||
currentState,
|
||||
@@ -250,15 +321,6 @@ public class QsvPipelineBuilder : SoftwarePipelineBuilder
|
||||
format,
|
||||
forceInputOverrides: usesVppQsv);
|
||||
|
||||
// force nv12 if we're still in hardware
|
||||
if (currentState.FrameDataLocation == FrameDataLocation.Hardware)
|
||||
{
|
||||
if (formatForDownload is not PixelFormatNv12)
|
||||
{
|
||||
formatForDownload = new PixelFormatNv12(pixelFormat.Name);
|
||||
}
|
||||
}
|
||||
|
||||
currentState = colorspace.NextState(currentState);
|
||||
result.Add(colorspace);
|
||||
}
|
||||
@@ -295,13 +357,6 @@ public class QsvPipelineBuilder : SoftwarePipelineBuilder
|
||||
}
|
||||
}
|
||||
|
||||
// qsv encoders don't like yuv420p
|
||||
format = format switch
|
||||
{
|
||||
PixelFormatYuv420P => new PixelFormatNv12(PixelFormat.YUV420P),
|
||||
_ => format
|
||||
};
|
||||
|
||||
pipelineSteps.Add(new PixelFormatOutputOption(format));
|
||||
}
|
||||
}
|
||||
@@ -375,6 +430,12 @@ public class QsvPipelineBuilder : SoftwarePipelineBuilder
|
||||
pf,
|
||||
_logger);
|
||||
watermarkOverlayFilterSteps.Add(watermarkFilter);
|
||||
|
||||
// overlay filter with 10-bit vp9 seems to output alpha channel, so remove it with a pixel format change
|
||||
if (videoStream.Codec == "vp9" && desiredPixelFormat.BitDepth == 10)
|
||||
{
|
||||
watermarkOverlayFilterSteps.Add(new PixelFormatFilter(new PixelFormatYuv420P10Le()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -397,20 +458,9 @@ public class QsvPipelineBuilder : SoftwarePipelineBuilder
|
||||
{
|
||||
videoInputFile.AddOption(new CopyTimestampInputOption());
|
||||
|
||||
// if (videoInputFile.FilterSteps.Count == 0 && videoInputFile.InputOptions.OfType<CuvidDecoder>().Any())
|
||||
// {
|
||||
// // change the hw accel output to software so the explicit download isn't needed
|
||||
// foreach (CuvidDecoder decoder in videoInputFile.InputOptions.OfType<CuvidDecoder>())
|
||||
// {
|
||||
// decoder.HardwareAccelerationMode = HardwareAccelerationMode.None;
|
||||
// }
|
||||
// }
|
||||
// else
|
||||
// {
|
||||
var downloadFilter = new HardwareDownloadFilter(currentState);
|
||||
currentState = downloadFilter.NextState(currentState);
|
||||
videoInputFile.FilterSteps.Add(downloadFilter);
|
||||
// }
|
||||
var downloadFilter = new HardwareDownloadFilter(currentState);
|
||||
currentState = downloadFilter.NextState(currentState);
|
||||
videoInputFile.FilterSteps.Add(downloadFilter);
|
||||
|
||||
var subtitlesFilter = new SubtitlesFilter(fontsFolder, subtitle);
|
||||
currentState = subtitlesFilter.NextState(currentState);
|
||||
@@ -436,6 +486,12 @@ public class QsvPipelineBuilder : SoftwarePipelineBuilder
|
||||
|
||||
var subtitlesFilter = new OverlaySubtitleFilter(pf);
|
||||
subtitleOverlayFilterSteps.Add(subtitlesFilter);
|
||||
|
||||
// overlay filter with 10-bit vp9 seems to output alpha channel, so remove it with a pixel format change
|
||||
if (videoInputFile.VideoStreams.Any(vs => vs.Codec == "vp9") && context.Is10BitOutput)
|
||||
{
|
||||
subtitleOverlayFilterSteps.Add(new PixelFormatFilter(new PixelFormatYuv420P10Le()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -488,11 +544,7 @@ public class QsvPipelineBuilder : SoftwarePipelineBuilder
|
||||
scaleStep = new ScaleQsvFilter(
|
||||
currentState with
|
||||
{
|
||||
PixelFormat = //context.HasWatermark ||
|
||||
//context.HasSubtitleOverlay ||
|
||||
// (desiredState.ScaledSize != desiredState.PaddedSize) ||
|
||||
// context.HasSubtitleText ||
|
||||
ffmpegState is
|
||||
PixelFormat = ffmpegState is
|
||||
{
|
||||
DecoderHardwareAccelerationMode: HardwareAccelerationMode.Nvenc,
|
||||
EncoderHardwareAccelerationMode: HardwareAccelerationMode.None
|
||||
|
||||
@@ -197,6 +197,7 @@ public class VaapiPipelineBuilder : SoftwarePipelineBuilder
|
||||
{
|
||||
(HardwareAccelerationMode.Vaapi, VideoFormat.Hevc) => new EncoderHevcVaapi(),
|
||||
(HardwareAccelerationMode.Vaapi, VideoFormat.H264) => new EncoderH264Vaapi(),
|
||||
(HardwareAccelerationMode.Vaapi, VideoFormat.Mpeg2Video) => new EncoderMpeg2Vaapi(),
|
||||
|
||||
(_, _) => GetSoftwareEncoder(currentState, desiredState)
|
||||
};
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="6.8.0" />
|
||||
<PackageReference Include="FluentAssertions" Version="6.10.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.4.1" />
|
||||
<PackageReference Include="Moq" Version="4.18.4" />
|
||||
<PackageReference Include="NUnit" Version="3.13.3" />
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
using Microsoft.EntityFrameworkCore.ChangeTracking;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Data.Configurations;
|
||||
|
||||
public class CollectionValueComparer<T> : ValueComparer<ICollection<T>>
|
||||
{
|
||||
public CollectionValueComparer() : base(
|
||||
(c1, c2) => c1.SequenceEqual(c2),
|
||||
c => c.Aggregate(0, (a, v) => HashCode.Combine(a, v.GetHashCode())),
|
||||
c => c.ToHashSet())
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Data.Configurations;
|
||||
|
||||
public class EnumCollectionJsonValueConverter<T> : ValueConverter<ICollection<T>, string> where T : Enum
|
||||
{
|
||||
public EnumCollectionJsonValueConverter() : base(
|
||||
v => JsonConvert.SerializeObject(v.Select(e => e.ToString()).ToList()),
|
||||
v => JsonConvert.DeserializeObject<ICollection<string>>(v)
|
||||
.Select(e => (T)Enum.Parse(typeof(T), e)).ToList())
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,26 @@ public class ArtistMetadataConfiguration : IEntityTypeConfiguration<ArtistMetada
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(sm => sm.Tags)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(sm => sm.Studios)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(sm => sm.Actors)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(mm => mm.Guids)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(mm => mm.Subtitles)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(sm => sm.Styles)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
@@ -10,19 +10,23 @@ public class EpisodeMetadataConfiguration : IEntityTypeConfiguration<EpisodeMeta
|
||||
{
|
||||
builder.ToTable("EpisodeMetadata");
|
||||
|
||||
builder.HasMany(em => em.Artwork)
|
||||
builder.HasMany(sm => sm.Artwork)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(em => em.Actors)
|
||||
builder.HasMany(sm => sm.Genres)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(mm => mm.Directors)
|
||||
builder.HasMany(sm => sm.Tags)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(mm => mm.Writers)
|
||||
builder.HasMany(sm => sm.Studios)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(sm => sm.Actors)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
@@ -30,7 +34,15 @@ public class EpisodeMetadataConfiguration : IEntityTypeConfiguration<EpisodeMeta
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(em => em.Subtitles)
|
||||
builder.HasMany(mm => mm.Subtitles)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(mm => mm.Directors)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(mm => mm.Writers)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
}
|
||||
|
||||
@@ -10,31 +10,23 @@ public class MovieMetadataConfiguration : IEntityTypeConfiguration<MovieMetadata
|
||||
{
|
||||
builder.ToTable("MovieMetadata");
|
||||
|
||||
builder.HasMany(mm => mm.Artwork)
|
||||
builder.HasMany(sm => sm.Artwork)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(mm => mm.Genres)
|
||||
builder.HasMany(sm => sm.Genres)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(mm => mm.Tags)
|
||||
builder.HasMany(sm => sm.Tags)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(mm => mm.Studios)
|
||||
builder.HasMany(sm => sm.Studios)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(mm => mm.Actors)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(mm => mm.Directors)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(mm => mm.Writers)
|
||||
builder.HasMany(sm => sm.Actors)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
@@ -45,5 +37,13 @@ public class MovieMetadataConfiguration : IEntityTypeConfiguration<MovieMetadata
|
||||
builder.HasMany(mm => mm.Subtitles)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(mm => mm.Directors)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(mm => mm.Writers)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,23 +10,31 @@ public class MusicVideoMetadataConfiguration : IEntityTypeConfiguration<MusicVid
|
||||
{
|
||||
builder.ToTable("MusicVideoMetadata");
|
||||
|
||||
builder.HasMany(mm => mm.Artwork)
|
||||
builder.HasMany(sm => sm.Artwork)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(mm => mm.Genres)
|
||||
builder.HasMany(sm => sm.Genres)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(mm => mm.Tags)
|
||||
builder.HasMany(sm => sm.Tags)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(mm => mm.Studios)
|
||||
builder.HasMany(sm => sm.Studios)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(mvm => mvm.Subtitles)
|
||||
builder.HasMany(sm => sm.Actors)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(mm => mm.Guids)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(mm => mm.Subtitles)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
|
||||
@@ -10,26 +10,34 @@ public class OtherVideoMetadataConfiguration : IEntityTypeConfiguration<OtherVid
|
||||
{
|
||||
builder.ToTable("OtherVideoMetadata");
|
||||
|
||||
builder.HasMany(ovm => ovm.Artwork)
|
||||
builder.HasMany(sm => sm.Artwork)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(ovm => ovm.Genres)
|
||||
builder.HasMany(sm => sm.Genres)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(ovm => ovm.Tags)
|
||||
builder.HasMany(sm => sm.Tags)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(ovm => ovm.Studios)
|
||||
builder.HasMany(sm => sm.Studios)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(ovm => ovm.Actors)
|
||||
builder.HasMany(sm => sm.Actors)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(mm => mm.Guids)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(mm => mm.Subtitles)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(ovm => ovm.Directors)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
@@ -37,13 +45,5 @@ public class OtherVideoMetadataConfiguration : IEntityTypeConfiguration<OtherVid
|
||||
builder.HasMany(ovm => ovm.Writers)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(ovm => ovm.Guids)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(ovm => ovm.Subtitles)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,12 +10,32 @@ public class SeasonMetadataConfiguration : IEntityTypeConfiguration<SeasonMetada
|
||||
{
|
||||
builder.ToTable("SeasonMetadata");
|
||||
|
||||
builder.HasMany(em => em.Artwork)
|
||||
builder.HasMany(sm => sm.Artwork)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(sm => sm.Genres)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(sm => sm.Tags)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(sm => sm.Studios)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(sm => sm.Actors)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(mm => mm.Guids)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(mm => mm.Subtitles)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,5 +33,9 @@ public class ShowMetadataConfiguration : IEntityTypeConfiguration<ShowMetadata>
|
||||
builder.HasMany(mm => mm.Guids)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(mm => mm.Subtitles)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,19 +10,31 @@ public class SongMetadataConfiguration : IEntityTypeConfiguration<SongMetadata>
|
||||
{
|
||||
builder.ToTable("SongMetadata");
|
||||
|
||||
builder.HasMany(mm => mm.Artwork)
|
||||
builder.HasMany(sm => sm.Artwork)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(mm => mm.Genres)
|
||||
builder.HasMany(sm => sm.Genres)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(mm => mm.Tags)
|
||||
builder.HasMany(sm => sm.Tags)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(mm => mm.Studios)
|
||||
builder.HasMany(sm => sm.Studios)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(sm => sm.Actors)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(mm => mm.Guids)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(mm => mm.Subtitles)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,11 @@ public class PlayoutConfiguration : IEntityTypeConfiguration<Playout>
|
||||
{
|
||||
builder.ToTable("Playout");
|
||||
|
||||
builder.HasMany(p => p.ProgramScheduleAlternates)
|
||||
.WithOne(a => a.Playout)
|
||||
.HasForeignKey(a => a.PlayoutId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(p => p.Items)
|
||||
.WithOne(pi => pi.Playout)
|
||||
.HasForeignKey(pi => pi.PlayoutId)
|
||||
|
||||
@@ -24,6 +24,12 @@ public class PlayoutProgramScheduleAnchorConfiguration : IEntityTypeConfiguratio
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired(false);
|
||||
|
||||
builder.HasOne(i => i.SmartCollection)
|
||||
.WithMany()
|
||||
.HasForeignKey(i => i.SmartCollectionId)
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired(false);
|
||||
|
||||
builder.HasOne(i => i.MediaItem)
|
||||
.WithMany()
|
||||
.HasForeignKey(i => i.MediaItemId)
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Data.Configurations;
|
||||
|
||||
public class ProgramScheduleAlternateConfiguration : IEntityTypeConfiguration<ProgramScheduleAlternate>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<ProgramScheduleAlternate> builder)
|
||||
{
|
||||
builder.ToTable("ProgramScheduleAlternate");
|
||||
|
||||
var intCollectionValueConverter = new ValueConverter<ICollection<int>, string>(
|
||||
i => string.Join(",", i),
|
||||
s => string.IsNullOrWhiteSpace(s)
|
||||
? Array.Empty<int>()
|
||||
: s.Split(new[] { ',' }).Select(int.Parse).ToArray());
|
||||
|
||||
var intCollectionValueComparer = new CollectionValueComparer<int>();
|
||||
|
||||
builder.Property(t => t.DaysOfMonth)
|
||||
.HasConversion(intCollectionValueConverter)
|
||||
.Metadata.SetValueComparer(intCollectionValueComparer);
|
||||
|
||||
builder.Property(t => t.MonthsOfYear)
|
||||
.HasConversion(intCollectionValueConverter)
|
||||
.Metadata.SetValueComparer(intCollectionValueComparer);
|
||||
|
||||
builder.Property(t => t.DaysOfWeek)
|
||||
.HasConversion(new EnumCollectionJsonValueConverter<DayOfWeek>())
|
||||
.Metadata.SetValueComparer(new CollectionValueComparer<DayOfWeek>());
|
||||
}
|
||||
}
|
||||
@@ -22,5 +22,10 @@ public class ProgramScheduleConfiguration : IEntityTypeConfiguration<ProgramSche
|
||||
.WithOne(p => p.ProgramSchedule)
|
||||
.HasForeignKey(p => p.ProgramScheduleId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(p => p.ProgramScheduleAlternates)
|
||||
.WithOne(a => a.ProgramSchedule)
|
||||
.HasForeignKey(a => a.ProgramScheduleId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,14 +160,4 @@ public class ArtistRepository : IArtistRepository
|
||||
.Filter(mv => mv.ArtistId == artistId)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<List<Artist>> GetAllArtists()
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
return await dbContext.Artists
|
||||
.AsNoTracking()
|
||||
.Include(a => a.ArtistMetadata)
|
||||
.ThenInclude(am => am.Artwork)
|
||||
.ToListAsync();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,7 +124,8 @@ public class EmbyTelevisionRepository : IEmbyTelevisionRepository
|
||||
|
||||
public async Task<Either<BaseError, MediaItemScanResult<EmbyEpisode>>> GetOrAdd(
|
||||
EmbyLibrary library,
|
||||
EmbyEpisode item)
|
||||
EmbyEpisode item,
|
||||
bool deepScan)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
Option<EmbyEpisode> maybeExisting = await dbContext.EmbyEpisodes
|
||||
@@ -158,7 +159,7 @@ public class EmbyTelevisionRepository : IEmbyTelevisionRepository
|
||||
foreach (EmbyEpisode embyEpisode in maybeExisting)
|
||||
{
|
||||
var result = new MediaItemScanResult<EmbyEpisode>(embyEpisode) { IsAdded = false };
|
||||
if (embyEpisode.Etag != item.Etag)
|
||||
if (embyEpisode.Etag != item.Etag || deepScan)
|
||||
{
|
||||
await UpdateEpisode(dbContext, embyEpisode, item);
|
||||
result.IsUpdated = true;
|
||||
@@ -657,6 +658,36 @@ public class EmbyTelevisionRepository : IEmbyTelevisionRepository
|
||||
metadata.Guids.Add(guid);
|
||||
}
|
||||
|
||||
// genres
|
||||
foreach (Genre genre in metadata.Genres
|
||||
.Filter(g => incomingMetadata.Genres.All(g2 => g2.Name != g.Name))
|
||||
.ToList())
|
||||
{
|
||||
metadata.Genres.Remove(genre);
|
||||
}
|
||||
|
||||
foreach (Genre genre in incomingMetadata.Genres
|
||||
.Filter(g => metadata.Genres.All(g2 => g2.Name != g.Name))
|
||||
.ToList())
|
||||
{
|
||||
metadata.Genres.Add(genre);
|
||||
}
|
||||
|
||||
// tags
|
||||
foreach (Tag tag in metadata.Tags
|
||||
.Filter(g => incomingMetadata.Tags.All(g2 => g2.Name != g.Name))
|
||||
.ToList())
|
||||
{
|
||||
metadata.Tags.Remove(tag);
|
||||
}
|
||||
|
||||
foreach (Tag tag in incomingMetadata.Tags
|
||||
.Filter(g => metadata.Tags.All(g2 => g2.Name != g.Name))
|
||||
.ToList())
|
||||
{
|
||||
metadata.Tags.Add(tag);
|
||||
}
|
||||
|
||||
var paths = incomingMetadata.Artwork.Map(a => a.Path).ToList();
|
||||
foreach (Artwork artworkToRemove in metadata.Artwork
|
||||
.Filter(a => !paths.Contains(a.Path))
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user