Compare commits

...

44 Commits

Author SHA1 Message Date
Jason Dove
c309ab430e update changelog for release v0.7.4-beta [no ci] 2023-02-12 18:21:11 -06:00
Jason Dove
13e21bbcce sync episode tags and genres (#1155)
* sync episode tags and genres

* update dependencies

* property update local episode genres and tags

* fix test
2023-02-12 09:53:20 -06:00
Jason Dove
0eb36f0ce1 prioritize default audio streams (#1154) 2023-02-10 09:31:55 -06:00
Jason Dove
6429f0f064 fix filler padding (#1153)
* fix filler padding

* update dependencies
2023-02-07 19:55:30 -06:00
Jason Dove
7412ac6fc9 fix mid and post roll filler ordering (#1152) 2023-02-07 12:25:22 -06:00
Jason Dove
e58e3c786d fix last scan check (#1150) 2023-02-06 05:38:08 -06:00
Jason Dove
93fc1e4eb4 fix fallback filler looping (#1146) 2023-02-04 08:49:52 -06:00
Jason Dove
cacde26796 merge other video folder tags with nfo tags (#1144) 2023-02-01 05:58:26 -06:00
Jason Dove
0a3db92c60 fix schedule copy (#1142) 2023-01-30 10:23:18 -06:00
Jason Dove
8bb0cd5ab5 add copy schedule feature (#1141) 2023-01-30 06:38:34 -06:00
Jason Dove
e497dc4e36 fix nvidia vp9 color normalization (#1140) 2023-01-29 16:02:29 -06:00
Jason Dove
2689a67eb8 qsv and vaapi fixes (#1139)
* lots of qsv fixes

* update changelog

* fix qsv mpeg2

* vaapi fixes

* update changelog

* upgrade mudblazor

* fix bug with undefined input colorspace
2023-01-29 10:00:52 -06:00
Jason Dove
3d821043bb update changelog for v0.7.3-beta [no ci] 2023-01-25 12:01:38 -06:00
Jason Dove
e69c58e615 conditionally disable v2 apis (#1135)
* conditionally disable v2 apis

* update changelog
2023-01-25 11:37:43 -06:00
Jason Dove
a21b6f9f4e add oidc logout url to support auth0 (#1134) 2023-01-25 09:30:36 -06:00
Jason Dove
99b8038852 add oidc support (#1133) 2023-01-25 08:37:59 -06:00
Jason Dove
ef8ca9f8c6 build mac artifacts on macos 11 (#1132) 2023-01-24 15:04:55 -06:00
Jason Dove
d9186df157 minor logging and doc updates (#1130) 2023-01-23 05:28:17 -06:00
Jason Dove
aca6bfb0bb fix multiple gcs after extracting subtitles (#1129) 2023-01-22 13:10:13 -06:00
Jason Dove
587fc3a98f release memory after extracting embedded subtitles (#1128) 2023-01-22 12:34:42 -06:00
Jason Dove
ab1c67e60e memory improvements (#1127)
* regularly release memory

* don't aggressively GC while legacy streaming

* update changelog
2023-01-22 09:16:24 -06:00
Jason Dove
e271f43066 more scan check fixes (#1126) 2023-01-21 08:22:56 -06:00
Jason Dove
6bf8feb26e fix local library scan check with new install (#1125) 2023-01-21 08:10:42 -06:00
Jason Dove
ffd66f6a21 fix removing media server libraries (#1124) 2023-01-20 09:31:18 -06:00
Jason Dove
3b135df4c1 scan with below-normal priority when unforced (#1123) 2023-01-20 06:05:39 -06:00
Jason Dove
4369d04940 scanner improvements (#1122)
* optimize periodic scanning

* set scanner process priority

* update dependencies
2023-01-20 05:37:39 -06:00
Jason Dove
faaa78fed7 update changelog [no ci] 2023-01-18 15:40:00 -06:00
Jason Dove
6bea1660ea disable mac compression; this is needed until dotnet 7.0.3 (#1120) 2023-01-18 15:13:07 -06:00
Jason Dove
8d46676c25 try to fix mac scanning (#1119) 2023-01-18 14:43:26 -06:00
Jason Dove
4c75e638a2 fix bug with smart collection progress (#1118) 2023-01-18 14:09:54 -06:00
Jason Dove
dd73a3803a fix schedule editor crash (#1115)
* fix schedule editor crash due to bad music video artist data

* update dependencies
2023-01-15 06:35:51 -06:00
Jason Dove
f6c345d7cf fix build 2023-01-10 15:13:42 -06:00
Jason Dove
585b56a668 bug fixes (#1107)
* don't search an empty search index

* fix bug with flood filler prediction check

* extract subtitles on primary worker thread
2023-01-10 14:45:04 -06:00
Jason Dove
f18f3b4f35 try to fix develop artifacts 2023-01-09 08:46:55 -06:00
Jason Dove
eb7871a048 fix alternate schedule playout update check (#1106)
* fix alternate schedule playout update check

* Revert "use mknejp/delete-release-assets again"

This reverts commit 07ac833067.
2023-01-09 05:36:15 -06:00
Jason Dove
000fc78fd3 add alternate schedule system (#1105)
* start to add program schedule alternates

* edit days of the week

* editor improvements

* save changes

* build playouts using alternate schedules

* reset playout as needed

* add priority message
2023-01-08 23:22:17 -06:00
Jason Dove
ba676ef956 add jellyfin admin error logging (#1102) 2023-01-07 09:55:02 -06:00
Jason Dove
36ea88e2d6 fix error display (#1099)
* fix error display by ignoring hw accel setting

* update changelog

* revert background change
2023-01-05 20:02:48 -06:00
Jason Dove
5237e6fa50 update changelog for release v0.7.2-beta [no ci] 2023-01-05 11:51:57 -06:00
Jason Dove
99bde1819c use mknejp/delete-release-assets again (#1098) 2023-01-05 10:26:42 -06:00
Jason Dove
f5d7ec2890 update workflow [no ci] 2023-01-05 10:06:32 -06:00
Jason Dove
13c65435d3 update dependencies (#1097) 2023-01-05 09:27:12 -06:00
Jason Dove
315420f1a5 fix log viewer on windows (#1095)
* fix log viewer on windows

* catch cancellation on trakt page

* update changelog
2023-01-04 22:26:35 -06:00
Jason Dove
ab7051f075 reimplement log viewer (#1094) 2023-01-04 10:09:11 -06:00
174 changed files with 22261 additions and 922 deletions

View File

@@ -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 }}

View File

@@ -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

View File

@@ -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

View File

@@ -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;
}
}

View File

@@ -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));
}
}

View File

@@ -0,0 +1,3 @@
namespace ErsatzTV.Application.Channels;
public record GetChannelNameByPlayoutId(int PlayoutId) : IRequest<Option<string>>;

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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;

View File

@@ -52,7 +52,7 @@ public class UpdateFFmpegSettingsHandler : IRequestHandler<UpdateFFmpegSettings,
UseShellExecute = false
};
var test = new Process
using var test = new Process
{
StartInfo = startInfo
};

View File

@@ -1,5 +0,0 @@
namespace ErsatzTV.Application;
public interface ISubtitleWorkerRequest
{
}

View File

@@ -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);
}
}

View File

@@ -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;
}

View File

@@ -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);

View File

@@ -0,0 +1,8 @@
using Serilog.Events;
namespace ErsatzTV.Application.Logs;
public record LogEntryViewModel(
DateTimeOffset Timestamp,
LogEventLevel Level,
string Message);

View 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);
}
}

View File

@@ -0,0 +1,3 @@
namespace ErsatzTV.Application.Logs;
public record PagedLogEntriesViewModel(int TotalCount, List<LogEntryViewModel> Page);

View 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; }
}

View File

@@ -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;
}
}
}

View File

@@ -0,0 +1,6 @@
namespace ErsatzTV.Application.Maintenance;
public record ReleaseMemory(bool ForceAggressive) : IRequest<Unit>, IBackgroundServiceRequest
{
public DateTimeOffset RequestTime = DateTimeOffset.Now;
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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."));
}

View File

@@ -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);

View File

@@ -0,0 +1,6 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.Playouts;
public record ReplacePlayoutAlternateScheduleItems
(int PlayoutId, List<ReplacePlayoutAlternateSchedule> Items) : IRequest<Either<BaseError, Unit>>;

View File

@@ -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);
}
}
}

View File

@@ -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)

View File

@@ -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);

View File

@@ -0,0 +1,3 @@
namespace ErsatzTV.Application.Playouts;
public record GetPlayoutAlternateSchedules(int PlayoutId) : IRequest<List<PlayoutAlternateScheduleViewModel>>;

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,6 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.ProgramSchedules;
public record CopyProgramSchedule
(int ProgramScheduleId, string Name) : IRequest<Either<BaseError, ProgramScheduleViewModel>>;

View File

@@ -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);
}
}
}

View File

@@ -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(

View File

@@ -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(

View File

@@ -3,4 +3,4 @@
namespace ErsatzTV.Application.Subtitles;
public record ExtractEmbeddedSubtitles(Option<int> PlayoutId) : IRequest<Either<BaseError, Unit>>,
ISubtitleWorkerRequest;
IBackgroundServiceRequest;

View File

@@ -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(

View File

@@ -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>

View File

@@ -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();

View File

@@ -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();

View File

@@ -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);

View File

@@ -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]

View File

@@ -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)

View File

@@ -1,9 +0,0 @@
namespace ErsatzTV.Core.Domain;
public record LogEntry(
int Id,
DateTime Timestamp,
string Level,
string Exception,
string RenderedMessage,
string Properties);

View File

@@ -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; }

View File

@@ -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; }
}

View 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; }
}

View File

@@ -0,0 +1,8 @@
namespace ErsatzTV.Core.Errors;
public class ScanIsNotRequired : BaseError
{
public ScanIsNotRequired() : base("Scan is not required")
{
}
}

View File

@@ -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>

View File

@@ -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),

View File

@@ -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);

View File

@@ -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,

View 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);
}
}

View File

@@ -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(

View File

@@ -10,5 +10,6 @@ public interface IEmbyMovieLibraryScanner
EmbyLibrary library,
string ffmpegPath,
string ffprobePath,
bool deepScan,
CancellationToken cancellationToken);
}

View File

@@ -10,5 +10,6 @@ public interface IEmbyTelevisionLibraryScanner
EmbyLibrary library,
string ffmpegPath,
string ffprobePath,
bool deepScan,
CancellationToken cancellationToken);
}

View File

@@ -10,5 +10,6 @@ public interface IJellyfinMovieLibraryScanner
JellyfinLibrary library,
string ffmpegPath,
string ffprobePath,
bool deepScan,
CancellationToken cancellationToken);
}

View File

@@ -10,5 +10,6 @@ public interface IJellyfinTelevisionLibraryScanner
JellyfinLibrary library,
string ffmpegPath,
string ffprobePath,
bool deepScan,
CancellationToken cancellationToken);
}

View File

@@ -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();
}

View File

@@ -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);

View File

@@ -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);

View File

@@ -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();
}

View File

@@ -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)];

View File

@@ -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

View 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;
}
}

View File

@@ -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" />

View File

@@ -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)
{

View File

@@ -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
};

View File

@@ -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";

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View 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
};
}

View 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
};
}

View File

@@ -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>

View File

@@ -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

View 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;
}

View 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;
}

View File

@@ -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");

View File

@@ -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

View File

@@ -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)
};

View File

@@ -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" />

View File

@@ -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())
{
}
}

View File

@@ -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())
{
}
}

View File

@@ -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);

View File

@@ -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);
}

View File

@@ -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);
}
}

View File

@@ -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);

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}

View File

@@ -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)

View File

@@ -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)

View File

@@ -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>());
}
}

View File

@@ -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);
}
}

View File

@@ -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();
}
}

View File

@@ -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