Compare commits
105 Commits
v0.7.1-bet
...
v0.7.7-bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
793e85f889 | ||
|
|
126304bb8a | ||
|
|
7b3b9b4aad | ||
|
|
cf3b8d90e3 | ||
|
|
109b244676 | ||
|
|
9f42333465 | ||
|
|
c9141b0d86 | ||
|
|
d2c4a58528 | ||
|
|
e93d678b97 | ||
|
|
307940d732 | ||
|
|
721f0df82a | ||
|
|
aa87abc53d | ||
|
|
83d4aa0cb1 | ||
|
|
46034aff54 | ||
|
|
3e447ac7e4 | ||
|
|
bda27faaa3 | ||
|
|
80d89a2530 | ||
|
|
e849ef5dfa | ||
|
|
a26ecb91b8 | ||
|
|
2853e13edc | ||
|
|
9ba0b844a1 | ||
|
|
fdab54a055 | ||
|
|
7e0801119e | ||
|
|
b2f7bcaf1e | ||
|
|
71b8be37da | ||
|
|
f7d19e3747 | ||
|
|
17dcbfc344 | ||
|
|
78745de0ca | ||
|
|
35445e2b3d | ||
|
|
bd2f0f6236 | ||
|
|
4c67965b50 | ||
|
|
234e93349b | ||
|
|
e7e20de502 | ||
|
|
dfc36b4581 | ||
|
|
c56e2526c4 | ||
|
|
8ff6bf652c | ||
|
|
a386fe9ba1 | ||
|
|
4d84fc242b | ||
|
|
40e79a3a14 | ||
|
|
c653bb32a7 | ||
|
|
b032e70d7e | ||
|
|
074816be50 | ||
|
|
3fafd5192f | ||
|
|
1d63197b56 | ||
|
|
b2c57e7407 | ||
|
|
581aa51792 | ||
|
|
4d57ece30d | ||
|
|
eddbf07b11 | ||
|
|
450ea063b4 | ||
|
|
f320d84874 | ||
|
|
c832c8e860 | ||
|
|
e5ef8eaf72 | ||
|
|
6db71f525d | ||
|
|
3ab66ef12a | ||
|
|
018f759fa4 | ||
|
|
1afff11063 | ||
|
|
7e3436e68f | ||
|
|
b751f1054b | ||
|
|
900e9e75f3 | ||
|
|
62c28d9f51 | ||
|
|
132ca99f94 | ||
|
|
c309ab430e | ||
|
|
13e21bbcce | ||
|
|
0eb36f0ce1 | ||
|
|
6429f0f064 | ||
|
|
7412ac6fc9 | ||
|
|
e58e3c786d | ||
|
|
93fc1e4eb4 | ||
|
|
cacde26796 | ||
|
|
0a3db92c60 | ||
|
|
8bb0cd5ab5 | ||
|
|
e497dc4e36 | ||
|
|
2689a67eb8 | ||
|
|
3d821043bb | ||
|
|
e69c58e615 | ||
|
|
a21b6f9f4e | ||
|
|
99b8038852 | ||
|
|
ef8ca9f8c6 | ||
|
|
d9186df157 | ||
|
|
aca6bfb0bb | ||
|
|
587fc3a98f | ||
|
|
ab1c67e60e | ||
|
|
e271f43066 | ||
|
|
6bf8feb26e | ||
|
|
ffd66f6a21 | ||
|
|
3b135df4c1 | ||
|
|
4369d04940 | ||
|
|
faaa78fed7 | ||
|
|
6bea1660ea | ||
|
|
8d46676c25 | ||
|
|
4c75e638a2 | ||
|
|
dd73a3803a | ||
|
|
f6c345d7cf | ||
|
|
585b56a668 | ||
|
|
f18f3b4f35 | ||
|
|
eb7871a048 | ||
|
|
000fc78fd3 | ||
|
|
ba676ef956 | ||
|
|
36ea88e2d6 | ||
|
|
5237e6fa50 | ||
|
|
99bde1819c | ||
|
|
f5d7ec2890 | ||
|
|
13c65435d3 | ||
|
|
315420f1a5 | ||
|
|
ab7051f075 |
20
.github/workflows/artifacts.yml
vendored
20
.github/workflows/artifacts.yml
vendored
@@ -33,10 +33,10 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- os: macos-latest
|
||||
- os: macos-11
|
||||
kind: macOS
|
||||
target: osx-x64
|
||||
- os: macos-latest
|
||||
- os: macos-11
|
||||
kind: macOS
|
||||
target: osx-arm64
|
||||
steps:
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
node-version: '14'
|
||||
|
||||
- name: Cache NPM dependencies
|
||||
uses: bahmutov/npm-install@v1.4.5
|
||||
uses: bahmutov/npm-install@v1.8.28
|
||||
with:
|
||||
working-directory: ErsatzTV/client-app
|
||||
|
||||
@@ -83,8 +83,8 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
sed -i '' '/Scanner/d' ErsatzTV/ErsatzTV.csproj
|
||||
dotnet publish ErsatzTV.Scanner/ErsatzTV.Scanner.csproj --framework net7.0 --runtime "${{ matrix.target }}" -c Release -o publish -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
|
||||
dotnet publish ErsatzTV/ErsatzTV.csproj --framework net7.0 --runtime "${{ matrix.target }}" -c Release -o publish -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
|
||||
dotnet publish ErsatzTV.Scanner/ErsatzTV.Scanner.csproj --framework net7.0 --runtime "${{ matrix.target }}" -c Release -o publish -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=false -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
|
||||
dotnet publish ErsatzTV/ErsatzTV.csproj --framework net7.0 --runtime "${{ matrix.target }}" -c Release -o publish -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=false -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
|
||||
|
||||
- name: Bundle
|
||||
shell: bash
|
||||
@@ -133,7 +133,7 @@ jobs:
|
||||
rm -r ErsatzTV.app
|
||||
|
||||
- name: Delete old release assets
|
||||
uses: asfernandes/delete-release-assets@update-libraries-and-node
|
||||
uses: mknejp/delete-release-assets@v1
|
||||
if: ${{ inputs.release_tag == 'develop' }}
|
||||
with:
|
||||
token: ${{ secrets.gh_token }}
|
||||
@@ -193,7 +193,7 @@ jobs:
|
||||
if: ${{ matrix.kind == 'windows' }}
|
||||
|
||||
- name: Cache NPM dependencies
|
||||
uses: bahmutov/npm-install@v1.4.5
|
||||
uses: bahmutov/npm-install@v1.8.28
|
||||
with:
|
||||
working-directory: ErsatzTV/client-app
|
||||
|
||||
@@ -203,12 +203,12 @@ 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
|
||||
with:
|
||||
url: "https://github.com/GyanD/codexffmpeg/releases/download/5.1/ffmpeg-5.1-full_build.7z"
|
||||
url: "https://github.com/GyanD/codexffmpeg/releases/download/6.0/ffmpeg-6.0-full_build.7z"
|
||||
target: ffmpeg/
|
||||
|
||||
- name: Build
|
||||
@@ -250,7 +250,7 @@ jobs:
|
||||
AC_PASSWORD: ${{ secrets.ac_password }}
|
||||
|
||||
- name: Delete old release assets
|
||||
uses: asfernandes/delete-release-assets@update-libraries-and-node
|
||||
uses: mknejp/delete-release-assets@v1
|
||||
if: ${{ inputs.release_tag == 'develop' }}
|
||||
with:
|
||||
token: ${{ secrets.gh_token }}
|
||||
|
||||
2
.github/workflows/pr.yml
vendored
2
.github/workflows/pr.yml
vendored
@@ -63,7 +63,7 @@ jobs:
|
||||
- name: Test
|
||||
run: dotnet test --no-restore --verbosity normal
|
||||
build_and_test_mac:
|
||||
runs-on: macos-latest
|
||||
runs-on: macos-11
|
||||
steps:
|
||||
- name: Get the sources
|
||||
uses: actions/checkout@v3
|
||||
|
||||
169
CHANGELOG.md
169
CHANGELOG.md
@@ -1,9 +1,168 @@
|
||||
Changelog
|
||||
# Changelog
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
|
||||
## [Unreleased]
|
||||
## [0.7.7-beta] - 2023-04-07
|
||||
### Added
|
||||
- Use `plot` field from Other Video NFO metadata as XMLTV description
|
||||
- Add detailed warning log when a file is added to ErsatzTV more than once
|
||||
|
||||
### Fixed
|
||||
- Fix updating (re-adding) Trakt lists to properly use new metadata ids that were not present when originally added
|
||||
- Fix local show library scanning with non-english season folder names, e.g. `Staffel 02`
|
||||
- Fix bug where local libraries would merge with media server libraries when the same file was added to both libraries
|
||||
- Fix transcoding some 10-bit content from media servers using VAAPI acceleration
|
||||
- Fix decoding of MPEG-4 Part 2 (e.g. DivX) content using NVIDIA acceleration
|
||||
- Fix color normalization from `bt470bg` to `bt709` using QSV acceleration
|
||||
- Fix adding files to search index with unknown video codec
|
||||
- Fix subtitle burn-in (embedded or external) using Jellyfin, Emby and Plex libraries
|
||||
- **This requires a one-time full library scan, which may take a long time with large libraries.**
|
||||
|
||||
### Changed
|
||||
- Use Poster artwork for XMLTV if available
|
||||
- If Poster artwork is unavailable, use Thumbnail
|
||||
- Improve XMLTV response time by caching data as playouts are updated
|
||||
|
||||
## [0.7.6-beta] - 2023-03-24
|
||||
### Added
|
||||
- Add `Troubleshooting` page with aggregated settings/hardware accel info for easy reference
|
||||
- Read `director` fields from music video NFO metadata
|
||||
- Pass `directors` and `studios` to music video credit templates
|
||||
- Add optional JSON Web Token (JWT) query string auth for streaming endpoints (everything under `/iptv`)
|
||||
- This can be configured using the following env var (note the double underscore separator `__`)
|
||||
- `JWT__ISSUERSIGNINGKEY`
|
||||
- When configured, a JWT signed with the configured signing key is required to be passed in the query string as `access_token`, for example:
|
||||
- `http://localhost:8409/iptv/channels.m3u?access_token=ABCDEF`
|
||||
- `http://localhost:8409/iptv/xmltv.xml?access_token=ABCDEF`
|
||||
- When channels are retrieved this way, the access token will automatically be passed through to all necessary urls
|
||||
- Note that ONLY the `/iptv` endpoints will require auth when JWT is configured
|
||||
|
||||
### Fixed
|
||||
- Fix scaling anamorphic content from non-local libraries
|
||||
- Fix direct streaming content from Jellyfin that has external subtitles
|
||||
- Note that these subtitles are not currently supported in ETV, but they did cause a playback issue
|
||||
- Fix Jellyfin, Emby and Plex library scans that wouldn't work in certain timezones
|
||||
- Fix song normalization to match FFmpeg Profile bit depth
|
||||
- Fix bug playing some external subtitle files (e.g. with an apostrophe in the file name)
|
||||
- Fix bug detecting VAAPI capabilities when no device is selected in active FFmpeg Profile
|
||||
- Fix playout mode duration bugs in XMLTV
|
||||
- Tail mode filler will properly include filler duration in XMLTV
|
||||
- Duration that wraps across midnight will no longer have overlapping items in XMLTV
|
||||
- Maintain collection progress across all alternate schedules on a playout
|
||||
- Fix color normalization from `bt470bg` to `bt709`
|
||||
|
||||
### Changed
|
||||
- Ignore case of video and audio file extensions in local folder scanner
|
||||
- For example, the scanner will now find `movie.MKV` as well as `movie.mkv` on case-sensitive filesystems
|
||||
- Include multiple `display-name` entries in generated XMLTV
|
||||
- Plex should now display the channel number instead of the channel id (e.g. `1.2` instead of `1.2.etv`)
|
||||
- Rework concurrency a bit
|
||||
- Playout builds are no longer blocked by library scans
|
||||
- Adding Trakt lists is no longer blocked by library scans
|
||||
- All library scans (local and media servers) run sequentially
|
||||
- Emby collection scanning will no longer happen after every (automatic or forced) library scan
|
||||
- Automatic/periodic scans will check collections one time after all libraries have been scanned
|
||||
- There is a new table in the `Media` > `Libraries` page with a button to manually re-scan Emby collections as needed
|
||||
- For performance reasons, limit console log output to errors on Windows
|
||||
- Other platforms are unchanged
|
||||
- Log file behavior is unchanged
|
||||
|
||||
## [0.7.5-beta] - 2023-03-05
|
||||
### Added
|
||||
- Use AV1 hardware-accelerated decoder with VAAPI, QSV, NVIDIA when available
|
||||
- Use VP9 hardware-accelerated decoder with VAAPI when available
|
||||
|
||||
### Fixed
|
||||
- Align default docker image (no acceleration) with new images from [ErsatzTV-ffmpeg](https://github.com/jasongdove/ErsatzTV-ffmpeg)
|
||||
- Fix some transcoding pipelines that use software decoders
|
||||
- Improve VAAPI encoder capability detection on newer hardware
|
||||
- Fix trash page to properly display episodes with missing metadata or titles
|
||||
- Fix playback of content with yuv444p10le pixel format
|
||||
- Fix case where some multi-episode files from Plex would crash the scanner
|
||||
|
||||
### Changed
|
||||
- Upgrade all docker images and windows builds to ffmpeg 6.0
|
||||
- Plex, Jellyfin and Emby libraries now retrieve all metadata and statistics from the media server
|
||||
- File systems will no longer be periodically scanned for libraries using these media sources
|
||||
- Plex, Jellyfin and Emby libraries now direct stream content when files are not found on ErsatzTV's file system
|
||||
- Content will still be normalized according to the Channel and FFmpeg Profile settings
|
||||
- Streaming from disk is preferred, so every playback attempt will first check the local file system
|
||||
- Use libvpl instead of libmfx to provide intel acceleration in vaapi docker images
|
||||
- Search queries no longer remove duplicate results as this was causing incorrect behavior
|
||||
- Prioritize audio streams that are flagged as "default" over number of audio channels
|
||||
- For example, a video with a stereo commentary track and a mono "default" track will now prefer the "default" track
|
||||
- Support many more season folder names with local television libraries
|
||||
|
||||
## [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
|
||||
@@ -1456,7 +1615,13 @@ 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.7-beta...HEAD
|
||||
[0.7.7-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.7.6-beta...v0.7.7-beta
|
||||
[0.7.6-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.7.5-beta...v0.7.6-beta
|
||||
[0.7.5-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.7.4-beta...v0.7.5-beta
|
||||
[0.7.4-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.7.3-beta...v0.7.4-beta
|
||||
[0.7.3-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.7.2-beta...v0.7.3-beta
|
||||
[0.7.2-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.7.1-beta...v0.7.2-beta
|
||||
[0.7.1-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.7.0-beta...v0.7.1-beta
|
||||
[0.7.0-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.6.9-beta...v0.7.0-beta
|
||||
[0.6.9-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.6.8-beta...v0.6.9-beta
|
||||
|
||||
@@ -1,22 +1,44 @@
|
||||
using ErsatzTV.Application.MediaItems;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using static ErsatzTV.Application.MediaItems.Mapper;
|
||||
|
||||
namespace ErsatzTV.Application.Artists;
|
||||
|
||||
public class GetAllArtistsHandler : IRequestHandler<GetAllArtists, List<NamedMediaItemViewModel>>
|
||||
{
|
||||
private readonly IArtistRepository _artistRepository;
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
|
||||
public GetAllArtistsHandler(IArtistRepository artistRepository) => _artistRepository = artistRepository;
|
||||
public GetAllArtistsHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
}
|
||||
|
||||
public Task<List<NamedMediaItemViewModel>> Handle(
|
||||
public async Task<List<NamedMediaItemViewModel>> Handle(
|
||||
GetAllArtists request,
|
||||
CancellationToken cancellationToken) =>
|
||||
_artistRepository.GetAllArtists()
|
||||
.Map(
|
||||
list => list.Filter(
|
||||
a => !string.IsNullOrWhiteSpace(
|
||||
a.ArtistMetadata.HeadOrNone().Match(am => am.Title, () => string.Empty))))
|
||||
.Map(list => list.Map(ProjectToViewModel).ToList());
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
List<Artist> allArtists = await dbContext.Artists
|
||||
.AsNoTracking()
|
||||
.Include(a => a.ArtistMetadata)
|
||||
.ToListAsync(cancellationToken: cancellationToken);
|
||||
|
||||
return allArtists.Bind(a => ProjectArtist(a)).ToList();
|
||||
}
|
||||
|
||||
private static Option<NamedMediaItemViewModel> ProjectArtist(Artist a)
|
||||
{
|
||||
foreach (ArtistMetadata metadata in a.ArtistMetadata.HeadOrNone())
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(metadata.Title))
|
||||
{
|
||||
return ProjectToViewModel(a);
|
||||
}
|
||||
}
|
||||
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public record RefreshChannelData(string ChannelNumber) : IRequest, IBackgroundServiceRequest;
|
||||
@@ -0,0 +1,521 @@
|
||||
using System.Xml;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.Emby;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Jellyfin;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.IO;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
{
|
||||
private readonly RecyclableMemoryStreamManager _recyclableMemoryStreamManager;
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly ILogger<RefreshChannelDataHandler> _logger;
|
||||
|
||||
public RefreshChannelDataHandler(
|
||||
RecyclableMemoryStreamManager recyclableMemoryStreamManager,
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ILocalFileSystem localFileSystem,
|
||||
ILogger<RefreshChannelDataHandler> logger)
|
||||
{
|
||||
_recyclableMemoryStreamManager = recyclableMemoryStreamManager;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_localFileSystem = localFileSystem;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task Handle(RefreshChannelData request, CancellationToken cancellationToken)
|
||||
{
|
||||
_localFileSystem.EnsureFolderExists(FileSystemLayout.ChannelGuideCacheFolder);
|
||||
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
List<PlayoutItem> sorted = await dbContext.Playouts
|
||||
.AsNoTracking()
|
||||
.Filter(pi => pi.Channel.Number == request.ChannelNumber)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Episode).EpisodeMetadata)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Episode).Season)
|
||||
.ThenInclude(s => s.Show)
|
||||
.ThenInclude(s => s.ShowMetadata)
|
||||
.ThenInclude(sm => sm.Artwork)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Episode).Season)
|
||||
.ThenInclude(s => s.Show)
|
||||
.ThenInclude(s => s.ShowMetadata)
|
||||
.ThenInclude(em => em.Genres)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Movie).MovieMetadata)
|
||||
.ThenInclude(mm => mm.Artwork)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Movie).MovieMetadata)
|
||||
.ThenInclude(mm => mm.Genres)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
|
||||
.ThenInclude(mm => mm.Artwork)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
|
||||
.ThenInclude(mvm => mvm.Genres)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as MusicVideo).Artist)
|
||||
.ThenInclude(a => a.ArtistMetadata)
|
||||
.ThenInclude(am => am.Genres)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as OtherVideo).OtherVideoMetadata)
|
||||
.ThenInclude(vm => vm.Artwork)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Song).SongMetadata)
|
||||
.ThenInclude(vm => vm.Artwork)
|
||||
.ToListAsync(cancellationToken)
|
||||
.Map(list => list.Collect(p => p.Items).OrderBy(pi => pi.Start).ToList());
|
||||
|
||||
using MemoryStream ms = _recyclableMemoryStreamManager.GetStream();
|
||||
await using var xml = XmlWriter.Create(
|
||||
ms,
|
||||
new XmlWriterSettings { Async = true, ConformanceLevel = ConformanceLevel.Fragment });
|
||||
|
||||
// skip all filler that isn't pre-roll
|
||||
var i = 0;
|
||||
while (i < sorted.Count && sorted[i].FillerKind != FillerKind.None &&
|
||||
sorted[i].FillerKind != FillerKind.PreRoll)
|
||||
{
|
||||
i++;
|
||||
}
|
||||
|
||||
while (i < sorted.Count)
|
||||
{
|
||||
PlayoutItem startItem = sorted[i];
|
||||
int j = i;
|
||||
while (sorted[j].FillerKind != FillerKind.None && j + 1 < sorted.Count)
|
||||
{
|
||||
j++;
|
||||
}
|
||||
|
||||
PlayoutItem displayItem = sorted[j];
|
||||
bool hasCustomTitle = !string.IsNullOrWhiteSpace(startItem.CustomTitle);
|
||||
|
||||
int finishIndex = j;
|
||||
while (finishIndex + 1 < sorted.Count && (sorted[finishIndex + 1].GuideGroup == startItem.GuideGroup
|
||||
|| sorted[finishIndex + 1].FillerKind is FillerKind.GuideMode
|
||||
or FillerKind.Tail or FillerKind.Fallback))
|
||||
{
|
||||
finishIndex++;
|
||||
}
|
||||
|
||||
int customShowId = -1;
|
||||
if (displayItem.MediaItem is Episode ep)
|
||||
{
|
||||
customShowId = ep.Season.ShowId;
|
||||
}
|
||||
|
||||
bool isSameCustomShow = hasCustomTitle;
|
||||
for (int x = j; x <= finishIndex; x++)
|
||||
{
|
||||
isSameCustomShow = isSameCustomShow && sorted[x].MediaItem is Episode e &&
|
||||
customShowId == e.Season.ShowId;
|
||||
}
|
||||
|
||||
PlayoutItem finishItem = sorted[finishIndex];
|
||||
i = finishIndex;
|
||||
|
||||
string start = startItem.StartOffset.ToString("yyyyMMddHHmmss zzz").Replace(":", string.Empty);
|
||||
string stop = displayItem.GuideFinishOffset.HasValue
|
||||
? displayItem.GuideFinishOffset.Value.ToString("yyyyMMddHHmmss zzz").Replace(":", string.Empty)
|
||||
: finishItem.FinishOffset.ToString("yyyyMMddHHmmss zzz").Replace(":", string.Empty);
|
||||
|
||||
string title = GetTitle(displayItem);
|
||||
string subtitle = GetSubtitle(displayItem);
|
||||
string description = GetDescription(displayItem);
|
||||
Option<ContentRating> contentRating = GetContentRating(displayItem);
|
||||
|
||||
await xml.WriteStartElementAsync(null, "programme", null);
|
||||
await xml.WriteAttributeStringAsync(null, "start", null, start);
|
||||
await xml.WriteAttributeStringAsync(null, "stop", null, stop);
|
||||
await xml.WriteAttributeStringAsync(null, "channel", null, $"{request.ChannelNumber}.etv");
|
||||
|
||||
await xml.WriteStartElementAsync(null, "title", null);
|
||||
await xml.WriteAttributeStringAsync(null, "lang", null, "en");
|
||||
await xml.WriteStringAsync(title);
|
||||
await xml.WriteEndElementAsync(); // title
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(subtitle))
|
||||
{
|
||||
await xml.WriteStartElementAsync(null, "sub-title", null);
|
||||
await xml.WriteAttributeStringAsync(null, "lang", null, "en");
|
||||
await xml.WriteStringAsync(subtitle);
|
||||
await xml.WriteEndElementAsync(); // subtitle
|
||||
}
|
||||
|
||||
if (!isSameCustomShow)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(description))
|
||||
{
|
||||
await xml.WriteStartElementAsync(null, "desc", null);
|
||||
await xml.WriteAttributeStringAsync(null, "lang", null, "en");
|
||||
await xml.WriteStringAsync(description);
|
||||
await xml.WriteEndElementAsync(); // desc
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasCustomTitle && displayItem.MediaItem is Movie movie)
|
||||
{
|
||||
foreach (MovieMetadata metadata in movie.MovieMetadata.HeadOrNone())
|
||||
{
|
||||
if (metadata.Year.HasValue)
|
||||
{
|
||||
await xml.WriteStartElementAsync(null, "date", null);
|
||||
await xml.WriteStringAsync(metadata.Year.Value.ToString());
|
||||
await xml.WriteEndElementAsync(); // date
|
||||
}
|
||||
|
||||
await xml.WriteStartElementAsync(null, "category", null);
|
||||
await xml.WriteAttributeStringAsync(null, "lang", null, "en");
|
||||
await xml.WriteStringAsync("Movie");
|
||||
await xml.WriteEndElementAsync(); // category
|
||||
|
||||
foreach (Genre genre in Optional(metadata.Genres).Flatten().OrderBy(g => g.Name))
|
||||
{
|
||||
await xml.WriteStartElementAsync(null, "category", null);
|
||||
await xml.WriteAttributeStringAsync(null, "lang", null, "en");
|
||||
await xml.WriteStringAsync(genre.Name);
|
||||
await xml.WriteEndElementAsync(); // category
|
||||
}
|
||||
|
||||
string poster = Optional(metadata.Artwork).Flatten()
|
||||
.Filter(a => a.ArtworkKind == ArtworkKind.Poster)
|
||||
.HeadOrNone()
|
||||
.Match(a => GetArtworkUrl(a, ArtworkKind.Poster), () => string.Empty);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(poster))
|
||||
{
|
||||
await xml.WriteStartElementAsync(null, "icon", null);
|
||||
await xml.WriteAttributeStringAsync(null, "src", null, poster);
|
||||
await xml.WriteEndElementAsync(); // icon
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasCustomTitle && displayItem.MediaItem is MusicVideo musicVideo)
|
||||
{
|
||||
foreach (MusicVideoMetadata metadata in musicVideo.MusicVideoMetadata.HeadOrNone())
|
||||
{
|
||||
if (metadata.Year.HasValue)
|
||||
{
|
||||
await xml.WriteStartElementAsync(null, "date", null);
|
||||
await xml.WriteStringAsync(metadata.Year.Value.ToString());
|
||||
await xml.WriteEndElementAsync(); // date
|
||||
}
|
||||
|
||||
await xml.WriteStartElementAsync(null, "category", null);
|
||||
await xml.WriteAttributeStringAsync(null, "lang", null, "en");
|
||||
await xml.WriteStringAsync("Music");
|
||||
await xml.WriteEndElementAsync(); // category
|
||||
|
||||
// music video genres
|
||||
foreach (Genre genre in Optional(metadata.Genres).Flatten().OrderBy(g => g.Name))
|
||||
{
|
||||
await xml.WriteStartElementAsync(null, "category", null);
|
||||
await xml.WriteAttributeStringAsync(null, "lang", null, "en");
|
||||
await xml.WriteStringAsync(genre.Name);
|
||||
await xml.WriteEndElementAsync(); // category
|
||||
}
|
||||
|
||||
// artist genres
|
||||
Option<ArtistMetadata> maybeMetadata =
|
||||
Optional(musicVideo.Artist?.ArtistMetadata.HeadOrNone()).Flatten();
|
||||
foreach (ArtistMetadata artistMetadata in maybeMetadata)
|
||||
{
|
||||
foreach (Genre genre in Optional(artistMetadata.Genres).Flatten().OrderBy(g => g.Name))
|
||||
{
|
||||
await xml.WriteStartElementAsync(null, "category", null);
|
||||
await xml.WriteAttributeStringAsync(null, "lang", null, "en");
|
||||
await xml.WriteStringAsync(genre.Name);
|
||||
await xml.WriteEndElementAsync(); // category
|
||||
}
|
||||
}
|
||||
|
||||
string artworkPath = GetPrioritizedArtworkPath(metadata);
|
||||
if (!string.IsNullOrWhiteSpace(artworkPath))
|
||||
{
|
||||
await xml.WriteStartElementAsync(null, "icon", null);
|
||||
await xml.WriteAttributeStringAsync(null, "src", null, artworkPath);
|
||||
await xml.WriteEndElementAsync(); // icon
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasCustomTitle && displayItem.MediaItem is Song song)
|
||||
{
|
||||
await xml.WriteStartElementAsync(null, "category", null);
|
||||
await xml.WriteAttributeStringAsync(null, "lang", null, "en");
|
||||
await xml.WriteStringAsync("Music");
|
||||
await xml.WriteEndElementAsync(); // category
|
||||
|
||||
foreach (SongMetadata metadata in song.SongMetadata.HeadOrNone())
|
||||
{
|
||||
string artworkPath = GetPrioritizedArtworkPath(metadata);
|
||||
if (!string.IsNullOrWhiteSpace(artworkPath))
|
||||
{
|
||||
await xml.WriteStartElementAsync(null, "icon", null);
|
||||
await xml.WriteAttributeStringAsync(null, "src", null, artworkPath);
|
||||
await xml.WriteEndElementAsync(); // icon
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (displayItem.MediaItem is Episode episode && (!hasCustomTitle || isSameCustomShow))
|
||||
{
|
||||
Option<ShowMetadata> maybeMetadata =
|
||||
Optional(episode.Season?.Show?.ShowMetadata.HeadOrNone()).Flatten();
|
||||
foreach (ShowMetadata metadata in maybeMetadata)
|
||||
{
|
||||
await xml.WriteStartElementAsync(null, "category", null);
|
||||
await xml.WriteAttributeStringAsync(null, "lang", null, "en");
|
||||
await xml.WriteStringAsync("Series");
|
||||
await xml.WriteEndElementAsync(); // category
|
||||
|
||||
foreach (Genre genre in Optional(metadata.Genres).Flatten().OrderBy(g => g.Name))
|
||||
{
|
||||
await xml.WriteStartElementAsync(null, "category", null);
|
||||
await xml.WriteAttributeStringAsync(null, "lang", null, "en");
|
||||
await xml.WriteStringAsync(genre.Name);
|
||||
await xml.WriteEndElementAsync(); // category
|
||||
}
|
||||
|
||||
string artworkPath = GetPrioritizedArtworkPath(metadata);
|
||||
if (!string.IsNullOrWhiteSpace(artworkPath))
|
||||
{
|
||||
await xml.WriteStartElementAsync(null, "icon", null);
|
||||
await xml.WriteAttributeStringAsync(null, "src", null, artworkPath);
|
||||
await xml.WriteEndElementAsync(); // icon
|
||||
}
|
||||
}
|
||||
|
||||
if (!isSameCustomShow)
|
||||
{
|
||||
int s = await Optional(episode.Season?.SeasonNumber).IfNoneAsync(-1);
|
||||
// TODO: multi-episode?
|
||||
int e = episode.EpisodeMetadata.HeadOrNone().Match(em => em.EpisodeNumber, -1);
|
||||
if (s >= 0 && e > 0)
|
||||
{
|
||||
await xml.WriteStartElementAsync(null, "episode-num", null);
|
||||
await xml.WriteAttributeStringAsync(null, "system", null, "onscreen");
|
||||
await xml.WriteStringAsync($"S{s:00}E{e:00}");
|
||||
await xml.WriteEndElementAsync(); // episode-num
|
||||
|
||||
await xml.WriteStartElementAsync(null, "episode-num", null);
|
||||
await xml.WriteAttributeStringAsync(null, "system", null, "xmltv_ns");
|
||||
await xml.WriteStringAsync($"{s - 1}.{e - 1}.0/1");
|
||||
await xml.WriteEndElementAsync(); // episode-num
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await xml.WriteStartElementAsync(null, "previously-shown", null);
|
||||
await xml.WriteEndElementAsync(); // previously-shown
|
||||
|
||||
foreach (ContentRating rating in contentRating)
|
||||
{
|
||||
await xml.WriteStartElementAsync(null, "rating", null);
|
||||
foreach (string system in rating.System)
|
||||
{
|
||||
await xml.WriteAttributeStringAsync(null, "system", null, system);
|
||||
}
|
||||
|
||||
await xml.WriteStartElementAsync(null, "value", null);
|
||||
await xml.WriteStringAsync(rating.Value);
|
||||
await xml.WriteEndElementAsync(); // value
|
||||
await xml.WriteEndElementAsync(); // rating
|
||||
}
|
||||
|
||||
await xml.WriteEndElementAsync(); // programme
|
||||
|
||||
i++;
|
||||
}
|
||||
|
||||
await xml.FlushAsync();
|
||||
|
||||
string tempFile = Path.GetTempFileName();
|
||||
await File.WriteAllBytesAsync(tempFile, ms.ToArray(), cancellationToken);
|
||||
|
||||
string targetFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, $"{request.ChannelNumber}.xml");
|
||||
File.Move(tempFile, targetFile, true);
|
||||
}
|
||||
|
||||
private static string GetArtworkUrl(Artwork artwork, ArtworkKind artworkKind)
|
||||
{
|
||||
string artworkPath = artwork.Path;
|
||||
|
||||
int height = artworkKind switch
|
||||
{
|
||||
ArtworkKind.Thumbnail => 220,
|
||||
_ => 440
|
||||
};
|
||||
|
||||
if (artworkPath.StartsWith("jellyfin://"))
|
||||
{
|
||||
artworkPath = JellyfinUrl.PlaceholderProxyForArtwork(artworkPath, artworkKind, height);
|
||||
}
|
||||
else if (artworkPath.StartsWith("emby://"))
|
||||
{
|
||||
artworkPath = EmbyUrl.PlaceholderProxyForArtwork(artworkPath, artworkKind, height);
|
||||
}
|
||||
else
|
||||
{
|
||||
string artworkFolder = artworkKind switch
|
||||
{
|
||||
ArtworkKind.Thumbnail => "thumbnails",
|
||||
_ => "posters"
|
||||
};
|
||||
|
||||
artworkPath = $"{{RequestBase}}/iptv/artwork/{artworkFolder}/{artwork.Path}.jpg{{AccessTokenUri}}";
|
||||
}
|
||||
|
||||
return artworkPath;
|
||||
}
|
||||
|
||||
private static string GetTitle(PlayoutItem playoutItem)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(playoutItem.CustomTitle))
|
||||
{
|
||||
return playoutItem.CustomTitle;
|
||||
}
|
||||
|
||||
return playoutItem.MediaItem switch
|
||||
{
|
||||
Movie m => m.MovieMetadata.HeadOrNone().Map(mm => mm.Title ?? string.Empty)
|
||||
.IfNone("[unknown movie]"),
|
||||
Episode e => e.Season.Show.ShowMetadata.HeadOrNone().Map(em => em.Title ?? string.Empty)
|
||||
.IfNone("[unknown show]"),
|
||||
MusicVideo mv => mv.Artist.ArtistMetadata.HeadOrNone().Map(am => am.Title ?? string.Empty)
|
||||
.IfNone("[unknown artist]"),
|
||||
OtherVideo ov => ov.OtherVideoMetadata.HeadOrNone().Map(vm => vm.Title ?? string.Empty)
|
||||
.IfNone("[unknown video]"),
|
||||
Song s => s.SongMetadata.HeadOrNone().Map(sm => sm.Artist ?? string.Empty)
|
||||
.IfNone("[unknown artist]"),
|
||||
_ => "[unknown]"
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetSubtitle(PlayoutItem playoutItem)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(playoutItem.CustomTitle))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return playoutItem.MediaItem switch
|
||||
{
|
||||
Episode e => e.EpisodeMetadata.HeadOrNone().Match(
|
||||
em => em.Title ?? string.Empty,
|
||||
() => string.Empty),
|
||||
MusicVideo mv => mv.MusicVideoMetadata.HeadOrNone().Match(
|
||||
mvm => mvm.Title ?? string.Empty,
|
||||
() => string.Empty),
|
||||
Song s => s.SongMetadata.HeadOrNone().Match(
|
||||
mvm => mvm.Title ?? string.Empty,
|
||||
() => string.Empty),
|
||||
_ => string.Empty
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetDescription(PlayoutItem playoutItem)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(playoutItem.CustomTitle))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return playoutItem.MediaItem switch
|
||||
{
|
||||
Movie m => m.MovieMetadata.HeadOrNone().Map(mm => mm.Plot ?? string.Empty).IfNone(string.Empty),
|
||||
Episode e => e.EpisodeMetadata.HeadOrNone().Map(em => em.Plot ?? string.Empty)
|
||||
.IfNone(string.Empty),
|
||||
MusicVideo mv => mv.MusicVideoMetadata.HeadOrNone().Map(mvm => mvm.Plot ?? string.Empty)
|
||||
.IfNone(string.Empty),
|
||||
OtherVideo ov => ov.OtherVideoMetadata.HeadOrNone().Map(ovm => ovm.Plot ?? string.Empty)
|
||||
.IfNone(string.Empty),
|
||||
_ => string.Empty
|
||||
};
|
||||
}
|
||||
|
||||
private Option<ContentRating> GetContentRating(PlayoutItem playoutItem)
|
||||
{
|
||||
try
|
||||
{
|
||||
return playoutItem.MediaItem switch
|
||||
{
|
||||
Movie m => m.MovieMetadata
|
||||
.HeadOrNone()
|
||||
.Match(mm => ParseContentRating(mm.ContentRating, "MPAA"), () => None),
|
||||
Episode e => e.Season.Show.ShowMetadata
|
||||
.HeadOrNone()
|
||||
.Match(sm => ParseContentRating(sm.ContentRating, "VCHIP"), () => None),
|
||||
_ => None
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to get content rating for playout item {Item}", GetTitle(playoutItem));
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
private static Option<ContentRating> ParseContentRating(string contentRating, string system)
|
||||
{
|
||||
Option<string> maybeFirst = (contentRating ?? string.Empty).Split('/').HeadOrNone();
|
||||
return maybeFirst.Map(
|
||||
first =>
|
||||
{
|
||||
string[] split = first.Split(':');
|
||||
if (split.Length == 2)
|
||||
{
|
||||
return split[0].ToLowerInvariant() == "us"
|
||||
? new ContentRating(system, split[1].ToUpperInvariant())
|
||||
: new ContentRating(None, split[1].ToUpperInvariant());
|
||||
}
|
||||
|
||||
return string.IsNullOrWhiteSpace(first)
|
||||
? Option<ContentRating>.None
|
||||
: new ContentRating(None, first);
|
||||
}).Flatten();
|
||||
}
|
||||
|
||||
private record ContentRating(Option<string> System, string Value);
|
||||
|
||||
private string GetPrioritizedArtworkPath(Metadata metadata)
|
||||
{
|
||||
Option<string> maybeArtwork = Optional(metadata.Artwork).Flatten()
|
||||
.Filter(a => a.ArtworkKind == ArtworkKind.Poster)
|
||||
.HeadOrNone()
|
||||
.Map(a => GetArtworkUrl(a, ArtworkKind.Poster));
|
||||
|
||||
if (maybeArtwork.IsNone)
|
||||
{
|
||||
maybeArtwork = Optional(metadata.Artwork).Flatten()
|
||||
.Filter(a => a.ArtworkKind == ArtworkKind.Thumbnail)
|
||||
.HeadOrNone()
|
||||
.Map(a => GetArtworkUrl(a, ArtworkKind.Thumbnail));
|
||||
}
|
||||
|
||||
return maybeArtwork.IfNone(string.Empty);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public record RefreshChannelList : IRequest, IBackgroundServiceRequest;
|
||||
@@ -0,0 +1,113 @@
|
||||
using System.Data;
|
||||
using System.Data.Common;
|
||||
using System.Xml;
|
||||
using Dapper;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.IO;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class RefreshChannelListHandler : IRequestHandler<RefreshChannelList>
|
||||
{
|
||||
private readonly RecyclableMemoryStreamManager _recyclableMemoryStreamManager;
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
|
||||
public RefreshChannelListHandler(
|
||||
RecyclableMemoryStreamManager recyclableMemoryStreamManager,
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ILocalFileSystem localFileSystem)
|
||||
{
|
||||
_recyclableMemoryStreamManager = recyclableMemoryStreamManager;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_localFileSystem = localFileSystem;
|
||||
}
|
||||
|
||||
public async Task Handle(RefreshChannelList request, CancellationToken cancellationToken)
|
||||
{
|
||||
_localFileSystem.EnsureFolderExists(FileSystemLayout.ChannelGuideCacheFolder);
|
||||
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
using MemoryStream ms = _recyclableMemoryStreamManager.GetStream();
|
||||
await using var xml = XmlWriter.Create(
|
||||
ms,
|
||||
new XmlWriterSettings { Async = true, ConformanceLevel = ConformanceLevel.Fragment });
|
||||
|
||||
await foreach (ChannelResult channel in GetChannels(dbContext).WithCancellation(cancellationToken))
|
||||
{
|
||||
await xml.WriteStartElementAsync(null, "channel", null);
|
||||
await xml.WriteAttributeStringAsync(null, "id", null, $"{channel.Number}.etv");
|
||||
|
||||
await xml.WriteStartElementAsync(null, "display-name", null);
|
||||
await xml.WriteStringAsync($"{channel.Number} {channel.Name}");
|
||||
await xml.WriteEndElementAsync(); // display-name (number and name)
|
||||
|
||||
await xml.WriteStartElementAsync(null, "display-name", null);
|
||||
await xml.WriteStringAsync(channel.Number);
|
||||
await xml.WriteEndElementAsync(); // display-name (number)
|
||||
|
||||
await xml.WriteStartElementAsync(null, "display-name", null);
|
||||
await xml.WriteStringAsync(channel.Name);
|
||||
await xml.WriteEndElementAsync(); // display-name (name)
|
||||
|
||||
foreach (string category in GetCategories(channel.Categories))
|
||||
{
|
||||
await xml.WriteStartElementAsync(null, "category", null);
|
||||
await xml.WriteAttributeStringAsync(null, "lang", null, "en");
|
||||
await xml.WriteStringAsync(category);
|
||||
await xml.WriteEndElementAsync(); // category
|
||||
}
|
||||
|
||||
await xml.WriteStartElementAsync(null, "icon", null);
|
||||
await xml.WriteAttributeStringAsync(null, "src", null, GetIconUrl(channel));
|
||||
await xml.WriteEndElementAsync(); // icon
|
||||
|
||||
await xml.WriteEndElementAsync(); // channel
|
||||
}
|
||||
|
||||
await xml.FlushAsync();
|
||||
|
||||
string tempFile = Path.GetTempFileName();
|
||||
await File.WriteAllBytesAsync(tempFile, ms.ToArray(), cancellationToken);
|
||||
|
||||
string targetFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, "channels.xml");
|
||||
File.Move(tempFile, targetFile, true);
|
||||
}
|
||||
|
||||
private static async IAsyncEnumerable<ChannelResult> GetChannels(TvContext dbContext)
|
||||
{
|
||||
const string QUERY = @"select C.Number, C.Name, C.Categories, A.Path as ArtworkPath
|
||||
from Channel C
|
||||
left outer join Artwork A on C.Id = A.ChannelId and A.ArtworkKind = 2
|
||||
where C.Id in (select ChannelId from Playout)
|
||||
order by CAST(C.Number as real)";
|
||||
|
||||
await using var reader = (DbDataReader)await dbContext.Connection.ExecuteReaderAsync(QUERY);
|
||||
Func<IDataReader, ChannelResult> rowParser = reader.GetRowParser<ChannelResult>();
|
||||
|
||||
while (await reader.ReadAsync()) {
|
||||
yield return rowParser(reader);
|
||||
}
|
||||
|
||||
while (await reader.NextResultAsync()) {}
|
||||
}
|
||||
|
||||
private static List<string> GetCategories(string categories) =>
|
||||
(categories ?? string.Empty).Split(',')
|
||||
.Map(s => s.Trim())
|
||||
.Filter(s => !string.IsNullOrWhiteSpace(s))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
private static string GetIconUrl(ChannelResult channel) =>
|
||||
string.IsNullOrWhiteSpace(channel.ArtworkPath)
|
||||
? "{RequestBase}/iptv/images/ersatztv-500.png{AccessTokenUri}"
|
||||
: $"{{RequestBase}}/iptv/logos/{channel.ArtworkPath}.jpg{{AccessTokenUri}}";
|
||||
|
||||
// ReSharper disable once ClassNeverInstantiated.Local
|
||||
private record ChannelResult(string Number, string Name, string Categories, string ArtworkPath);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -86,10 +86,20 @@ public class GetChannelFramerateHandler : IRequestHandler<GetChannelFramerate, O
|
||||
return result;
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"All content on channel {ChannelNumber} has the same frame rate of {FrameRate}; will not normalize",
|
||||
request.ChannelNumber,
|
||||
distinct[0]);
|
||||
if (distinct.Any())
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"All content on channel {ChannelNumber} has the same frame rate of {FrameRate}; will not normalize",
|
||||
request.ChannelNumber,
|
||||
distinct[0]);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"No content on channel {ChannelNumber} has frame rate information; will not normalize",
|
||||
request.ChannelNumber);
|
||||
}
|
||||
|
||||
return None;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using ErsatzTV.Core.Iptv;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Iptv;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public record GetChannelGuide(string Scheme, string Host, string BaseUrl) : IRequest<ChannelGuide>;
|
||||
public record GetChannelGuide
|
||||
(string Scheme, string Host, string BaseUrl, string AccessToken) : IRequest<Either<BaseError, ChannelGuide>>;
|
||||
|
||||
@@ -1,29 +1,72 @@
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using System.Text;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Iptv;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.IO;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class GetChannelGuideHandler : IRequestHandler<GetChannelGuide, ChannelGuide>
|
||||
public class GetChannelGuideHandler : IRequestHandler<GetChannelGuide, Either<BaseError, ChannelGuide>>
|
||||
{
|
||||
private readonly IChannelRepository _channelRepository;
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly RecyclableMemoryStreamManager _recyclableMemoryStreamManager;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
|
||||
public GetChannelGuideHandler(
|
||||
IChannelRepository channelRepository,
|
||||
RecyclableMemoryStreamManager recyclableMemoryStreamManager)
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
RecyclableMemoryStreamManager recyclableMemoryStreamManager,
|
||||
ILocalFileSystem localFileSystem)
|
||||
{
|
||||
_channelRepository = channelRepository;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_recyclableMemoryStreamManager = recyclableMemoryStreamManager;
|
||||
_localFileSystem = localFileSystem;
|
||||
}
|
||||
|
||||
public Task<ChannelGuide> Handle(GetChannelGuide request, CancellationToken cancellationToken) =>
|
||||
_channelRepository.GetAllForGuide()
|
||||
.Map(
|
||||
channels => new ChannelGuide(
|
||||
_recyclableMemoryStreamManager,
|
||||
request.Scheme,
|
||||
request.Host,
|
||||
request.BaseUrl,
|
||||
channels));
|
||||
public async Task<Either<BaseError, ChannelGuide>> Handle(
|
||||
GetChannelGuide request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
string channelsFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, "channels.xml");
|
||||
if (!_localFileSystem.FileExists(channelsFile))
|
||||
{
|
||||
return BaseError.New($"Required file {channelsFile} is missing");
|
||||
}
|
||||
|
||||
string accessTokenUri = string.Empty;
|
||||
if (!string.IsNullOrWhiteSpace(request.AccessToken))
|
||||
{
|
||||
accessTokenUri = $"?access_token={request.AccessToken}";
|
||||
}
|
||||
|
||||
string channelsFragment = await File.ReadAllTextAsync(channelsFile, Encoding.UTF8, cancellationToken);
|
||||
|
||||
// TODO: is regex faster?
|
||||
channelsFragment = channelsFragment
|
||||
.Replace("{RequestBase}", $"{request.Scheme}://{request.Host}{request.BaseUrl}")
|
||||
.Replace("{AccessTokenUri}", accessTokenUri);
|
||||
|
||||
var channelDataFragments = new Dictionary<string, string>();
|
||||
|
||||
foreach (string fileName in _localFileSystem.ListFiles(FileSystemLayout.ChannelGuideCacheFolder))
|
||||
{
|
||||
if (fileName.Contains("channels"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string channelDataFragment = await File.ReadAllTextAsync(fileName, Encoding.UTF8, cancellationToken);
|
||||
|
||||
channelDataFragment = channelDataFragment
|
||||
.Replace("{RequestBase}", $"{request.Scheme}://{request.Host}{request.BaseUrl}")
|
||||
.Replace("{AccessTokenUri}", accessTokenUri);
|
||||
|
||||
channelDataFragments.Add(Path.GetFileNameWithoutExtension(fileName), channelDataFragment);
|
||||
}
|
||||
|
||||
return new ChannelGuide(_recyclableMemoryStreamManager, channelsFragment, channelDataFragments);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public record GetChannelNameByPlayoutId(int PlayoutId) : IRequest<Option<string>>;
|
||||
@@ -0,0 +1,24 @@
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class GetChannelNameByPlayoutIdHandler : IRequestHandler<GetChannelNameByPlayoutId, Option<string>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
|
||||
public GetChannelNameByPlayoutIdHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
}
|
||||
|
||||
public async Task<Option<string>> Handle(GetChannelNameByPlayoutId request, CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
return await dbContext.Playouts
|
||||
.Include(p => p.Channel)
|
||||
.SelectOneAsync(p => p.Id, p => p.Id == request.PlayoutId)
|
||||
.MapT(p => p.Channel.Name);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
using ErsatzTV.Core.Iptv;
|
||||
using ErsatzTV.Core.Iptv;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public record GetChannelPlaylist(string Scheme, string Host, string BaseUrl, string Mode) : IRequest<ChannelPlaylist>;
|
||||
public record GetChannelPlaylist(string Scheme, string Host, string BaseUrl, string Mode, string AccessToken) : IRequest<ChannelPlaylist>;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Iptv;
|
||||
|
||||
@@ -14,7 +14,7 @@ public class GetChannelPlaylistHandler : IRequestHandler<GetChannelPlaylist, Cha
|
||||
public Task<ChannelPlaylist> Handle(GetChannelPlaylist request, CancellationToken cancellationToken) =>
|
||||
_channelRepository.GetAll()
|
||||
.Map(channels => EnsureMode(channels, request.Mode))
|
||||
.Map(channels => new ChannelPlaylist(request.Scheme, request.Host, request.BaseUrl, channels));
|
||||
.Map(channels => new ChannelPlaylist(request.Scheme, request.Host, request.BaseUrl, channels, request.AccessToken));
|
||||
|
||||
private static List<Channel> EnsureMode(IEnumerable<Channel> channels, string mode)
|
||||
{
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public record SaveConfigElementByKey(ConfigElementKey Key, string Value) : IRequest<Unit>;
|
||||
public record SaveConfigElementByKey(ConfigElementKey Key, string Value) : IRequest;
|
||||
|
||||
@@ -2,16 +2,15 @@
|
||||
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public class SaveConfigElementByKeyHandler : IRequestHandler<SaveConfigElementByKey, Unit>
|
||||
public class SaveConfigElementByKeyHandler : IRequestHandler<SaveConfigElementByKey>
|
||||
{
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
|
||||
public SaveConfigElementByKeyHandler(IConfigElementRepository configElementRepository) =>
|
||||
_configElementRepository = configElementRepository;
|
||||
|
||||
public async Task<Unit> Handle(SaveConfigElementByKey request, CancellationToken cancellationToken)
|
||||
public async Task Handle(SaveConfigElementByKey request, CancellationToken cancellationToken)
|
||||
{
|
||||
await _configElementRepository.Upsert(request.Key, request.Value);
|
||||
return Unit.Default;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
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 CallEmbyCollectionScannerHandler : CallLibraryScannerHandler<SynchronizeEmbyCollections>,
|
||||
IRequestHandler<SynchronizeEmbyCollections, Either<BaseError, Unit>>
|
||||
{
|
||||
public CallEmbyCollectionScannerHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IConfigElementRepository configElementRepository,
|
||||
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel,
|
||||
IMediator mediator,
|
||||
IRuntimeInfo runtimeInfo) : base(dbContextFactory, configElementRepository, channel, mediator, runtimeInfo)
|
||||
{
|
||||
}
|
||||
|
||||
protected override async Task<DateTimeOffset> GetLastScan(TvContext dbContext, SynchronizeEmbyCollections request)
|
||||
{
|
||||
DateTime minDateTime = await dbContext.EmbyMediaSources
|
||||
.SelectOneAsync(l => l.Id, l => l.Id == request.EmbyMediaSourceId)
|
||||
.Match(l => l.LastCollectionsScan ?? SystemTime.MinValueUtc, () => SystemTime.MaxValueUtc);
|
||||
|
||||
return new DateTimeOffset(minDateTime, TimeSpan.Zero);
|
||||
}
|
||||
|
||||
protected override bool ScanIsRequired(
|
||||
DateTimeOffset lastScan,
|
||||
int libraryRefreshInterval,
|
||||
SynchronizeEmbyCollections request)
|
||||
{
|
||||
if (lastScan == SystemTime.MaxValueUtc)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(libraryRefreshInterval);
|
||||
return request.ForceScan || (libraryRefreshInterval > 0 && nextScan < DateTimeOffset.Now);
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Unit>>
|
||||
Handle(SynchronizeEmbyCollections request, CancellationToken cancellationToken)
|
||||
{
|
||||
Validation<BaseError, string> validation = await Validate(request);
|
||||
return await validation.Match(
|
||||
scanner => PerformScan(scanner, request, cancellationToken),
|
||||
error =>
|
||||
{
|
||||
foreach (ScanIsNotRequired scanIsNotRequired in error.OfType<ScanIsNotRequired>())
|
||||
{
|
||||
return Task.FromResult<Either<BaseError, Unit>>(scanIsNotRequired);
|
||||
}
|
||||
|
||||
return Task.FromResult<Either<BaseError, Unit>>(error.Join());
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, Unit>> PerformScan(
|
||||
string scanner,
|
||||
SynchronizeEmbyCollections request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var arguments = new List<string>
|
||||
{
|
||||
"scan-emby-collections", request.EmbyMediaSourceId.ToString()
|
||||
};
|
||||
|
||||
if (request.ForceScan)
|
||||
{
|
||||
arguments.Add("--force");
|
||||
}
|
||||
|
||||
return await base.PerformScan(scanner, arguments, cancellationToken).MapT(_ => Unit.Default);
|
||||
}
|
||||
}
|
||||
@@ -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,36 @@ 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)
|
||||
{
|
||||
DateTime minDateTime = await dbContext.EmbyLibraries
|
||||
.SelectOneAsync(l => l.Id, l => l.Id == request.EmbyLibraryId)
|
||||
.Match(l => l.LastScan ?? SystemTime.MinValueUtc, () => SystemTime.MaxValueUtc);
|
||||
|
||||
return new DateTimeOffset(minDateTime, TimeSpan.Zero);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Emby;
|
||||
|
||||
public record SynchronizeEmbyCollections(int EmbyMediaSourceId, bool ForceScan) : IRequest<Either<BaseError, Unit>>,
|
||||
IScannerBackgroundServiceRequest;
|
||||
@@ -3,4 +3,4 @@
|
||||
namespace ErsatzTV.Application.Emby;
|
||||
|
||||
public record SynchronizeEmbyLibraries(int EmbyMediaSourceId) : IRequest<Either<BaseError, Unit>>,
|
||||
IEmbyBackgroundServiceRequest;
|
||||
IScannerBackgroundServiceRequest;
|
||||
|
||||
@@ -2,19 +2,20 @@
|
||||
|
||||
namespace ErsatzTV.Application.Emby;
|
||||
|
||||
public interface ISynchronizeEmbyLibraryById : IRequest<Either<BaseError, string>>,
|
||||
IEmbyBackgroundServiceRequest
|
||||
public interface ISynchronizeEmbyLibraryById : IRequest<Either<BaseError, string>>, IScannerBackgroundServiceRequest
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -8,15 +8,15 @@ namespace ErsatzTV.Application.Emby;
|
||||
public class SynchronizeEmbyMediaSourcesHandler : IRequestHandler<SynchronizeEmbyMediaSources,
|
||||
Either<BaseError, List<EmbyMediaSource>>>
|
||||
{
|
||||
private readonly ChannelWriter<IEmbyBackgroundServiceRequest> _channel;
|
||||
private readonly ChannelWriter<IScannerBackgroundServiceRequest> _scannerWorkerChannel;
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
|
||||
public SynchronizeEmbyMediaSourcesHandler(
|
||||
IMediaSourceRepository mediaSourceRepository,
|
||||
ChannelWriter<IEmbyBackgroundServiceRequest> channel)
|
||||
ChannelWriter<IScannerBackgroundServiceRequest> scannerWorkerChannel)
|
||||
{
|
||||
_mediaSourceRepository = mediaSourceRepository;
|
||||
_channel = channel;
|
||||
_scannerWorkerChannel = scannerWorkerChannel;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, List<EmbyMediaSource>>> Handle(
|
||||
@@ -27,7 +27,7 @@ public class SynchronizeEmbyMediaSourcesHandler : IRequestHandler<SynchronizeEmb
|
||||
foreach (EmbyMediaSource mediaSource in mediaSources)
|
||||
{
|
||||
// await _channel.WriteAsync(new SynchronizeEmbyAdminUserId(mediaSource.Id), cancellationToken);
|
||||
await _channel.WriteAsync(new SynchronizeEmbyLibraries(mediaSource.Id), cancellationToken);
|
||||
await _scannerWorkerChannel.WriteAsync(new SynchronizeEmbyLibraries(mediaSource.Id), cancellationToken);
|
||||
}
|
||||
|
||||
return mediaSources;
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
namespace ErsatzTV.Application.Emby;
|
||||
|
||||
public record EmbyConnectionParametersViewModel(string Address);
|
||||
public record EmbyConnectionParametersViewModel(string Address, string ApiKey);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Emby;
|
||||
using ErsatzTV.Core.Interfaces.Emby;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
|
||||
@@ -9,14 +11,17 @@ public class GetEmbyConnectionParametersHandler : IRequestHandler<GetEmbyConnect
|
||||
Either<BaseError, EmbyConnectionParametersViewModel>>
|
||||
{
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
private readonly IEmbySecretStore _embySecretStore;
|
||||
private readonly IMemoryCache _memoryCache;
|
||||
|
||||
public GetEmbyConnectionParametersHandler(
|
||||
IMemoryCache memoryCache,
|
||||
IMediaSourceRepository mediaSourceRepository)
|
||||
IMediaSourceRepository mediaSourceRepository,
|
||||
IEmbySecretStore embySecretStore)
|
||||
{
|
||||
_memoryCache = memoryCache;
|
||||
_mediaSourceRepository = mediaSourceRepository;
|
||||
_embySecretStore = embySecretStore;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, EmbyConnectionParametersViewModel>> Handle(
|
||||
@@ -30,7 +35,7 @@ public class GetEmbyConnectionParametersHandler : IRequestHandler<GetEmbyConnect
|
||||
|
||||
Either<BaseError, EmbyConnectionParametersViewModel> maybeParameters =
|
||||
await Validate()
|
||||
.MapT(cp => new EmbyConnectionParametersViewModel(cp.ActiveConnection.Address))
|
||||
.MapT(cp => new EmbyConnectionParametersViewModel(cp.ActiveConnection.Address, cp.ApiKey))
|
||||
.Map(v => v.ToEither<EmbyConnectionParametersViewModel>());
|
||||
|
||||
return maybeParameters.Match(
|
||||
@@ -44,7 +49,8 @@ public class GetEmbyConnectionParametersHandler : IRequestHandler<GetEmbyConnect
|
||||
|
||||
private Task<Validation<BaseError, ConnectionParameters>> Validate() =>
|
||||
EmbyMediaSourceMustExist()
|
||||
.BindT(MediaSourceMustHaveActiveConnection);
|
||||
.BindT(MediaSourceMustHaveActiveConnection)
|
||||
.BindT(MediaSourceMustHaveApiKey);
|
||||
|
||||
private Task<Validation<BaseError, EmbyMediaSource>> EmbyMediaSourceMustExist() =>
|
||||
_mediaSourceRepository.GetAllEmby().Map(list => list.HeadOrNone())
|
||||
@@ -59,8 +65,21 @@ public class GetEmbyConnectionParametersHandler : IRequestHandler<GetEmbyConnect
|
||||
return maybeConnection.Map(connection => new ConnectionParameters(embyMediaSource, connection))
|
||||
.ToValidation<BaseError>("Emby media source requires an active connection");
|
||||
}
|
||||
|
||||
private async Task<Validation<BaseError, ConnectionParameters>> MediaSourceMustHaveApiKey(
|
||||
ConnectionParameters connectionParameters)
|
||||
{
|
||||
EmbySecrets secrets = await _embySecretStore.ReadSecrets();
|
||||
return Optional(secrets.Address == connectionParameters.ActiveConnection.Address)
|
||||
.Where(match => match)
|
||||
.Map(_ => connectionParameters with { ApiKey = secrets.ApiKey })
|
||||
.ToValidation<BaseError>("Emby media source requires an api key");
|
||||
}
|
||||
|
||||
private record ConnectionParameters(
|
||||
EmbyMediaSource EmbyMediaSource,
|
||||
EmbyConnection ActiveConnection);
|
||||
EmbyConnection ActiveConnection)
|
||||
{
|
||||
public string ApiKey { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,13 +10,13 @@
|
||||
<PackageReference Include="Bugsnag" Version="3.1.0" />
|
||||
<PackageReference Include="CliWrap" Version="3.6.0" />
|
||||
<PackageReference Include="Humanizer.Core" Version="2.14.1" />
|
||||
<PackageReference Include="MediatR" Version="11.1.0" />
|
||||
<PackageReference Include="MediatR" Version="12.0.1" />
|
||||
<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>
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.2" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
<PackageReference Include="Serilog.Formatting.Compact.Reader" Version="2.0.0" />
|
||||
<PackageReference Include="Winista.MimeDetect" Version="1.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -36,7 +36,12 @@ public class
|
||||
p.QsvExtraHardwareFrames = update.QsvExtraHardwareFrames;
|
||||
p.ResolutionId = update.ResolutionId;
|
||||
p.VideoFormat = update.VideoFormat;
|
||||
p.BitDepth = update.BitDepth;
|
||||
|
||||
// mpeg2video only supports 8-bit content
|
||||
p.BitDepth = update.VideoFormat == FFmpegProfileVideoFormat.Mpeg2Video
|
||||
? FFmpegProfileBitDepth.EightBit
|
||||
: update.BitDepth;
|
||||
|
||||
p.VideoBitrate = update.VideoBitrate;
|
||||
p.VideoBufferSize = update.VideoBufferSize;
|
||||
p.AudioFormat = update.AudioFormat;
|
||||
|
||||
@@ -52,7 +52,7 @@ public class UpdateFFmpegSettingsHandler : IRequestHandler<UpdateFFmpegSettings,
|
||||
UseShellExecute = false
|
||||
};
|
||||
|
||||
var test = new Process
|
||||
using var test = new Process
|
||||
{
|
||||
StartInfo = startInfo
|
||||
};
|
||||
|
||||
5
ErsatzTV.Application/IScannerBackgroundServiceRequest.cs
Normal file
5
ErsatzTV.Application/IScannerBackgroundServiceRequest.cs
Normal file
@@ -0,0 +1,5 @@
|
||||
namespace ErsatzTV.Application;
|
||||
|
||||
public interface IScannerBackgroundServiceRequest
|
||||
{
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
namespace ErsatzTV.Application;
|
||||
|
||||
public interface ISubtitleWorkerRequest
|
||||
{
|
||||
}
|
||||
@@ -1,19 +1,26 @@
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Libraries;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.FFmpeg.Runtime;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Jellyfin;
|
||||
|
||||
public class CallJellyfinLibraryScannerHandler : CallLibraryScannerHandler,
|
||||
public class CallJellyfinLibraryScannerHandler : CallLibraryScannerHandler<ISynchronizeJellyfinLibraryById>,
|
||||
IRequestHandler<ForceSynchronizeJellyfinLibraryById, Either<BaseError, string>>,
|
||||
IRequestHandler<SynchronizeJellyfinLibraryByIdIfNeeded, Either<BaseError, string>>
|
||||
{
|
||||
public CallJellyfinLibraryScannerHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IConfigElementRepository configElementRepository,
|
||||
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel,
|
||||
IMediator mediator,
|
||||
IRuntimeInfo runtimeInfo)
|
||||
: base(channel, mediator, runtimeInfo)
|
||||
: base(dbContextFactory, configElementRepository, channel, mediator, runtimeInfo)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -29,10 +36,18 @@ public class CallJellyfinLibraryScannerHandler : CallLibraryScannerHandler,
|
||||
ISynchronizeJellyfinLibraryById request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Validation<BaseError, string> validation = Validate();
|
||||
Validation<BaseError, string> validation = await Validate(request);
|
||||
return await validation.Match(
|
||||
scanner => PerformScan(scanner, request, cancellationToken),
|
||||
error => Task.FromResult<Either<BaseError, string>>(error.Join()));
|
||||
error =>
|
||||
{
|
||||
foreach (ScanIsNotRequired scanIsNotRequired in error.OfType<ScanIsNotRequired>())
|
||||
{
|
||||
return Task.FromResult<Either<BaseError, string>>(scanIsNotRequired);
|
||||
}
|
||||
|
||||
return Task.FromResult<Either<BaseError, string>>(error.Join());
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, string>> PerformScan(
|
||||
@@ -49,7 +64,37 @@ 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)
|
||||
{
|
||||
DateTime minDateTime = await dbContext.JellyfinLibraries
|
||||
.SelectOneAsync(l => l.Id, l => l.Id == request.JellyfinLibraryId)
|
||||
.Match(l => l.LastScan ?? SystemTime.MinValueUtc, () => SystemTime.MaxValueUtc);
|
||||
|
||||
return new DateTimeOffset(minDateTime, TimeSpan.Zero);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
namespace ErsatzTV.Application.Jellyfin;
|
||||
|
||||
public record SynchronizeJellyfinAdminUserId(int JellyfinMediaSourceId) : IRequest<Either<BaseError, Unit>>,
|
||||
IJellyfinBackgroundServiceRequest;
|
||||
IScannerBackgroundServiceRequest;
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
namespace ErsatzTV.Application.Jellyfin;
|
||||
|
||||
public record SynchronizeJellyfinLibraries(int JellyfinMediaSourceId) : IRequest<Either<BaseError, Unit>>,
|
||||
IJellyfinBackgroundServiceRequest;
|
||||
IScannerBackgroundServiceRequest;
|
||||
|
||||
@@ -3,18 +3,21 @@
|
||||
namespace ErsatzTV.Application.Jellyfin;
|
||||
|
||||
public interface ISynchronizeJellyfinLibraryById : IRequest<Either<BaseError, string>>,
|
||||
IJellyfinBackgroundServiceRequest
|
||||
IScannerBackgroundServiceRequest
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -8,15 +8,15 @@ namespace ErsatzTV.Application.Jellyfin;
|
||||
public class SynchronizeJellyfinMediaSourcesHandler : IRequestHandler<SynchronizeJellyfinMediaSources,
|
||||
Either<BaseError, List<JellyfinMediaSource>>>
|
||||
{
|
||||
private readonly ChannelWriter<IJellyfinBackgroundServiceRequest> _channel;
|
||||
private readonly ChannelWriter<IScannerBackgroundServiceRequest> _scannerWorkerChannel;
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
|
||||
public SynchronizeJellyfinMediaSourcesHandler(
|
||||
IMediaSourceRepository mediaSourceRepository,
|
||||
ChannelWriter<IJellyfinBackgroundServiceRequest> channel)
|
||||
ChannelWriter<IScannerBackgroundServiceRequest> scannerWorkerChannel)
|
||||
{
|
||||
_mediaSourceRepository = mediaSourceRepository;
|
||||
_channel = channel;
|
||||
_scannerWorkerChannel = scannerWorkerChannel;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, List<JellyfinMediaSource>>> Handle(
|
||||
@@ -26,8 +26,8 @@ public class SynchronizeJellyfinMediaSourcesHandler : IRequestHandler<Synchroniz
|
||||
List<JellyfinMediaSource> mediaSources = await _mediaSourceRepository.GetAllJellyfin();
|
||||
foreach (JellyfinMediaSource mediaSource in mediaSources)
|
||||
{
|
||||
await _channel.WriteAsync(new SynchronizeJellyfinAdminUserId(mediaSource.Id), cancellationToken);
|
||||
await _channel.WriteAsync(new SynchronizeJellyfinLibraries(mediaSource.Id), cancellationToken);
|
||||
await _scannerWorkerChannel.WriteAsync(new SynchronizeJellyfinAdminUserId(mediaSource.Id), cancellationToken);
|
||||
await _scannerWorkerChannel.WriteAsync(new SynchronizeJellyfinLibraries(mediaSource.Id), cancellationToken);
|
||||
}
|
||||
|
||||
return mediaSources;
|
||||
|
||||
@@ -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;
|
||||
@@ -55,7 +66,7 @@ public abstract class CallLibraryScannerHandler
|
||||
return BaseError.New($"ErsatzTV.Scanner exited with code {process.ExitCode}");
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -14,14 +14,14 @@ public class CreateLocalLibraryHandler : LocalLibraryHandlerBase,
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly IEntityLocker _entityLocker;
|
||||
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
|
||||
private readonly ChannelWriter<IScannerBackgroundServiceRequest> _scannerWorkerChannel;
|
||||
|
||||
public CreateLocalLibraryHandler(
|
||||
ChannelWriter<IBackgroundServiceRequest> workerChannel,
|
||||
ChannelWriter<IScannerBackgroundServiceRequest> scannerWorkerChannel,
|
||||
IEntityLocker entityLocker,
|
||||
IDbContextFactory<TvContext> dbContextFactory)
|
||||
{
|
||||
_workerChannel = workerChannel;
|
||||
_scannerWorkerChannel = scannerWorkerChannel;
|
||||
_entityLocker = entityLocker;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
}
|
||||
@@ -44,7 +44,7 @@ public class CreateLocalLibraryHandler : LocalLibraryHandlerBase,
|
||||
|
||||
if (_entityLocker.LockLibrary(localLibrary.Id))
|
||||
{
|
||||
await _workerChannel.WriteAsync(new ForceScanLocalLibrary(localLibrary.Id));
|
||||
await _scannerWorkerChannel.WriteAsync(new ForceScanLocalLibrary(localLibrary.Id));
|
||||
}
|
||||
|
||||
return ProjectToViewModel(localLibrary);
|
||||
|
||||
@@ -17,15 +17,15 @@ public class UpdateLocalLibraryHandler : LocalLibraryHandlerBase,
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly IEntityLocker _entityLocker;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
|
||||
private readonly ChannelWriter<IScannerBackgroundServiceRequest> _scannerWorkerChannel;
|
||||
|
||||
public UpdateLocalLibraryHandler(
|
||||
ChannelWriter<IBackgroundServiceRequest> workerChannel,
|
||||
ChannelWriter<IScannerBackgroundServiceRequest> scannerWorkerChannel,
|
||||
IEntityLocker entityLocker,
|
||||
ISearchIndex searchIndex,
|
||||
IDbContextFactory<TvContext> dbContextFactory)
|
||||
{
|
||||
_workerChannel = workerChannel;
|
||||
_scannerWorkerChannel = scannerWorkerChannel;
|
||||
_entityLocker = entityLocker;
|
||||
_searchIndex = searchIndex;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
@@ -70,7 +70,7 @@ public class UpdateLocalLibraryHandler : LocalLibraryHandlerBase,
|
||||
|
||||
if ((toAdd.Count > 0 || toRemove.Count > 0) && _entityLocker.LockLibrary(existing.Id))
|
||||
{
|
||||
await _workerChannel.WriteAsync(new ForceScanLocalLibrary(existing.Id));
|
||||
await _scannerWorkerChannel.WriteAsync(new ForceScanLocalLibrary(existing.Id));
|
||||
}
|
||||
|
||||
return ProjectToViewModel(existing);
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.Libraries;
|
||||
|
||||
public record GetExternalCollections : IRequest<List<LibraryViewModel>>;
|
||||
@@ -0,0 +1,35 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Libraries;
|
||||
|
||||
public class GetExternalCollectionsHandler : IRequestHandler<GetExternalCollections, List<LibraryViewModel>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
|
||||
public GetExternalCollectionsHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
}
|
||||
|
||||
public async Task<List<LibraryViewModel>> Handle(
|
||||
GetExternalCollections request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
List<int> mediaSourceIds = await dbContext.EmbyMediaSources
|
||||
.Filter(ems => ems.Libraries.Any(l => ((EmbyLibrary)l).ShouldSyncItems))
|
||||
.Map(ems => ems.Id)
|
||||
.ToListAsync(cancellationToken: cancellationToken);
|
||||
|
||||
return mediaSourceIds.Map(
|
||||
id => new LibraryViewModel(
|
||||
"Emby",
|
||||
0,
|
||||
"Collections",
|
||||
0,
|
||||
id))
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
8
ErsatzTV.Application/Logs/LogEntryViewModel.cs
Normal file
8
ErsatzTV.Application/Logs/LogEntryViewModel.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
using Serilog.Events;
|
||||
|
||||
namespace ErsatzTV.Application.Logs;
|
||||
|
||||
public record LogEntryViewModel(
|
||||
DateTimeOffset Timestamp,
|
||||
LogEventLevel Level,
|
||||
string Message);
|
||||
31
ErsatzTV.Application/Logs/Mapper.cs
Normal file
31
ErsatzTV.Application/Logs/Mapper.cs
Normal file
@@ -0,0 +1,31 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using Serilog.Events;
|
||||
|
||||
namespace ErsatzTV.Application.Logs;
|
||||
|
||||
internal partial class Mapper
|
||||
{
|
||||
[GeneratedRegex(@"(.*)\[(DBG|INF|WRN|ERR|FTL)\](.*)")]
|
||||
private static partial Regex LogEntryRegex();
|
||||
|
||||
internal static Option<LogEntryViewModel> ProjectToViewModel(string line)
|
||||
{
|
||||
Match match = LogEntryRegex().Match(line);
|
||||
if (!match.Success)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
var timestamp = DateTimeOffset.Parse(match.Groups[1].Value);
|
||||
LogEventLevel level = match.Groups[2].Value switch
|
||||
{
|
||||
"FTL" => LogEventLevel.Fatal,
|
||||
"ERR" => LogEventLevel.Error,
|
||||
"WRN" => LogEventLevel.Warning,
|
||||
"INF" => LogEventLevel.Information,
|
||||
_ => LogEventLevel.Debug
|
||||
};
|
||||
|
||||
return new LogEntryViewModel(timestamp, level, match.Groups[3].Value);
|
||||
}
|
||||
}
|
||||
3
ErsatzTV.Application/Logs/PagedLogEntriesViewModel.cs
Normal file
3
ErsatzTV.Application/Logs/PagedLogEntriesViewModel.cs
Normal file
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.Logs;
|
||||
|
||||
public record PagedLogEntriesViewModel(int TotalCount, List<LogEntryViewModel> Page);
|
||||
9
ErsatzTV.Application/Logs/Queries/GetRecentLogEntries.cs
Normal file
9
ErsatzTV.Application/Logs/Queries/GetRecentLogEntries.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
using System.Linq.Expressions;
|
||||
|
||||
namespace ErsatzTV.Application.Logs;
|
||||
|
||||
public record GetRecentLogEntries(int PageNum, int PageSize, string Filter) : IRequest<PagedLogEntriesViewModel>
|
||||
{
|
||||
public Expression<Func<LogEntryViewModel, object>> SortExpression { get; init; }
|
||||
public Option<bool> SortDescending { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using static ErsatzTV.Application.Logs.Mapper;
|
||||
|
||||
namespace ErsatzTV.Application.Logs;
|
||||
|
||||
public class GetRecentLogEntriesHandler : IRequestHandler<GetRecentLogEntries, PagedLogEntriesViewModel>
|
||||
{
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
|
||||
public GetRecentLogEntriesHandler(ILocalFileSystem localFileSystem)
|
||||
{
|
||||
_localFileSystem = localFileSystem;
|
||||
}
|
||||
|
||||
public Task<PagedLogEntriesViewModel> Handle(
|
||||
GetRecentLogEntries request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// get most recent file
|
||||
string logFileName = _localFileSystem.ListFiles(FileSystemLayout.LogsFolder)
|
||||
.OrderDescending()
|
||||
.FirstOrDefault();
|
||||
|
||||
if (logFileName is not null)
|
||||
{
|
||||
IQueryable<LogEntryViewModel> entries = ReadFrom(logFileName)
|
||||
.Bind(line => ProjectToViewModel(line))
|
||||
.AsQueryable();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Filter))
|
||||
{
|
||||
entries = entries.Filter(
|
||||
le => le.Level.ToString().Contains(request.Filter, StringComparison.OrdinalIgnoreCase) ||
|
||||
le.Message.Contains(request.Filter, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
int count = entries.Count();
|
||||
|
||||
IOrderedQueryable<LogEntryViewModel> ordered = request.SortDescending.Match(
|
||||
descending => descending
|
||||
? entries.OrderByDescending(request.SortExpression).ThenByDescending(le => le.Timestamp)
|
||||
: entries.OrderBy(request.SortExpression).ThenByDescending(le => le.Timestamp),
|
||||
() => entries.OrderByDescending(le => le.Timestamp));
|
||||
|
||||
var page = ordered
|
||||
.Skip(request.PageNum * request.PageSize)
|
||||
.Take(request.PageSize)
|
||||
.ToList();
|
||||
|
||||
return new PagedLogEntriesViewModel(count, page).AsTask();
|
||||
}
|
||||
|
||||
return new PagedLogEntriesViewModel(0, new List<LogEntryViewModel>()).AsTask();
|
||||
}
|
||||
|
||||
private static IEnumerable<string> ReadFrom(string file)
|
||||
{
|
||||
using FileStream fs = File.Open(file, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
|
||||
using var reader = new StreamReader(fs);
|
||||
while (reader.ReadLine() is { } line)
|
||||
{
|
||||
yield return line;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Maintenance;
|
||||
|
||||
public record DeleteOrphanedSubtitles : IRequest<Either<BaseError, Unit>>, IBackgroundServiceRequest;
|
||||
@@ -0,0 +1,44 @@
|
||||
using Dapper;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Maintenance;
|
||||
|
||||
public class DeleteOrphanedSubtitlesHandler : IRequestHandler<DeleteOrphanedSubtitles, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
|
||||
public DeleteOrphanedSubtitlesHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Unit>> Handle(
|
||||
DeleteOrphanedSubtitles request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
IEnumerable<int> toDelete = await dbContext.Connection.QueryAsync<int>(
|
||||
@"SELECT S.Id FROM Subtitle S
|
||||
WHERE S.ArtistMetadataId IS NULL AND S.EpisodeMetadataId IS NULL
|
||||
AND S.MovieMetadataId IS NULL AND S.MusicVideoMetadataId IS NULL
|
||||
AND S.OtherVideoMetadataId IS NULL AND S.SeasonMetadataId IS NULL
|
||||
AND S.ShowMetadataId IS NULL AND s.SongMetadataId IS NULL");
|
||||
|
||||
foreach (int id in toDelete)
|
||||
{
|
||||
await dbContext.Connection.ExecuteAsync("DELETE FROM Subtitle WHERE Id = @Id", new { Id = id });
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BaseError.New(ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace ErsatzTV.Application.Maintenance;
|
||||
|
||||
public record ReleaseMemory(bool ForceAggressive) : IRequest, IBackgroundServiceRequest
|
||||
{
|
||||
public DateTimeOffset RequestTime = DateTimeOffset.Now;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Application.Maintenance;
|
||||
|
||||
public class ReleaseMemoryHandler : IRequestHandler<ReleaseMemory>
|
||||
{
|
||||
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 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.CompletedTask;
|
||||
}
|
||||
|
||||
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.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -61,7 +61,7 @@ public class AddTraktListHandler : TraktCommandBase, IRequestHandler<AddTraktLis
|
||||
|
||||
private async Task<Either<BaseError, Unit>> DoAdd(Parameters parameters)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
|
||||
return await TraktApiClient.GetUserList(parameters.User, parameters.List)
|
||||
.BindT(list => SaveList(dbContext, list))
|
||||
|
||||
@@ -87,7 +87,11 @@ public abstract class TraktCommandBase
|
||||
|
||||
foreach (TraktListItem existing in toUpdate)
|
||||
{
|
||||
Option<TraktListItem> maybeIncoming = list.Items.Find(i => i.TraktId == existing.TraktId);
|
||||
Option<TraktListItem> maybeIncoming = items
|
||||
.Filter(i => i.TraktId == existing.TraktId)
|
||||
.Map(i => ProjectItem(list, i))
|
||||
.HeadOrNone();
|
||||
|
||||
foreach (TraktListItem incoming in maybeIncoming)
|
||||
{
|
||||
existing.Kind = incoming.Kind;
|
||||
@@ -208,7 +212,7 @@ public abstract class TraktCommandBase
|
||||
|
||||
foreach (int movieId in maybeMovieByGuid)
|
||||
{
|
||||
_logger.LogDebug("Located trakt movie {Title} by id", item.DisplayTitle);
|
||||
// _logger.LogDebug("Located trakt movie {Title} by id", item.DisplayTitle);
|
||||
return movieId;
|
||||
}
|
||||
|
||||
@@ -221,11 +225,11 @@ public abstract class TraktCommandBase
|
||||
|
||||
foreach (int movieId in maybeMovieByTitleYear)
|
||||
{
|
||||
_logger.LogDebug("Located trakt movie {Title} by title/year", item.DisplayTitle);
|
||||
// _logger.LogDebug("Located trakt movie {Title} by title/year", item.DisplayTitle);
|
||||
return movieId;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Unable to locate trakt movie {Title}", item.DisplayTitle);
|
||||
// _logger.LogDebug("Unable to locate trakt movie {Title}", item.DisplayTitle);
|
||||
|
||||
return None;
|
||||
}
|
||||
@@ -243,7 +247,7 @@ public abstract class TraktCommandBase
|
||||
|
||||
foreach (int showId in maybeShowByGuid)
|
||||
{
|
||||
_logger.LogDebug("Located trakt show {Title} by id", item.DisplayTitle);
|
||||
// _logger.LogDebug("Located trakt show {Title} by id", item.DisplayTitle);
|
||||
return showId;
|
||||
}
|
||||
|
||||
@@ -256,11 +260,11 @@ public abstract class TraktCommandBase
|
||||
|
||||
foreach (int showId in maybeShowByTitleYear)
|
||||
{
|
||||
_logger.LogDebug("Located trakt show {Title} by title/year", item.Title);
|
||||
// _logger.LogDebug("Located trakt show {Title} by title/year", item.Title);
|
||||
return showId;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Unable to locate trakt show {Title}", item.DisplayTitle);
|
||||
// _logger.LogDebug("Unable to locate trakt show {Title}", item.DisplayTitle);
|
||||
|
||||
return None;
|
||||
}
|
||||
@@ -278,7 +282,7 @@ public abstract class TraktCommandBase
|
||||
|
||||
foreach (int seasonId in maybeSeasonByGuid)
|
||||
{
|
||||
_logger.LogDebug("Located trakt season {Title} by id", item.DisplayTitle);
|
||||
// _logger.LogDebug("Located trakt season {Title} by id", item.DisplayTitle);
|
||||
return seasonId;
|
||||
}
|
||||
|
||||
@@ -292,11 +296,11 @@ public abstract class TraktCommandBase
|
||||
|
||||
foreach (int seasonId in maybeSeasonByTitleYear)
|
||||
{
|
||||
_logger.LogDebug("Located trakt season {Title} by title/year/season", item.DisplayTitle);
|
||||
// _logger.LogDebug("Located trakt season {Title} by title/year/season", item.DisplayTitle);
|
||||
return seasonId;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Unable to locate trakt season {Title}", item.DisplayTitle);
|
||||
// _logger.LogDebug("Unable to locate trakt season {Title}", item.DisplayTitle);
|
||||
|
||||
return None;
|
||||
}
|
||||
@@ -314,7 +318,7 @@ public abstract class TraktCommandBase
|
||||
|
||||
foreach (int episodeId in maybeEpisodeByGuid)
|
||||
{
|
||||
_logger.LogDebug("Located trakt episode {Title} by id", item.DisplayTitle);
|
||||
// _logger.LogDebug("Located trakt episode {Title} by id", item.DisplayTitle);
|
||||
return episodeId;
|
||||
}
|
||||
|
||||
@@ -329,11 +333,11 @@ public abstract class TraktCommandBase
|
||||
|
||||
foreach (int episodeId in maybeEpisodeByTitleYear)
|
||||
{
|
||||
_logger.LogDebug("Located trakt episode {Title} by title/year/season/episode", item.DisplayTitle);
|
||||
// _logger.LogDebug("Located trakt episode {Title} by title/year/season/episode", item.DisplayTitle);
|
||||
return episodeId;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Unable to locate trakt episode {Title}", item.DisplayTitle);
|
||||
// _logger.LogDebug("Unable to locate trakt episode {Title}", item.DisplayTitle);
|
||||
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -1,19 +1,26 @@
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Libraries;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
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 +34,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 +65,31 @@ public class CallLocalLibraryScannerHandler : CallLibraryScannerHandler,
|
||||
|
||||
return await base.PerformScan(scanner, arguments, cancellationToken);
|
||||
}
|
||||
|
||||
protected override async Task<DateTimeOffset> GetLastScan(TvContext dbContext, IScanLocalLibrary request)
|
||||
{
|
||||
List<LibraryPath> libraryPaths = await dbContext.LibraryPaths
|
||||
.Filter(lp => lp.LibraryId == request.LibraryId)
|
||||
.ToListAsync();
|
||||
|
||||
DateTime 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
namespace ErsatzTV.Application.MediaSources;
|
||||
|
||||
public interface IScanLocalLibrary : IRequest<Either<BaseError, string>>, IBackgroundServiceRequest
|
||||
public interface IScanLocalLibrary : IRequest<Either<BaseError, string>>, IScannerBackgroundServiceRequest
|
||||
{
|
||||
int LibraryId { get; }
|
||||
bool ForceScan { get; }
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using System.Threading.Channels;
|
||||
using Bugsnag;
|
||||
using Dapper;
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Application.Subtitles;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
@@ -17,7 +19,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 +27,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 +58,24 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
|
||||
_ffmpegSegmenterService.PlayoutUpdated(playout.Channel.Number);
|
||||
}
|
||||
|
||||
await _ffmpegWorkerChannel.WriteAsync(new ExtractEmbeddedSubtitles(playout.Id));
|
||||
Option<string> maybeChannelNumber = await dbContext.Connection
|
||||
.QuerySingleOrDefaultAsync<string>(
|
||||
@"select C.Number from Channel C
|
||||
inner join Playout P on C.Id = P.ChannelId
|
||||
where P.Id = @PlayoutId",
|
||||
new { request.PlayoutId })
|
||||
.Map(Optional);
|
||||
|
||||
foreach (string channelNumber in maybeChannelNumber)
|
||||
{
|
||||
string fileName = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, $"{channelNumber}.xml");
|
||||
if (hasChanges || !File.Exists(fileName))
|
||||
{
|
||||
await _workerChannel.WriteAsync(new RefreshChannelData(channelNumber));
|
||||
}
|
||||
}
|
||||
|
||||
await _workerChannel.WriteAsync(new ExtractEmbeddedSubtitles(playout.Id));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -75,8 +94,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 +150,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."));
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
@@ -28,7 +29,7 @@ public class CreatePlayoutHandler : IRequestHandler<CreatePlayout, Either<BaseEr
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, Playout> validation = await Validate(dbContext, request);
|
||||
return await LanguageExtensions.Apply(validation, playout => PersistPlayout(dbContext, playout));
|
||||
return await validation.Apply(playout => PersistPlayout(dbContext, playout));
|
||||
}
|
||||
|
||||
private async Task<CreatePlayoutResponse> PersistPlayout(TvContext dbContext, Playout playout)
|
||||
@@ -36,6 +37,7 @@ public class CreatePlayoutHandler : IRequestHandler<CreatePlayout, Either<BaseEr
|
||||
await dbContext.Playouts.AddAsync(playout);
|
||||
await dbContext.SaveChangesAsync();
|
||||
await _channel.WriteAsync(new BuildPlayout(playout.Id, PlayoutBuildMode.Reset));
|
||||
await _channel.WriteAsync(new RefreshChannelList());
|
||||
return new CreatePlayoutResponse(playout.Id);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,31 +1,55 @@
|
||||
using ErsatzTV.Core;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Playouts;
|
||||
|
||||
public class DeletePlayoutHandler : IRequestHandler<DeletePlayout, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
|
||||
public DeletePlayoutHandler(IDbContextFactory<TvContext> dbContextFactory) =>
|
||||
public DeletePlayoutHandler(
|
||||
ChannelWriter<IBackgroundServiceRequest> workerChannel,
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ILocalFileSystem localFileSystem)
|
||||
{
|
||||
_workerChannel = workerChannel;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_localFileSystem = localFileSystem;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Unit>> Handle(
|
||||
DeletePlayout request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
Option<Playout> maybePlayout = await dbContext.Playouts
|
||||
.OrderBy(p => p.Id)
|
||||
.FirstOrDefaultAsync(p => p.Id == request.PlayoutId, cancellationToken);
|
||||
.AsNoTracking()
|
||||
.Include(p => p.Channel)
|
||||
.SelectOneAsync(p => p.Id, p => p.Id == request.PlayoutId);
|
||||
|
||||
foreach (Playout playout in maybePlayout)
|
||||
{
|
||||
dbContext.Playouts.Remove(playout);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// delete channel data from channel guide cache
|
||||
string cacheFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, $"{playout.Channel.Number}.xml");
|
||||
if (_localFileSystem.FileExists(cacheFile))
|
||||
{
|
||||
File.Delete(cacheFile);
|
||||
}
|
||||
|
||||
// refresh channel list to remove channel that has no playout
|
||||
await _workerChannel.WriteAsync(new RefreshChannelList(), cancellationToken);
|
||||
}
|
||||
|
||||
return maybePlayout
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace ErsatzTV.Application.Playouts;
|
||||
|
||||
public record ReplacePlayoutAlternateSchedule(
|
||||
int Id,
|
||||
int Index,
|
||||
int ProgramScheduleId,
|
||||
List<DayOfWeek> DaysOfWeek,
|
||||
List<int> DaysOfMonth,
|
||||
List<int> MonthsOfYear);
|
||||
@@ -0,0 +1,6 @@
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Playouts;
|
||||
|
||||
public record ReplacePlayoutAlternateScheduleItems
|
||||
(int PlayoutId, List<ReplacePlayoutAlternateSchedule> Items) : IRequest<Either<BaseError, Unit>>;
|
||||
@@ -0,0 +1,158 @@
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Application.Playouts;
|
||||
|
||||
public class ReplacePlayoutAlternateScheduleItemsHandler :
|
||||
IRequestHandler<ReplacePlayoutAlternateScheduleItems, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
|
||||
private readonly ILogger<ReplacePlayoutAlternateScheduleItemsHandler> _logger;
|
||||
|
||||
public ReplacePlayoutAlternateScheduleItemsHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ChannelWriter<IBackgroundServiceRequest> channel,
|
||||
ILogger<ReplacePlayoutAlternateScheduleItemsHandler> logger)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_channel = channel;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Unit>> Handle(
|
||||
ReplacePlayoutAlternateScheduleItems request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// TODO: validate that items is not empty
|
||||
|
||||
try
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
Option<Playout> maybePlayout = await dbContext.Playouts
|
||||
.Include(p => p.ProgramSchedule)
|
||||
.Include(p => p.ProgramScheduleAlternates)
|
||||
.ThenInclude(p => p.ProgramSchedule)
|
||||
.SelectOneAsync(p => p.Id, p => p.Id == request.PlayoutId);
|
||||
|
||||
foreach (Playout playout in maybePlayout)
|
||||
{
|
||||
var existingScheduleMap = new Dictionary<DateTimeOffset, ProgramSchedule>();
|
||||
var daysToCheck = new List<DateTimeOffset>();
|
||||
|
||||
Option<PlayoutItem> maybeLastPlayoutItem = await dbContext.PlayoutItems
|
||||
.Filter(pi => pi.PlayoutId == request.PlayoutId)
|
||||
.OrderByDescending(pi => pi.Start)
|
||||
.FirstOrDefaultAsync(cancellationToken)
|
||||
.Map(Optional);
|
||||
|
||||
foreach (PlayoutItem lastPlayoutItem in maybeLastPlayoutItem)
|
||||
{
|
||||
DateTimeOffset start = DateTimeOffset.Now;
|
||||
daysToCheck = Enumerable.Range(0, (lastPlayoutItem.StartOffset - start).Days + 1)
|
||||
.Select(d => start.AddDays(d))
|
||||
.ToList();
|
||||
|
||||
foreach (DateTimeOffset dayToCheck in daysToCheck)
|
||||
{
|
||||
ProgramSchedule schedule = PlayoutScheduleSelector.GetProgramScheduleFor(
|
||||
playout.ProgramSchedule,
|
||||
playout.ProgramScheduleAlternates,
|
||||
dayToCheck);
|
||||
|
||||
existingScheduleMap.Add(dayToCheck, schedule);
|
||||
}
|
||||
}
|
||||
|
||||
// exclude highest index
|
||||
int maxIndex = request.Items.Map(x => x.Index).Max();
|
||||
ReplacePlayoutAlternateSchedule highest = request.Items.First(x => x.Index == maxIndex);
|
||||
|
||||
ProgramScheduleAlternate[] existing = playout.ProgramScheduleAlternates.ToArray();
|
||||
|
||||
var incoming = request.Items.Except(new[] { highest }).ToList();
|
||||
|
||||
var toAdd = incoming.Filter(x => existing.All(e => e.Id != x.Id)).ToList();
|
||||
var toRemove = existing.Filter(e => incoming.All(m => m.Id != e.Id)).ToList();
|
||||
var toUpdate = incoming.Except(toAdd).ToList();
|
||||
|
||||
playout.ProgramScheduleAlternates.RemoveAll(toRemove.Contains);
|
||||
|
||||
foreach (ReplacePlayoutAlternateSchedule add in toAdd)
|
||||
{
|
||||
playout.ProgramScheduleAlternates.Add(
|
||||
new ProgramScheduleAlternate
|
||||
{
|
||||
PlayoutId = playout.Id,
|
||||
Index = add.Index,
|
||||
ProgramScheduleId = add.ProgramScheduleId,
|
||||
DaysOfWeek = add.DaysOfWeek,
|
||||
DaysOfMonth = add.DaysOfMonth,
|
||||
MonthsOfYear = add.MonthsOfYear
|
||||
});
|
||||
}
|
||||
|
||||
foreach (ReplacePlayoutAlternateSchedule update in toUpdate)
|
||||
{
|
||||
foreach (ProgramScheduleAlternate ex in existing.Filter(x => x.Id == update.Id))
|
||||
{
|
||||
ex.Index = update.Index;
|
||||
ex.ProgramScheduleId = update.ProgramScheduleId;
|
||||
ex.DaysOfWeek = update.DaysOfWeek;
|
||||
ex.DaysOfMonth = update.DaysOfMonth;
|
||||
ex.MonthsOfYear = update.MonthsOfYear;
|
||||
}
|
||||
}
|
||||
|
||||
// save highest index directly to playout
|
||||
if (playout.ProgramScheduleId != highest.ProgramScheduleId)
|
||||
{
|
||||
playout.ProgramScheduleId = highest.ProgramScheduleId;
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
foreach (PlayoutItem _ in maybeLastPlayoutItem)
|
||||
{
|
||||
foreach (DateTimeOffset dayToCheck in daysToCheck)
|
||||
{
|
||||
ProgramSchedule schedule = PlayoutScheduleSelector.GetProgramScheduleFor(
|
||||
playout.ProgramSchedule,
|
||||
playout.ProgramScheduleAlternates,
|
||||
dayToCheck);
|
||||
|
||||
if (existingScheduleMap.TryGetValue(dayToCheck, out ProgramSchedule existingValue) &&
|
||||
existingValue.Id != schedule.Id)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Alternate schedule change detected for day {Day}, schedule {One} => {Two}; will refresh playout",
|
||||
dayToCheck,
|
||||
existingValue.Name,
|
||||
schedule.Name);
|
||||
|
||||
await _channel.WriteAsync(
|
||||
new BuildPlayout(request.PlayoutId, PlayoutBuildMode.Refresh),
|
||||
cancellationToken);
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error saving alternate schedule items");
|
||||
return BaseError.New(ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,16 @@ internal static class Mapper
|
||||
playoutItem.StartOffset,
|
||||
GetDisplayDuration(playoutItem.FinishOffset - playoutItem.StartOffset));
|
||||
|
||||
internal static PlayoutAlternateScheduleViewModel ProjectToViewModel(
|
||||
ProgramScheduleAlternate programScheduleAlternate) =>
|
||||
new(
|
||||
programScheduleAlternate.Id,
|
||||
programScheduleAlternate.Index,
|
||||
programScheduleAlternate.ProgramScheduleId,
|
||||
programScheduleAlternate.DaysOfWeek,
|
||||
programScheduleAlternate.DaysOfMonth,
|
||||
programScheduleAlternate.MonthsOfYear);
|
||||
|
||||
private static string GetDisplayTitle(PlayoutItem playoutItem)
|
||||
{
|
||||
switch (playoutItem.MediaItem)
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace ErsatzTV.Application.Playouts;
|
||||
|
||||
public record PlayoutAlternateScheduleViewModel(
|
||||
int Id,
|
||||
int Index,
|
||||
int ProgramScheduleId,
|
||||
ICollection<DayOfWeek> DaysOfWeek,
|
||||
ICollection<int> DaysOfMonth,
|
||||
ICollection<int> MonthsOfYear);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.Playouts;
|
||||
|
||||
public record GetPlayoutAlternateSchedules(int PlayoutId) : IRequest<List<PlayoutAlternateScheduleViewModel>>;
|
||||
@@ -0,0 +1,53 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using static ErsatzTV.Application.Playouts.Mapper;
|
||||
|
||||
namespace ErsatzTV.Application.Playouts;
|
||||
|
||||
public class GetPlayoutAlternateSchedulesHandler :
|
||||
IRequestHandler<GetPlayoutAlternateSchedules, List<PlayoutAlternateScheduleViewModel>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
|
||||
public GetPlayoutAlternateSchedulesHandler(IDbContextFactory<TvContext> dbContextFactory) =>
|
||||
_dbContextFactory = dbContextFactory;
|
||||
|
||||
public async Task<List<PlayoutAlternateScheduleViewModel>> Handle(
|
||||
GetPlayoutAlternateSchedules request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
List<PlayoutAlternateScheduleViewModel> result = await dbContext.ProgramScheduleAlternates
|
||||
.Filter(psa => psa.PlayoutId == request.PlayoutId)
|
||||
.Include(psa => psa.ProgramSchedule)
|
||||
.Map(psa => ProjectToViewModel(psa))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
Option<ProgramSchedule> maybeDefaultSchedule = await dbContext.Playouts
|
||||
.Include(p => p.ProgramSchedule)
|
||||
.SelectOneAsync(p => p.Id, p => p.Id == request.PlayoutId)
|
||||
.MapT(p => p.ProgramSchedule);
|
||||
|
||||
foreach (ProgramSchedule defaultSchedule in maybeDefaultSchedule)
|
||||
{
|
||||
var psa = new ProgramScheduleAlternate
|
||||
{
|
||||
Id = -1,
|
||||
PlayoutId = request.PlayoutId,
|
||||
ProgramScheduleId = defaultSchedule.Id,
|
||||
ProgramSchedule = defaultSchedule,
|
||||
Index = result.Map(i => i.Index).DefaultIfEmpty().Max() + 1,
|
||||
DaysOfMonth = ProgramScheduleAlternate.AllDaysOfMonth(),
|
||||
DaysOfWeek = ProgramScheduleAlternate.AllDaysOfWeek(),
|
||||
MonthsOfYear = ProgramScheduleAlternate.AllMonthsOfYear()
|
||||
};
|
||||
|
||||
result.Add(ProjectToViewModel(psa));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,26 @@
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Libraries;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.FFmpeg.Runtime;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Plex;
|
||||
|
||||
public class CallPlexLibraryScannerHandler : CallLibraryScannerHandler,
|
||||
public class CallPlexLibraryScannerHandler : CallLibraryScannerHandler<ISynchronizePlexLibraryById>,
|
||||
IRequestHandler<ForceSynchronizePlexLibraryById, Either<BaseError, string>>,
|
||||
IRequestHandler<SynchronizePlexLibraryByIdIfNeeded, Either<BaseError, string>>
|
||||
{
|
||||
public CallPlexLibraryScannerHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IConfigElementRepository configElementRepository,
|
||||
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel,
|
||||
IMediator mediator,
|
||||
IRuntimeInfo runtimeInfo)
|
||||
: base(channel, mediator, runtimeInfo)
|
||||
: base(dbContextFactory, configElementRepository, channel, mediator, runtimeInfo)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -29,10 +36,18 @@ public class CallPlexLibraryScannerHandler : CallLibraryScannerHandler,
|
||||
ISynchronizePlexLibraryById request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Validation<BaseError, string> validation = Validate();
|
||||
Validation<BaseError, string> validation = await Validate(request);
|
||||
return await validation.Match(
|
||||
scanner => PerformScan(scanner, request, cancellationToken),
|
||||
error => Task.FromResult<Either<BaseError, string>>(error.Join()));
|
||||
error =>
|
||||
{
|
||||
foreach (ScanIsNotRequired scanIsNotRequired in error.OfType<ScanIsNotRequired>())
|
||||
{
|
||||
return Task.FromResult<Either<BaseError, string>>(scanIsNotRequired);
|
||||
}
|
||||
|
||||
return Task.FromResult<Either<BaseError, string>>(error.Join());
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, string>> PerformScan(
|
||||
@@ -57,4 +72,29 @@ public class CallPlexLibraryScannerHandler : CallLibraryScannerHandler,
|
||||
|
||||
return await base.PerformScan(scanner, arguments, cancellationToken);
|
||||
}
|
||||
|
||||
protected override async Task<DateTimeOffset> GetLastScan(
|
||||
TvContext dbContext,
|
||||
ISynchronizePlexLibraryById request)
|
||||
{
|
||||
DateTime minDateTime = await dbContext.PlexLibraries
|
||||
.SelectOneAsync(l => l.Id, l => l.Id == request.PlexLibraryId)
|
||||
.Match(l => l.LastScan ?? SystemTime.MinValueUtc, () => SystemTime.MaxValueUtc);
|
||||
|
||||
return new DateTimeOffset(minDateTime, TimeSpan.Zero);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
namespace ErsatzTV.Application.Plex;
|
||||
|
||||
public interface ISynchronizePlexLibraryById : IRequest<Either<BaseError, string>>, IPlexBackgroundServiceRequest
|
||||
public interface ISynchronizePlexLibraryById : IRequest<Either<BaseError, string>>, IScannerBackgroundServiceRequest
|
||||
{
|
||||
int PlexLibraryId { get; }
|
||||
bool ForceScan { get; }
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.ProgramSchedules;
|
||||
|
||||
public record CopyProgramSchedule
|
||||
(int ProgramScheduleId, string Name) : IRequest<Either<BaseError, ProgramScheduleViewModel>>;
|
||||
@@ -0,0 +1,103 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using static ErsatzTV.Application.ProgramSchedules.Mapper;
|
||||
|
||||
namespace ErsatzTV.Application.ProgramSchedules;
|
||||
|
||||
public class
|
||||
CopyProgramScheduleHandler : IRequestHandler<CopyProgramSchedule, Either<BaseError, ProgramScheduleViewModel>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
|
||||
public CopyProgramScheduleHandler(IDbContextFactory<TvContext> dbContextFactory) =>
|
||||
_dbContextFactory = dbContextFactory;
|
||||
|
||||
public async Task<Either<BaseError, ProgramScheduleViewModel>> Handle(
|
||||
CopyProgramSchedule request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, ProgramSchedule> validation = await Validate(dbContext, request);
|
||||
return await validation.Apply(p => PerformCopy(dbContext, p, request, cancellationToken));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BaseError.New(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ProgramScheduleViewModel> PerformCopy(
|
||||
TvContext dbContext,
|
||||
ProgramSchedule schedule,
|
||||
CopyProgramSchedule request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
DetachEntity(dbContext, schedule);
|
||||
schedule.Name = request.Name;
|
||||
|
||||
// no playouts, no alternates
|
||||
schedule.Playouts = new List<Playout>();
|
||||
schedule.ProgramScheduleAlternates = new List<ProgramScheduleAlternate>();
|
||||
|
||||
foreach (ProgramScheduleItem item in schedule.Items)
|
||||
{
|
||||
DetachEntity(dbContext, item);
|
||||
item.ProgramScheduleId = 0;
|
||||
item.ProgramSchedule = schedule;
|
||||
}
|
||||
|
||||
await dbContext.ProgramSchedules.AddAsync(schedule, cancellationToken);
|
||||
await dbContext.ProgramScheduleItems.AddRangeAsync(schedule.Items, cancellationToken);
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return ProjectToViewModel(schedule);
|
||||
}
|
||||
|
||||
private static async Task<Validation<BaseError, ProgramSchedule>> Validate(
|
||||
TvContext dbContext,
|
||||
CopyProgramSchedule request) =>
|
||||
(await ScheduleMustExist(dbContext, request), await ValidateName(dbContext, request))
|
||||
.Apply((programSchedule, _) => programSchedule);
|
||||
|
||||
private static Task<Validation<BaseError, ProgramSchedule>> ScheduleMustExist(
|
||||
TvContext dbContext,
|
||||
CopyProgramSchedule request) =>
|
||||
dbContext.ProgramSchedules
|
||||
.AsNoTracking()
|
||||
.Include(ps => ps.Items)
|
||||
.SelectOneAsync(p => p.Id, p => p.Id == request.ProgramScheduleId)
|
||||
.Map(o => o.ToValidation<BaseError>("Schedule does not exist."));
|
||||
|
||||
private static async Task<Validation<BaseError, string>> ValidateName(
|
||||
TvContext dbContext,
|
||||
CopyProgramSchedule request)
|
||||
{
|
||||
List<string> allNames = await dbContext.ProgramSchedules
|
||||
.Map(ps => ps.Name)
|
||||
.ToListAsync();
|
||||
|
||||
Validation<BaseError, string> result1 = request.NotEmpty(c => c.Name)
|
||||
.Bind(_ => request.NotLongerThan(50)(c => c.Name));
|
||||
|
||||
var result2 = Optional(request.Name)
|
||||
.Where(name => !allNames.Contains(name))
|
||||
.ToValidation<BaseError>("Schedule name must be unique");
|
||||
|
||||
return (result1, result2).Apply((_, _) => request.Name);
|
||||
}
|
||||
|
||||
private static void DetachEntity<T>(DbContext db, T entity) where T : class
|
||||
{
|
||||
db.Entry(entity).State = EntityState.Detached;
|
||||
if (entity.GetType().GetProperty("Id") is not null)
|
||||
{
|
||||
entity.GetType().GetProperty("Id")!.SetValue(entity, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,10 +17,10 @@ public class CreateProgramScheduleHandler :
|
||||
CreateProgramSchedule request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
Validation<BaseError, ProgramSchedule> validation = await Validate(dbContext, request);
|
||||
return await LanguageExtensions.Apply(validation, ps => PersistProgramSchedule(dbContext, ps));
|
||||
return await validation.Apply(ps => PersistProgramSchedule(dbContext, ps));
|
||||
}
|
||||
|
||||
private static async Task<CreateProgramScheduleResult> PersistProgramSchedule(
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
namespace ErsatzTV.Application.Search;
|
||||
|
||||
public record RebuildSearchIndex : IRequest<Unit>, IBackgroundServiceRequest;
|
||||
public record RebuildSearchIndex : IRequest, IScannerBackgroundServiceRequest;
|
||||
|
||||
@@ -10,7 +10,7 @@ using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Application.Search;
|
||||
|
||||
public class RebuildSearchIndexHandler : IRequestHandler<RebuildSearchIndex, Unit>
|
||||
public class RebuildSearchIndexHandler : IRequestHandler<RebuildSearchIndex>
|
||||
{
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
|
||||
@@ -35,7 +35,7 @@ public class RebuildSearchIndexHandler : IRequestHandler<RebuildSearchIndex, Uni
|
||||
_fallbackMetadataProvider = fallbackMetadataProvider;
|
||||
}
|
||||
|
||||
public async Task<Unit> Handle(RebuildSearchIndex request, CancellationToken cancellationToken)
|
||||
public async Task Handle(RebuildSearchIndex request, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("Initializing search index");
|
||||
|
||||
@@ -63,7 +63,5 @@ public class RebuildSearchIndexHandler : IRequestHandler<RebuildSearchIndex, Uni
|
||||
{
|
||||
_logger.LogInformation("Search index is already version {Version}", _searchIndex.Version);
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace ErsatzTV.Application.Search;
|
||||
|
||||
public record ReindexMediaItems(IReadOnlyCollection<int> MediaItemIds) : IRequest<Unit>,
|
||||
public record ReindexMediaItems(IReadOnlyCollection<int> MediaItemIds) : IRequest,
|
||||
ISearchIndexBackgroundServiceRequest;
|
||||
|
||||
@@ -4,7 +4,7 @@ using ErsatzTV.Core.Interfaces.Search;
|
||||
|
||||
namespace ErsatzTV.Application.Search;
|
||||
|
||||
public class ReindexMediaItemsHandler : IRequestHandler<ReindexMediaItems, Unit>
|
||||
public class ReindexMediaItemsHandler : IRequestHandler<ReindexMediaItems>
|
||||
{
|
||||
private readonly ICachingSearchRepository _cachingSearchRepository;
|
||||
private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
|
||||
@@ -20,10 +20,9 @@ public class ReindexMediaItemsHandler : IRequestHandler<ReindexMediaItems, Unit>
|
||||
_searchIndex = searchIndex;
|
||||
}
|
||||
|
||||
public async Task<Unit> Handle(ReindexMediaItems request, CancellationToken cancellationToken)
|
||||
public async Task Handle(ReindexMediaItems request, CancellationToken cancellationToken)
|
||||
{
|
||||
await _searchIndex.RebuildItems(_cachingSearchRepository, _fallbackMetadataProvider, request.MediaItemIds);
|
||||
_searchIndex.Commit();
|
||||
return Unit.Default;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace ErsatzTV.Application.Search;
|
||||
|
||||
public record RemoveMediaItems(IReadOnlyCollection<int> MediaItemIds) : IRequest<Unit>,
|
||||
public record RemoveMediaItems(IReadOnlyCollection<int> MediaItemIds) : IRequest,
|
||||
ISearchIndexBackgroundServiceRequest;
|
||||
|
||||
@@ -2,16 +2,15 @@ using ErsatzTV.Core.Interfaces.Search;
|
||||
|
||||
namespace ErsatzTV.Application.Search;
|
||||
|
||||
public class RemoveMediaItemsHandler : IRequestHandler<RemoveMediaItems, Unit>
|
||||
public class RemoveMediaItemsHandler : IRequestHandler<RemoveMediaItems>
|
||||
{
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
|
||||
public RemoveMediaItemsHandler(ISearchIndex searchIndex) => _searchIndex = searchIndex;
|
||||
|
||||
public async Task<Unit> Handle(RemoveMediaItems request, CancellationToken cancellationToken)
|
||||
public async Task Handle(RemoveMediaItems request, CancellationToken cancellationToken)
|
||||
{
|
||||
await _searchIndex.RemoveItems(request.MediaItemIds);
|
||||
_searchIndex.Commit();
|
||||
return Unit.Default;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -54,6 +54,8 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
_logger.LogDebug("Keep alive - session worker for channel {ChannelNumber}", _channelNumber);
|
||||
|
||||
_lastAccess = DateTimeOffset.Now;
|
||||
|
||||
_timer?.Stop();
|
||||
@@ -65,31 +67,38 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
DateTimeOffset filterBefore,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
await Slim.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
Option<string[]> maybeLines = await ReadPlaylistLines(cancellationToken);
|
||||
foreach (string[] input in maybeLines)
|
||||
var sw = Stopwatch.StartNew();
|
||||
await Slim.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
TrimPlaylistResult trimResult = _hlsPlaylistFilter.TrimPlaylist(PlaylistStart, filterBefore, input);
|
||||
if (DateTimeOffset.Now > _lastDelete.AddSeconds(30))
|
||||
Option<string[]> maybeLines = await ReadPlaylistLines(cancellationToken);
|
||||
foreach (string[] input in maybeLines)
|
||||
{
|
||||
DeleteOldSegments(trimResult);
|
||||
_lastDelete = DateTimeOffset.Now;
|
||||
TrimPlaylistResult trimResult = _hlsPlaylistFilter.TrimPlaylist(PlaylistStart, filterBefore, input);
|
||||
if (DateTimeOffset.Now > _lastDelete.AddSeconds(30))
|
||||
{
|
||||
DeleteOldSegments(trimResult);
|
||||
_lastDelete = DateTimeOffset.Now;
|
||||
}
|
||||
|
||||
return trimResult;
|
||||
}
|
||||
|
||||
return trimResult;
|
||||
}
|
||||
|
||||
return None;
|
||||
finally
|
||||
{
|
||||
Slim.Release();
|
||||
sw.Stop();
|
||||
// _logger.LogDebug("TrimPlaylist took {Duration}", sw.Elapsed);
|
||||
}
|
||||
}
|
||||
finally
|
||||
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
|
||||
{
|
||||
Slim.Release();
|
||||
sw.Stop();
|
||||
// _logger.LogDebug("TrimPlaylist took {Duration}", sw.Elapsed);
|
||||
// do nothing
|
||||
}
|
||||
|
||||
return None;
|
||||
}
|
||||
|
||||
public void PlayoutUpdated()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using CliWrap;
|
||||
using Dapper;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
@@ -97,6 +98,12 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
||||
.ThenInclude(mi => (mi as MusicVideo).MusicVideoMetadata)
|
||||
.ThenInclude(mvm => mvm.Artists)
|
||||
.Include(i => i.MediaItem)
|
||||
.ThenInclude(mi => (mi as MusicVideo).MusicVideoMetadata)
|
||||
.ThenInclude(mvm => mvm.Studios)
|
||||
.Include(i => i.MediaItem)
|
||||
.ThenInclude(mi => (mi as MusicVideo).MusicVideoMetadata)
|
||||
.ThenInclude(mvm => mvm.Directors)
|
||||
.Include(i => i.MediaItem)
|
||||
.ThenInclude(mi => (mi as MusicVideo).MediaVersions)
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.Include(i => i.MediaItem)
|
||||
@@ -126,7 +133,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
||||
.Include(i => i.Watermark)
|
||||
.ForChannelAndTime(channel.Id, now)
|
||||
.Map(o => o.ToEither<BaseError>(new UnableToLocatePlayoutItem()))
|
||||
.BindT(ValidatePlayoutItemPath);
|
||||
.BindT(item => ValidatePlayoutItemPath(dbContext, item));
|
||||
|
||||
if (maybePlayoutItem.LeftAsEnumerable().Any(e => e is UnableToLocatePlayoutItem))
|
||||
{
|
||||
@@ -295,30 +302,8 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
||||
|
||||
if (isMediaServer)
|
||||
{
|
||||
string mediaItemFolder = Path.GetDirectoryName(playoutItemWithPath.Path);
|
||||
|
||||
allSubtitles = allSubtitles.Map<Subtitle, Option<Subtitle>>(
|
||||
subtitle =>
|
||||
{
|
||||
if (subtitle.SubtitleKind == SubtitleKind.Sidecar)
|
||||
{
|
||||
// need to prepend path with movie/episode folder
|
||||
if (!string.IsNullOrWhiteSpace(mediaItemFolder))
|
||||
{
|
||||
subtitle.Path = Path.Combine(mediaItemFolder, subtitle.Path);
|
||||
|
||||
// skip subtitles that don't exist
|
||||
if (!File.Exists(subtitle.Path))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return subtitle;
|
||||
})
|
||||
.Somes()
|
||||
.ToList();
|
||||
// closed captions are currently unsupported
|
||||
allSubtitles.RemoveAll(s => s.Codec == "eia_608");
|
||||
}
|
||||
|
||||
return allSubtitles;
|
||||
@@ -440,29 +425,85 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
||||
DisableWatermarks = !fallbackPreset.AllowWatermarks
|
||||
};
|
||||
|
||||
return await ValidatePlayoutItemPath(playoutItem);
|
||||
return await ValidatePlayoutItemPath(dbContext, playoutItem);
|
||||
}
|
||||
|
||||
return new UnableToLocatePlayoutItem();
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, PlayoutItemWithPath>> ValidatePlayoutItemPath(PlayoutItem playoutItem)
|
||||
private async Task<Either<BaseError, PlayoutItemWithPath>> ValidatePlayoutItemPath(
|
||||
TvContext dbContext,
|
||||
PlayoutItem playoutItem)
|
||||
{
|
||||
string path = await GetPlayoutItemPath(playoutItem);
|
||||
|
||||
// check filesystem first
|
||||
if (_localFileSystem.FileExists(path))
|
||||
{
|
||||
return new PlayoutItemWithPath(playoutItem, path);
|
||||
}
|
||||
|
||||
// attempt to remotely stream plex
|
||||
MediaFile file = playoutItem.MediaItem.GetHeadVersion().MediaFiles.Head();
|
||||
switch (file)
|
||||
{
|
||||
case PlexMediaFile pmf:
|
||||
Option<int> maybeId = await dbContext.Connection.QuerySingleOrDefaultAsync<int>(
|
||||
@"SELECT PMS.Id FROM PlexMediaSource PMS
|
||||
INNER JOIN Library L on PMS.Id = L.MediaSourceId
|
||||
INNER JOIN LibraryPath LP on L.Id = LP.LibraryId
|
||||
WHERE LP.Id = @LibraryPathId",
|
||||
new { playoutItem.MediaItem.LibraryPathId })
|
||||
.Map(Optional);
|
||||
|
||||
foreach (int plexMediaSourceId in maybeId)
|
||||
{
|
||||
return new PlayoutItemWithPath(
|
||||
playoutItem,
|
||||
$"http://localhost:{Settings.ListenPort}/media/plex/{plexMediaSourceId}/{pmf.Key}");
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
// attempt to remotely stream jellyfin
|
||||
Option<string> jellyfinItemId = playoutItem.MediaItem switch
|
||||
{
|
||||
JellyfinEpisode e => e.ItemId,
|
||||
JellyfinMovie m => m.ItemId,
|
||||
_ => None
|
||||
};
|
||||
|
||||
foreach (string itemId in jellyfinItemId)
|
||||
{
|
||||
return new PlayoutItemWithPath(
|
||||
playoutItem,
|
||||
$"http://localhost:{Settings.ListenPort}/media/jellyfin/{itemId}");
|
||||
}
|
||||
|
||||
// attempt to remotely stream emby
|
||||
Option<string> embyItemId = playoutItem.MediaItem switch
|
||||
{
|
||||
EmbyEpisode e => e.ItemId,
|
||||
EmbyMovie m => m.ItemId,
|
||||
_ => None
|
||||
};
|
||||
|
||||
foreach (string itemId in embyItemId)
|
||||
{
|
||||
return new PlayoutItemWithPath(
|
||||
playoutItem,
|
||||
$"http://localhost:{Settings.ListenPort}/media/emby/{itemId}");
|
||||
}
|
||||
|
||||
return new PlayoutItemDoesNotExistOnDisk(path);
|
||||
}
|
||||
|
||||
private async Task<string> GetPlayoutItemPath(PlayoutItem playoutItem)
|
||||
{
|
||||
MediaVersion version = playoutItem.MediaItem.GetHeadVersion();
|
||||
|
||||
MediaFile file = version.MediaFiles.Head();
|
||||
|
||||
string path = file.Path;
|
||||
return playoutItem.MediaItem switch
|
||||
{
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
namespace ErsatzTV.Application.Subtitles;
|
||||
|
||||
public record ExtractEmbeddedSubtitles(Option<int> PlayoutId) : IRequest<Either<BaseError, Unit>>,
|
||||
ISubtitleWorkerRequest;
|
||||
IBackgroundServiceRequest;
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Threading.Channels;
|
||||
using CliWrap;
|
||||
using CliWrap.Buffered;
|
||||
using CliWrap.Builders;
|
||||
using ErsatzTV.Application.Maintenance;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Extensions;
|
||||
@@ -21,6 +23,7 @@ public class ExtractEmbeddedSubtitlesHandler : IRequestHandler<ExtractEmbeddedSu
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly IEmbyPathReplacementService _embyPathReplacementService;
|
||||
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
|
||||
private readonly IJellyfinPathReplacementService _jellyfinPathReplacementService;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly ILogger<ExtractEmbeddedSubtitlesHandler> _logger;
|
||||
@@ -32,6 +35,7 @@ public class ExtractEmbeddedSubtitlesHandler : IRequestHandler<ExtractEmbeddedSu
|
||||
IPlexPathReplacementService plexPathReplacementService,
|
||||
IJellyfinPathReplacementService jellyfinPathReplacementService,
|
||||
IEmbyPathReplacementService embyPathReplacementService,
|
||||
ChannelWriter<IBackgroundServiceRequest> workerChannel,
|
||||
ILogger<ExtractEmbeddedSubtitlesHandler> logger)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
@@ -39,6 +43,7 @@ public class ExtractEmbeddedSubtitlesHandler : IRequestHandler<ExtractEmbeddedSu
|
||||
_plexPathReplacementService = plexPathReplacementService;
|
||||
_jellyfinPathReplacementService = jellyfinPathReplacementService;
|
||||
_embyPathReplacementService = embyPathReplacementService;
|
||||
_workerChannel = workerChannel;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -49,7 +54,12 @@ public class ExtractEmbeddedSubtitlesHandler : IRequestHandler<ExtractEmbeddedSu
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, string> validation = await FFmpegPathMustExist(dbContext);
|
||||
return await validation.Match(
|
||||
ffmpegPath => ExtractAll(dbContext, request, ffmpegPath, cancellationToken),
|
||||
async ffmpegPath =>
|
||||
{
|
||||
Either<BaseError, Unit> result = await ExtractAll(dbContext, request, ffmpegPath, cancellationToken);
|
||||
await _workerChannel.WriteAsync(new ReleaseMemory(false), cancellationToken);
|
||||
return result;
|
||||
},
|
||||
error => Task.FromResult<Either<BaseError, Unit>>(error.Join()));
|
||||
}
|
||||
|
||||
@@ -68,6 +78,7 @@ public class ExtractEmbeddedSubtitlesHandler : IRequestHandler<ExtractEmbeddedSu
|
||||
|
||||
// only check the requested playout if subtitles are enabled
|
||||
Option<Playout> requestedPlayout = await dbContext.Playouts
|
||||
.AsNoTracking()
|
||||
.Filter(
|
||||
p => p.Channel.SubtitleMode != ChannelSubtitleMode.None ||
|
||||
p.ProgramSchedule.Items.Any(psi => psi.SubtitleMode != ChannelSubtitleMode.None))
|
||||
@@ -79,6 +90,7 @@ public class ExtractEmbeddedSubtitlesHandler : IRequestHandler<ExtractEmbeddedSu
|
||||
if (request.PlayoutId.IsNone)
|
||||
{
|
||||
playoutIdsToCheck = dbContext.Playouts
|
||||
.AsNoTracking()
|
||||
.Filter(
|
||||
p => p.Channel.SubtitleMode != ChannelSubtitleMode.None ||
|
||||
p.ProgramSchedule.Items.Any(psi => psi.SubtitleMode != ChannelSubtitleMode.None))
|
||||
@@ -104,6 +116,7 @@ public class ExtractEmbeddedSubtitlesHandler : IRequestHandler<ExtractEmbeddedSu
|
||||
|
||||
// find all playout items in the next hour
|
||||
List<PlayoutItem> playoutItems = await dbContext.PlayoutItems
|
||||
.AsNoTracking()
|
||||
.Filter(pi => playoutIdsToCheck.Contains(pi.PlayoutId))
|
||||
.Filter(pi => pi.Finish >= DateTime.UtcNow)
|
||||
.Filter(pi => pi.Start <= until)
|
||||
@@ -170,41 +183,45 @@ public class ExtractEmbeddedSubtitlesHandler : IRequestHandler<ExtractEmbeddedSu
|
||||
try
|
||||
{
|
||||
List<int> episodeIds = await dbContext.EpisodeMetadata
|
||||
.AsNoTracking()
|
||||
.Filter(em => mediaItemIds.Contains(em.EpisodeId))
|
||||
.Filter(
|
||||
em => em.Subtitles.Any(
|
||||
s => s.SubtitleKind == SubtitleKind.Embedded &&
|
||||
s.Codec != "hdmv_pgs_subtitle" && s.Codec != "dvd_subtitle"))
|
||||
s.Codec != "hdmv_pgs_subtitle" && s.Codec != "dvd_subtitle" && s.Codec != "dvdsub" && s.Codec != "pgssub"))
|
||||
.Map(em => em.EpisodeId)
|
||||
.ToListAsync(cancellationToken);
|
||||
result.AddRange(episodeIds);
|
||||
|
||||
List<int> movieIds = await dbContext.MovieMetadata
|
||||
.AsNoTracking()
|
||||
.Filter(mm => mediaItemIds.Contains(mm.MovieId))
|
||||
.Filter(
|
||||
mm => mm.Subtitles.Any(
|
||||
s => s.SubtitleKind == SubtitleKind.Embedded &&
|
||||
s.Codec != "hdmv_pgs_subtitle" && s.Codec != "dvd_subtitle"))
|
||||
s.Codec != "hdmv_pgs_subtitle" && s.Codec != "dvd_subtitle" && s.Codec != "dvdsub" && s.Codec != "pgssub"))
|
||||
.Map(mm => mm.MovieId)
|
||||
.ToListAsync(cancellationToken);
|
||||
result.AddRange(movieIds);
|
||||
|
||||
List<int> musicVideoIds = await dbContext.MusicVideoMetadata
|
||||
.AsNoTracking()
|
||||
.Filter(mm => mediaItemIds.Contains(mm.MusicVideoId))
|
||||
.Filter(
|
||||
mm => mm.Subtitles.Any(
|
||||
s => s.SubtitleKind == SubtitleKind.Embedded &&
|
||||
s.Codec != "hdmv_pgs_subtitle" && s.Codec != "dvd_subtitle"))
|
||||
s.Codec != "hdmv_pgs_subtitle" && s.Codec != "dvd_subtitle" && s.Codec != "dvdsub" && s.Codec != "pgssub"))
|
||||
.Map(mm => mm.MusicVideoId)
|
||||
.ToListAsync(cancellationToken);
|
||||
result.AddRange(musicVideoIds);
|
||||
|
||||
List<int> otherVideoIds = await dbContext.OtherVideoMetadata
|
||||
.AsNoTracking()
|
||||
.Filter(ovm => mediaItemIds.Contains(ovm.OtherVideoId))
|
||||
.Filter(
|
||||
ovm => ovm.Subtitles.Any(
|
||||
s => s.SubtitleKind == SubtitleKind.Embedded &&
|
||||
s.Codec != "hdmv_pgs_subtitle" && s.Codec != "dvd_subtitle"))
|
||||
s.Codec != "hdmv_pgs_subtitle" && s.Codec != "dvd_subtitle" && s.Codec != "dvdsub" && s.Codec != "pgssub"))
|
||||
.Map(ovm => ovm.OtherVideoId)
|
||||
.ToListAsync(cancellationToken);
|
||||
result.AddRange(otherVideoIds);
|
||||
@@ -233,7 +250,7 @@ public class ExtractEmbeddedSubtitlesHandler : IRequestHandler<ExtractEmbeddedSu
|
||||
IEnumerable<Subtitle> subtitles = allSubtitles
|
||||
.Filter(
|
||||
s => s.SubtitleKind == SubtitleKind.Embedded && s.IsExtracted == false &&
|
||||
s.Codec != "hdmv_pgs_subtitle" && s.Codec != "dvd_subtitle");
|
||||
s.Codec != "hdmv_pgs_subtitle" && s.Codec != "dvd_subtitle" && s.Codec != "dvdsub" && s.Codec != "pgssub");
|
||||
|
||||
// find cache paths for each subtitle
|
||||
foreach (Subtitle subtitle in subtitles)
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Subtitles.Queries;
|
||||
|
||||
public record GetSubtitlePathById(int Id) : IRequest<Either<BaseError, string>>;
|
||||
@@ -0,0 +1,171 @@
|
||||
using Dapper;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Jellyfin;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Subtitles.Queries;
|
||||
|
||||
public class GetSubtitlePathByIdHandler : IRequestHandler<GetSubtitlePathById, Either<BaseError, string>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
|
||||
public GetSubtitlePathByIdHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, string>> Handle(
|
||||
GetSubtitlePathById request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Option<Subtitle> maybeSubtitle = await dbContext.Subtitles
|
||||
.SelectOneAsync(s => s.Id, s => s.Id == request.Id);
|
||||
|
||||
foreach (string plexUrl in await GetPlexUrl(request, dbContext, maybeSubtitle))
|
||||
{
|
||||
return plexUrl;
|
||||
}
|
||||
|
||||
foreach (string jellyfinUrl in await GetJellyfinUrl(request, dbContext, maybeSubtitle))
|
||||
{
|
||||
return jellyfinUrl;
|
||||
}
|
||||
|
||||
foreach (string embyUrl in await GetEmbyUrl(request, dbContext, maybeSubtitle))
|
||||
{
|
||||
return embyUrl;
|
||||
}
|
||||
|
||||
return maybeSubtitle
|
||||
.Map(s => s.Path)
|
||||
.ToEither(BaseError.New($"Unable to locate subtitle with id {request.Id}"));
|
||||
}
|
||||
|
||||
private static async Task<Option<string>> GetPlexUrl(
|
||||
GetSubtitlePathById request,
|
||||
TvContext dbContext,
|
||||
Option<Subtitle> maybeSubtitle)
|
||||
{
|
||||
// check for plex episode
|
||||
Option<int> maybePlexId = await dbContext.Connection.QuerySingleOrDefaultAsync<int?>(
|
||||
@"select PMS.Id from PlexMediaSource PMS
|
||||
inner join Library L on PMS.Id = L.MediaSourceId
|
||||
inner join LibraryPath LP on L.Id = LP.LibraryId
|
||||
inner join MediaItem MI on LP.Id = MI.LibraryPathId
|
||||
inner join EpisodeMetadata EM on EM.EpisodeId = MI.Id
|
||||
inner join Subtitle S on EM.Id = S.EpisodeMetadataId
|
||||
where S.Id = @SubtitleId",
|
||||
new { SubtitleId = request.Id })
|
||||
.Map(Optional);
|
||||
|
||||
// check for plex movie
|
||||
if (maybePlexId.IsNone)
|
||||
{
|
||||
maybePlexId = await dbContext.Connection.QuerySingleOrDefaultAsync<int?>(
|
||||
@"select PMS.Id from PlexMediaSource PMS
|
||||
inner join Library L on PMS.Id = L.MediaSourceId
|
||||
inner join LibraryPath LP on L.Id = LP.LibraryId
|
||||
inner join MediaItem MI on LP.Id = MI.LibraryPathId
|
||||
inner join MovieMetadata MM on MM.MovieId = MI.Id
|
||||
inner join Subtitle S on MM.Id = S.MovieMetadataId
|
||||
where S.Id = @SubtitleId",
|
||||
new { SubtitleId = request.Id })
|
||||
.Map(Optional);
|
||||
}
|
||||
|
||||
foreach (int plexMediaSourceId in maybePlexId)
|
||||
{
|
||||
foreach (string subtitlePath in maybeSubtitle.Map(s => s.Path))
|
||||
{
|
||||
return $"http://localhost:{Settings.ListenPort}/media/plex/{plexMediaSourceId}/{subtitlePath}";
|
||||
}
|
||||
}
|
||||
|
||||
return Option<string>.None;
|
||||
}
|
||||
|
||||
private static async Task<Option<string>> GetJellyfinUrl(
|
||||
GetSubtitlePathById request,
|
||||
TvContext dbContext,
|
||||
Option<Subtitle> maybeSubtitle)
|
||||
{
|
||||
// check for jellyfin episode
|
||||
Option<string> maybeJellyfinId = await dbContext.Connection.QuerySingleOrDefaultAsync<string>(
|
||||
@"select JE.ItemId from JellyfinEpisode JE
|
||||
inner join EpisodeMetadata EM on EM.EpisodeId = JE.Id
|
||||
inner join Subtitle S on EM.Id = S.EpisodeMetadataId
|
||||
where S.Id = @SubtitleId",
|
||||
new { SubtitleId = request.Id })
|
||||
.Map(Optional);
|
||||
|
||||
// check for jellyfin movie
|
||||
if (maybeJellyfinId.IsNone)
|
||||
{
|
||||
maybeJellyfinId = await dbContext.Connection.QuerySingleOrDefaultAsync<string>(
|
||||
@"select JM.ItemId from JellyfinMovie JM
|
||||
inner join MovieMetadata MM on MM.MovieId = JM.Id
|
||||
inner join Subtitle S on MM.Id = S.MovieMetadataId
|
||||
where S.Id = @SubtitleId",
|
||||
new { SubtitleId = request.Id })
|
||||
.Map(Optional);
|
||||
}
|
||||
|
||||
foreach (string jellyfinItemId in maybeJellyfinId)
|
||||
{
|
||||
foreach (Subtitle subtitle in maybeSubtitle)
|
||||
{
|
||||
int index = subtitle.StreamIndex - JellyfinStream.ExternalStreamOffset;
|
||||
string extension = Subtitle.ExtensionForCodec(subtitle.Codec);
|
||||
var subtitlePath =
|
||||
$"Videos/{jellyfinItemId}/{jellyfinItemId}/Subtitles/{index}/{index}/Stream.{extension}";
|
||||
return $"http://localhost:{Settings.ListenPort}/media/jellyfin/{subtitlePath}";
|
||||
}
|
||||
}
|
||||
|
||||
return Option<string>.None;
|
||||
}
|
||||
|
||||
private static async Task<Option<string>> GetEmbyUrl(
|
||||
GetSubtitlePathById request,
|
||||
TvContext dbContext,
|
||||
Option<Subtitle> maybeSubtitle)
|
||||
{
|
||||
// check for emby episode
|
||||
Option<string> maybeEmbyId = await dbContext.Connection.QuerySingleOrDefaultAsync<string>(
|
||||
@"select EE.ItemId from EmbyEpisode EE
|
||||
inner join EpisodeMetadata EM on EM.EpisodeId = EE.Id
|
||||
inner join Subtitle S on EM.Id = S.EpisodeMetadataId
|
||||
where S.Id = @SubtitleId",
|
||||
new { SubtitleId = request.Id })
|
||||
.Map(Optional);
|
||||
|
||||
// check for emby movie
|
||||
if (maybeEmbyId.IsNone)
|
||||
{
|
||||
maybeEmbyId = await dbContext.Connection.QuerySingleOrDefaultAsync<string>(
|
||||
@"select EM.ItemId from EmbyMovie EM
|
||||
inner join MovieMetadata MM on MM.MovieId = EM.Id
|
||||
inner join Subtitle S on MM.Id = S.MovieMetadataId
|
||||
where S.Id = @SubtitleId",
|
||||
new { SubtitleId = request.Id })
|
||||
.Map(Optional);
|
||||
}
|
||||
|
||||
foreach (string embyItemId in maybeEmbyId)
|
||||
{
|
||||
foreach (Subtitle subtitle in maybeSubtitle)
|
||||
{
|
||||
string extension = Subtitle.ExtensionForCodec(subtitle.Codec);
|
||||
var subtitlePath =
|
||||
$"Videos/{embyItemId}/{subtitle.Path}/Subtitles/{subtitle.StreamIndex}/Stream.{extension}";
|
||||
return $"http://localhost:{Settings.ListenPort}/media/emby/{subtitlePath}";
|
||||
}
|
||||
}
|
||||
|
||||
return Option<string>.None;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.Troubleshooting;
|
||||
|
||||
public record HealthCheckResultSummary(string Title, string Message);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.Troubleshooting.Queries;
|
||||
|
||||
public record GetTroubleshootingInfo : IRequest<TroubleshootingInfo>;
|
||||
@@ -0,0 +1,191 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Reflection;
|
||||
using System.Runtime.InteropServices;
|
||||
using ErsatzTV.Application.FFmpegProfiles;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
using ErsatzTV.Core.Health;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.FFmpeg.Capabilities;
|
||||
using ErsatzTV.FFmpeg.Runtime;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Runtime;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
|
||||
namespace ErsatzTV.Application.Troubleshooting.Queries;
|
||||
|
||||
public class GetTroubleshootingInfoHandler : IRequestHandler<GetTroubleshootingInfo, TroubleshootingInfo>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly IHealthCheckService _healthCheckService;
|
||||
private readonly IHardwareCapabilitiesFactory _hardwareCapabilitiesFactory;
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
private readonly IRuntimeInfo _runtimeInfo;
|
||||
private readonly IMemoryCache _memoryCache;
|
||||
|
||||
public GetTroubleshootingInfoHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IHealthCheckService healthCheckService,
|
||||
IHardwareCapabilitiesFactory hardwareCapabilitiesFactory,
|
||||
IConfigElementRepository configElementRepository,
|
||||
IRuntimeInfo runtimeInfo,
|
||||
IMemoryCache memoryCache)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_healthCheckService = healthCheckService;
|
||||
_hardwareCapabilitiesFactory = hardwareCapabilitiesFactory;
|
||||
_configElementRepository = configElementRepository;
|
||||
_runtimeInfo = runtimeInfo;
|
||||
_memoryCache = memoryCache;
|
||||
}
|
||||
|
||||
public async Task<TroubleshootingInfo> Handle(GetTroubleshootingInfo request, CancellationToken cancellationToken)
|
||||
{
|
||||
List<HealthCheckResult> healthCheckResults = await _healthCheckService.PerformHealthChecks(cancellationToken);
|
||||
|
||||
string version = Assembly.GetEntryAssembly()?
|
||||
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?
|
||||
.InformationalVersion ?? "unknown";
|
||||
|
||||
var healthCheckSummaries = healthCheckResults
|
||||
.Filter(r => r.Status is HealthCheckStatus.Warning or HealthCheckStatus.Fail)
|
||||
.Map(r => new HealthCheckResultSummary(r.Title, r.Message))
|
||||
.ToList();
|
||||
|
||||
FFmpegSettingsViewModel ffmpegSettings = await GetFFmpegSettings();
|
||||
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
List<Channel> channels = await dbContext.Channels
|
||||
.AsNoTracking()
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var channelFFmpegProfiles = channels
|
||||
.Map(c => c.FFmpegProfileId)
|
||||
.ToImmutableHashSet();
|
||||
|
||||
List<FFmpegProfile> ffmpegProfiles = await dbContext.FFmpegProfiles
|
||||
.AsNoTracking()
|
||||
.Include(p => p.Resolution)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var activeFFmpegProfiles = ffmpegProfiles
|
||||
.Filter(f => channelFFmpegProfiles.Contains(f.Id))
|
||||
.ToList();
|
||||
|
||||
string nvidiaCapabilities = null;
|
||||
string vaapiCapabilities = null;
|
||||
Option<ConfigElement> maybeFFmpegPath = await _configElementRepository.Get(ConfigElementKey.FFmpegPath);
|
||||
if (maybeFFmpegPath.IsNone)
|
||||
{
|
||||
nvidiaCapabilities = "Unable to locate ffmpeg";
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (ConfigElement ffmpegPath in maybeFFmpegPath)
|
||||
{
|
||||
nvidiaCapabilities = await _hardwareCapabilitiesFactory.GetNvidiaOutput(ffmpegPath.Value);
|
||||
|
||||
if (_runtimeInfo.IsOSPlatform(OSPlatform.Linux))
|
||||
{
|
||||
var allDrivers = new List<VaapiDriver>
|
||||
{ VaapiDriver.iHD, VaapiDriver.i965, VaapiDriver.RadeonSI, VaapiDriver.Nouveau };
|
||||
|
||||
foreach (VaapiDriver activeDriver in allDrivers)
|
||||
{
|
||||
if (!_memoryCache.TryGetValue("ffmpeg.render_devices", out List<string> vaapiDevices))
|
||||
{
|
||||
vaapiDevices = new List<string> { "/dev/dri/renderD128" };
|
||||
}
|
||||
|
||||
foreach (string vaapiDevice in vaapiDevices)
|
||||
{
|
||||
foreach (string output in await _hardwareCapabilitiesFactory.GetVaapiOutput(
|
||||
Optional(GetDriverName(activeDriver)),
|
||||
vaapiDevice))
|
||||
{
|
||||
vaapiCapabilities += $"Checking driver {activeDriver} device {vaapiDevice}{Environment.NewLine}{Environment.NewLine}";
|
||||
vaapiCapabilities += output;
|
||||
vaapiCapabilities += Environment.NewLine + Environment.NewLine;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new TroubleshootingInfo(
|
||||
version,
|
||||
healthCheckSummaries,
|
||||
ffmpegSettings,
|
||||
activeFFmpegProfiles,
|
||||
channels,
|
||||
nvidiaCapabilities,
|
||||
vaapiCapabilities);
|
||||
}
|
||||
|
||||
// lifted from GetFFmpegSettingsHandler
|
||||
private async Task<FFmpegSettingsViewModel> GetFFmpegSettings()
|
||||
{
|
||||
Option<string> ffmpegPath = await _configElementRepository.GetValue<string>(ConfigElementKey.FFmpegPath);
|
||||
Option<string> ffprobePath = await _configElementRepository.GetValue<string>(ConfigElementKey.FFprobePath);
|
||||
Option<int> defaultFFmpegProfileId =
|
||||
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegDefaultProfileId);
|
||||
Option<bool> saveReports =
|
||||
await _configElementRepository.GetValue<bool>(ConfigElementKey.FFmpegSaveReports);
|
||||
Option<string> preferredAudioLanguageCode =
|
||||
await _configElementRepository.GetValue<string>(ConfigElementKey.FFmpegPreferredLanguageCode);
|
||||
Option<int> watermark =
|
||||
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegGlobalWatermarkId);
|
||||
Option<int> fallbackFiller =
|
||||
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegGlobalFallbackFillerId);
|
||||
Option<int> hlsSegmenterIdleTimeout =
|
||||
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegSegmenterTimeout);
|
||||
Option<int> workAheadSegmenterLimit =
|
||||
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegWorkAheadSegmenters);
|
||||
Option<int> initialSegmentCount =
|
||||
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegInitialSegmentCount);
|
||||
|
||||
var result = new FFmpegSettingsViewModel
|
||||
{
|
||||
FFmpegPath = await ffmpegPath.IfNoneAsync(string.Empty),
|
||||
FFprobePath = await ffprobePath.IfNoneAsync(string.Empty),
|
||||
DefaultFFmpegProfileId = await defaultFFmpegProfileId.IfNoneAsync(0),
|
||||
SaveReports = await saveReports.IfNoneAsync(false),
|
||||
PreferredAudioLanguageCode = await preferredAudioLanguageCode.IfNoneAsync("eng"),
|
||||
HlsSegmenterIdleTimeout = await hlsSegmenterIdleTimeout.IfNoneAsync(60),
|
||||
WorkAheadSegmenterLimit = await workAheadSegmenterLimit.IfNoneAsync(1),
|
||||
InitialSegmentCount = await initialSegmentCount.IfNoneAsync(1)
|
||||
};
|
||||
|
||||
foreach (int watermarkId in watermark)
|
||||
{
|
||||
result.GlobalWatermarkId = watermarkId;
|
||||
}
|
||||
|
||||
foreach (int fallbackFillerId in fallbackFiller)
|
||||
{
|
||||
result.GlobalFallbackFillerId = fallbackFillerId;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private string GetDriverName(VaapiDriver driver)
|
||||
{
|
||||
switch (driver)
|
||||
{
|
||||
case VaapiDriver.i965:
|
||||
return "i965";
|
||||
case VaapiDriver.iHD:
|
||||
return "iHD";
|
||||
case VaapiDriver.RadeonSI:
|
||||
return "radeonsi";
|
||||
case VaapiDriver.Nouveau:
|
||||
return "nouveau";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
13
ErsatzTV.Application/Troubleshooting/TroubleshootingInfo.cs
Normal file
13
ErsatzTV.Application/Troubleshooting/TroubleshootingInfo.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
using ErsatzTV.Application.FFmpegProfiles;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Application.Troubleshooting;
|
||||
|
||||
public record TroubleshootingInfo(
|
||||
string Version,
|
||||
IEnumerable<HealthCheckResultSummary> Health,
|
||||
FFmpegSettingsViewModel FFmpegSettings,
|
||||
IEnumerable<FFmpegProfile> FFmpegProfiles,
|
||||
IEnumerable<Channel> Channels,
|
||||
string NvidiaCapabilities,
|
||||
string VaapiCapabilities);
|
||||
@@ -9,21 +9,21 @@
|
||||
<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.NET.Test.Sdk" Version="17.5.0" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.5.22">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Moq" Version="4.18.4" />
|
||||
<PackageReference Include="NUnit" Version="3.13.3" />
|
||||
<PackageReference Include="NUnit3TestAdapter" Version="4.3.1" />
|
||||
<PackageReference Include="NUnit3TestAdapter" Version="4.4.2" />
|
||||
<PackageReference Include="Serilog" Version="2.12.0" />
|
||||
<PackageReference Include="Serilog.Extensions.Logging" Version="3.1.0" />
|
||||
<PackageReference Include="Serilog.Sinks.Debug" Version="2.0.0" />
|
||||
@@ -34,16 +34,4 @@
|
||||
<ProjectReference Include="..\ErsatzTV.Infrastructure\ErsatzTV.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="Resources\ErsatzTV.png">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Include="Resources\test.sup">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Include="Resources\test.srt">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -15,7 +15,7 @@ public class FakeMediaCollectionRepository : IMediaCollectionRepository
|
||||
|
||||
public Task<List<MediaItem>> GetItems(int id) => _data[id].ToList().AsTask();
|
||||
public Task<List<MediaItem>> GetMultiCollectionItems(int id) => throw new NotSupportedException();
|
||||
public Task<List<MediaItem>> GetSmartCollectionItems(int id) => throw new NotSupportedException();
|
||||
public Task<List<MediaItem>> GetSmartCollectionItems(int id) => _data[id].ToList().AsTask();
|
||||
|
||||
public Task<List<CollectionWithItems>> GetMultiCollectionCollections(int id) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
@@ -59,6 +59,8 @@ public class FakeTelevisionRepository : ITelevisionRepository
|
||||
public Task<List<int>> DeleteEmptyShows(LibraryPath libraryPath) => throw new NotSupportedException();
|
||||
|
||||
public Task<bool> AddGenre(ShowMetadata metadata, Genre genre) => throw new NotSupportedException();
|
||||
public Task<bool> AddGenre(EpisodeMetadata metadata, Genre genre) => throw new NotSupportedException();
|
||||
|
||||
public Task<bool> AddTag(Domain.Metadata metadata, Tag tag) => throw new NotSupportedException();
|
||||
|
||||
public Task<bool> AddStudio(ShowMetadata metadata, Studio studio) => throw new NotSupportedException();
|
||||
|
||||
@@ -70,6 +70,30 @@ public class FallbackMetadataProviderTests
|
||||
// metadata.Season.Should().Be(season);
|
||||
metadata.Head().EpisodeNumber.Should().Be(episode);
|
||||
}
|
||||
|
||||
[TestCase("Awesome Show - S01_BLAH.mkv", 0)]
|
||||
[TestCase("Awesome Show - NO_EPISODE_NUMBER_HERE.mkv", 0)]
|
||||
public void GetFallbackMetadata_ShouldHandleNonEpisodes(string path, int episode)
|
||||
{
|
||||
List<EpisodeMetadata> metadata = _fallbackMetadataProvider.GetFallbackMetadata(
|
||||
new Episode
|
||||
{
|
||||
LibraryPath = new LibraryPath(),
|
||||
MediaVersions = new List<MediaVersion>
|
||||
{
|
||||
new()
|
||||
{
|
||||
MediaFiles = new List<MediaFile>
|
||||
{
|
||||
new() { Path = path }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
metadata.Count.Should().Be(1);
|
||||
metadata.Head().EpisodeNumber.Should().Be(episode);
|
||||
}
|
||||
|
||||
[Test]
|
||||
[TestCase("Awesome Show - s01e02-s01e03.mkv", 1, 2, 3)]
|
||||
@@ -126,4 +150,28 @@ public class FallbackMetadataProviderTests
|
||||
metadata.Should().NotBeNull();
|
||||
metadata.Title.Should().Be(expectedTitle);
|
||||
}
|
||||
|
||||
[Test]
|
||||
[TestCase(@"/Whatever/American Dad! S01", 1)]
|
||||
[TestCase(@"/Whatever/Season 2", 2)]
|
||||
[TestCase(@"/Whatever/Season 02", 2)]
|
||||
[TestCase(@"/Whatever/Staffel 2", 2)]
|
||||
[TestCase(@"/Whatever/Staffel 02", 2)]
|
||||
[TestCase(@"/Whatever/Seinfeld/S02", 2)]
|
||||
[TestCase(@"/Whatever/Seinfeld/2", 2)]
|
||||
[TestCase(@"/Whatever/Season 2009", 2009)]
|
||||
[TestCase(@"/Whatever/Season1", 1)]
|
||||
[TestCase(@"/Bojack Horseman/Bojack.Horseman.S04.1080p.WEB.x264-ABBA", 4)]
|
||||
[TestCase(@"/Whatever/Season 7 (2016)", 7)]
|
||||
[TestCase(@"/Whatever/Season (8)", null)]
|
||||
[TestCase(@"/Whatever/s06e05", null)]
|
||||
[TestCase(@"/Whatever/The.Legend.of.Condor.Heroes.2017.V2.web-dl.1080p.h264.aac-hdctv", null)]
|
||||
[TestCase(@"/Whatever/extras", null)]
|
||||
[TestCase(@"/Whatever/specials", 0)]
|
||||
[TestCase(@"Stargate SG1 S08", 8)]
|
||||
public void GetSeasonNumberForFolder_ShouldHandleVariousFormats(string folder, int? season)
|
||||
{
|
||||
Option<int> actual = _fallbackMetadataProvider.GetSeasonNumberForFolder(folder);
|
||||
actual.Should<Option<int>>().Be(Optional(season));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
@@ -2064,8 +2078,7 @@ public class PlayoutBuilderTests
|
||||
Index = 1,
|
||||
Seed = 12345
|
||||
},
|
||||
Playout = playout,
|
||||
ProgramSchedule = playout.ProgramSchedule
|
||||
Playout = playout
|
||||
});
|
||||
|
||||
var configRepo = new Mock<IConfigElementRepository>();
|
||||
@@ -2228,8 +2241,6 @@ public class PlayoutBuilderTests
|
||||
MediaItemId = headAnchor.MediaItemId,
|
||||
MultiCollection = headAnchor.MultiCollection,
|
||||
MultiCollectionId = headAnchor.MultiCollectionId,
|
||||
ProgramSchedule = headAnchor.ProgramSchedule,
|
||||
ProgramScheduleId = headAnchor.ProgramScheduleId,
|
||||
SmartCollection = headAnchor.SmartCollection,
|
||||
SmartCollectionId = headAnchor.SmartCollectionId
|
||||
});
|
||||
@@ -2366,6 +2377,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 +2533,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 +2645,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 +2757,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 +2809,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 +2891,61 @@ public class PlayoutBuilderTests
|
||||
ProgramSchedule = new ProgramSchedule { Items = items },
|
||||
Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" },
|
||||
Items = new List<PlayoutItem>(),
|
||||
ProgramScheduleAnchors = new List<PlayoutProgramScheduleAnchor>()
|
||||
ProgramScheduleAnchors = new List<PlayoutProgramScheduleAnchor>(),
|
||||
ProgramScheduleAlternates = new List<ProgramScheduleAlternate>()
|
||||
};
|
||||
|
||||
return new TestData(builder, playout);
|
||||
}
|
||||
|
||||
private TestData TestDataFloodForSmartCollectionItems(
|
||||
List<MediaItem> mediaItems,
|
||||
PlaybackOrder playbackOrder,
|
||||
Mock<IConfigElementRepository> configMock = null)
|
||||
{
|
||||
var mediaCollection = new SmartCollection
|
||||
{
|
||||
Id = 1,
|
||||
Query = "asdf"
|
||||
};
|
||||
|
||||
var fillerCollection = new SmartCollection
|
||||
{
|
||||
Id = 2,
|
||||
Query = "ghjk"
|
||||
};
|
||||
|
||||
Mock<IConfigElementRepository> configRepo = configMock ?? new Mock<IConfigElementRepository>();
|
||||
|
||||
var collectionRepo = new FakeMediaCollectionRepository(
|
||||
Map(
|
||||
(mediaCollection.Id, mediaItems),
|
||||
(fillerCollection.Id, mediaItems.Take(1).ToList())
|
||||
)
|
||||
);
|
||||
var televisionRepo = new FakeTelevisionRepository();
|
||||
var artistRepo = new Mock<IArtistRepository>();
|
||||
var factory = new Mock<IMultiEpisodeShuffleCollectionEnumeratorFactory>();
|
||||
var localFileSystem = new Mock<ILocalFileSystem>();
|
||||
var builder = new PlayoutBuilder(
|
||||
configRepo.Object,
|
||||
collectionRepo,
|
||||
televisionRepo,
|
||||
artistRepo.Object,
|
||||
factory.Object,
|
||||
localFileSystem.Object,
|
||||
_logger);
|
||||
|
||||
var items = new List<ProgramScheduleItem> { Flood(mediaCollection, fillerCollection, playbackOrder) };
|
||||
|
||||
var playout = new Playout
|
||||
{
|
||||
Id = 1,
|
||||
ProgramSchedule = new ProgramSchedule { Items = items },
|
||||
Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" },
|
||||
Items = new List<PlayoutItem>(),
|
||||
ProgramScheduleAnchors = new List<PlayoutProgramScheduleAnchor>(),
|
||||
ProgramScheduleAlternates = new List<ProgramScheduleAlternate>()
|
||||
};
|
||||
|
||||
return new TestData(builder, playout);
|
||||
|
||||
@@ -356,6 +356,214 @@ public class PlayoutModeSchedulerBaseTests : SchedulerTestBase
|
||||
playoutItems[2].MediaItemId.Should().Be(1);
|
||||
playoutItems[2].StartOffset.Should().Be(startState.CurrentTime + TimeSpan.FromMinutes(11));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_Schedule_Post_Roll_After_Padded_Mid_Roll()
|
||||
{
|
||||
// content 45 min, mid roll pad to 60, post roll 5 min
|
||||
// content + post = 50 min, mid roll will add two 5 min items
|
||||
// content + mid + post = 60 min
|
||||
|
||||
Collection collectionOne = TwoItemCollection(1, 2, TimeSpan.FromMinutes(45));
|
||||
Collection collectionTwo = TwoItemCollection(3, 4, TimeSpan.FromMinutes(5));
|
||||
Collection collectionThree = TwoItemCollection(5, 6, TimeSpan.FromMinutes(5));
|
||||
|
||||
var scheduleItem = new ProgramScheduleItemOne
|
||||
{
|
||||
Id = 1,
|
||||
Index = 1,
|
||||
Collection = collectionOne,
|
||||
CollectionId = collectionOne.Id,
|
||||
StartTime = null,
|
||||
PlaybackOrder = PlaybackOrder.Chronological,
|
||||
TailFiller = null,
|
||||
FallbackFiller = null,
|
||||
MidRollFiller = new FillerPreset
|
||||
{
|
||||
FillerKind = FillerKind.MidRoll,
|
||||
FillerMode = FillerMode.Pad,
|
||||
PadToNearestMinute = 60,
|
||||
CollectionId = 2,
|
||||
Collection = collectionTwo
|
||||
},
|
||||
PostRollFiller = new FillerPreset
|
||||
{
|
||||
FillerKind = FillerKind.PostRoll,
|
||||
FillerMode = FillerMode.Count,
|
||||
Count = 1,
|
||||
CollectionId = 3,
|
||||
Collection = collectionThree
|
||||
}
|
||||
};
|
||||
|
||||
var scheduleItemsEnumerator = new OrderedScheduleItemsEnumerator(
|
||||
new List<ProgramScheduleItem> { scheduleItem },
|
||||
new CollectionEnumeratorState());
|
||||
|
||||
var enumerator = new ChronologicalMediaCollectionEnumerator(
|
||||
collectionOne.MediaItems,
|
||||
new CollectionEnumeratorState());
|
||||
|
||||
var midRollFillerEnumerator = new ChronologicalMediaCollectionEnumerator(
|
||||
collectionTwo.MediaItems,
|
||||
new CollectionEnumeratorState());
|
||||
|
||||
var postRollFillerEnumerator = new ChronologicalMediaCollectionEnumerator(
|
||||
collectionThree.MediaItems,
|
||||
new CollectionEnumeratorState());
|
||||
|
||||
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
|
||||
|
||||
Dictionary<CollectionKey, IMediaCollectionEnumerator> enumerators = CollectionEnumerators(
|
||||
scheduleItem,
|
||||
enumerator);
|
||||
|
||||
enumerators.Add(CollectionKey.ForFillerPreset(scheduleItem.MidRollFiller), midRollFillerEnumerator);
|
||||
enumerators.Add(CollectionKey.ForFillerPreset(scheduleItem.PostRollFiller), postRollFillerEnumerator);
|
||||
|
||||
List<PlayoutItem> playoutItems = Scheduler()
|
||||
.AddFiller(
|
||||
startState,
|
||||
enumerators,
|
||||
scheduleItem,
|
||||
new PlayoutItem
|
||||
{
|
||||
MediaItemId = 1,
|
||||
Start = startState.CurrentTime.UtcDateTime,
|
||||
Finish = startState.CurrentTime.AddHours(1).UtcDateTime
|
||||
},
|
||||
new List<MediaChapter>
|
||||
{
|
||||
new() { StartTime = TimeSpan.Zero, EndTime = TimeSpan.FromMinutes(6) },
|
||||
new() { StartTime = TimeSpan.FromMinutes(6), EndTime = TimeSpan.FromMinutes(45) }
|
||||
});
|
||||
|
||||
playoutItems.Count.Should().Be(5);
|
||||
|
||||
// content chapter 1
|
||||
playoutItems[0].MediaItemId.Should().Be(1);
|
||||
playoutItems[0].StartOffset.Should().Be(startState.CurrentTime);
|
||||
|
||||
// mid-roll 1
|
||||
playoutItems[1].MediaItemId.Should().Be(3);
|
||||
playoutItems[1].StartOffset.Should().Be(startState.CurrentTime + TimeSpan.FromMinutes(6));
|
||||
|
||||
// mid-roll 2
|
||||
playoutItems[2].MediaItemId.Should().Be(4);
|
||||
playoutItems[2].StartOffset.Should().Be(startState.CurrentTime + TimeSpan.FromMinutes(11));
|
||||
|
||||
// content chapter 2
|
||||
playoutItems[3].MediaItemId.Should().Be(1);
|
||||
playoutItems[3].StartOffset.Should().Be(startState.CurrentTime + TimeSpan.FromMinutes(16));
|
||||
|
||||
// post-roll
|
||||
playoutItems[4].MediaItemId.Should().Be(5);
|
||||
playoutItems[4].StartOffset.Should().Be(startState.CurrentTime + TimeSpan.FromMinutes(55));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_Schedule_Padded_Post_Roll_After_Mid_Roll_Count()
|
||||
{
|
||||
// content 45 min, mid roll 5 min, post roll pad to 60
|
||||
// content + mid = 50 min, post roll will add two 5 min items
|
||||
// content + mid + post = 60 min
|
||||
|
||||
Collection collectionOne = TwoItemCollection(1, 2, TimeSpan.FromMinutes(45));
|
||||
Collection collectionTwo = TwoItemCollection(3, 4, TimeSpan.FromMinutes(5));
|
||||
Collection collectionThree = TwoItemCollection(5, 6, TimeSpan.FromMinutes(5));
|
||||
|
||||
var scheduleItem = new ProgramScheduleItemOne
|
||||
{
|
||||
Id = 1,
|
||||
Index = 1,
|
||||
Collection = collectionOne,
|
||||
CollectionId = collectionOne.Id,
|
||||
StartTime = null,
|
||||
PlaybackOrder = PlaybackOrder.Chronological,
|
||||
TailFiller = null,
|
||||
FallbackFiller = null,
|
||||
MidRollFiller = new FillerPreset
|
||||
{
|
||||
FillerKind = FillerKind.MidRoll,
|
||||
FillerMode = FillerMode.Count,
|
||||
Count = 1,
|
||||
CollectionId = 2,
|
||||
Collection = collectionTwo
|
||||
},
|
||||
PostRollFiller = new FillerPreset
|
||||
{
|
||||
FillerKind = FillerKind.PostRoll,
|
||||
FillerMode = FillerMode.Pad,
|
||||
PadToNearestMinute = 60,
|
||||
CollectionId = 3,
|
||||
Collection = collectionThree
|
||||
}
|
||||
};
|
||||
|
||||
var scheduleItemsEnumerator = new OrderedScheduleItemsEnumerator(
|
||||
new List<ProgramScheduleItem> { scheduleItem },
|
||||
new CollectionEnumeratorState());
|
||||
|
||||
var enumerator = new ChronologicalMediaCollectionEnumerator(
|
||||
collectionOne.MediaItems,
|
||||
new CollectionEnumeratorState());
|
||||
|
||||
var midRollFillerEnumerator = new ChronologicalMediaCollectionEnumerator(
|
||||
collectionTwo.MediaItems,
|
||||
new CollectionEnumeratorState());
|
||||
|
||||
var postRollFillerEnumerator = new ChronologicalMediaCollectionEnumerator(
|
||||
collectionThree.MediaItems,
|
||||
new CollectionEnumeratorState());
|
||||
|
||||
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
|
||||
|
||||
Dictionary<CollectionKey, IMediaCollectionEnumerator> enumerators = CollectionEnumerators(
|
||||
scheduleItem,
|
||||
enumerator);
|
||||
|
||||
enumerators.Add(CollectionKey.ForFillerPreset(scheduleItem.MidRollFiller), midRollFillerEnumerator);
|
||||
enumerators.Add(CollectionKey.ForFillerPreset(scheduleItem.PostRollFiller), postRollFillerEnumerator);
|
||||
|
||||
List<PlayoutItem> playoutItems = Scheduler()
|
||||
.AddFiller(
|
||||
startState,
|
||||
enumerators,
|
||||
scheduleItem,
|
||||
new PlayoutItem
|
||||
{
|
||||
MediaItemId = 1,
|
||||
Start = startState.CurrentTime.UtcDateTime,
|
||||
Finish = startState.CurrentTime.AddHours(1).UtcDateTime
|
||||
},
|
||||
new List<MediaChapter>
|
||||
{
|
||||
new() { StartTime = TimeSpan.Zero, EndTime = TimeSpan.FromMinutes(6) },
|
||||
new() { StartTime = TimeSpan.FromMinutes(6), EndTime = TimeSpan.FromMinutes(45) }
|
||||
});
|
||||
|
||||
playoutItems.Count.Should().Be(5);
|
||||
|
||||
// content chapter 1
|
||||
playoutItems[0].MediaItemId.Should().Be(1);
|
||||
playoutItems[0].StartOffset.Should().Be(startState.CurrentTime);
|
||||
|
||||
// mid-roll 1
|
||||
playoutItems[1].MediaItemId.Should().Be(3);
|
||||
playoutItems[1].StartOffset.Should().Be(startState.CurrentTime + TimeSpan.FromMinutes(6));
|
||||
|
||||
// content chapter 2
|
||||
playoutItems[2].MediaItemId.Should().Be(1);
|
||||
playoutItems[2].StartOffset.Should().Be(startState.CurrentTime + TimeSpan.FromMinutes(11));
|
||||
|
||||
// post-roll 1
|
||||
playoutItems[3].MediaItemId.Should().Be(5);
|
||||
playoutItems[3].StartOffset.Should().Be(startState.CurrentTime + TimeSpan.FromMinutes(50));
|
||||
|
||||
// post-roll 2
|
||||
playoutItems[4].MediaItemId.Should().Be(6);
|
||||
playoutItems[4].StartOffset.Should().Be(startState.CurrentTime + TimeSpan.FromMinutes(55));
|
||||
}
|
||||
}
|
||||
|
||||
[TestFixture]
|
||||
|
||||
@@ -289,7 +289,9 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
|
||||
playoutItems[2].StartOffset.Should().Be(startState.CurrentTime.Add(new TimeSpan(1, 50, 0)));
|
||||
playoutItems[2].GuideGroup.Should().Be(3);
|
||||
playoutItems[2].FillerKind.Should().Be(FillerKind.None);
|
||||
playoutItems[2].GuideFinish.HasValue.Should().BeTrue();
|
||||
|
||||
// offline should not set guide finish
|
||||
playoutItems[2].GuideFinish.HasValue.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Test]
|
||||
|
||||
@@ -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, provider.GetRequiredService<ILogger<TelevisionRepository>>()),
|
||||
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";
|
||||
|
||||
@@ -154,7 +275,7 @@ public class ScheduleIntegrationTests
|
||||
var builder = new PlayoutBuilder(
|
||||
new ConfigElementRepository(factory),
|
||||
new MediaCollectionRepository(new Mock<IClient>().Object, new Mock<ISearchIndex>().Object, factory),
|
||||
new TelevisionRepository(factory),
|
||||
new TelevisionRepository(factory, provider.GetRequiredService<ILogger<TelevisionRepository>>()),
|
||||
new ArtistRepository(factory),
|
||||
new Mock<IMultiEpisodeShuffleCollectionEnumeratorFactory>().Object,
|
||||
new Mock<ILocalFileSystem>().Object,
|
||||
@@ -221,8 +342,41 @@ public class ScheduleIntegrationTests
|
||||
return await dbContext.Playouts
|
||||
.Include(p => p.Channel)
|
||||
.Include(p => p.Items)
|
||||
|
||||
.Include(p => p.ProgramScheduleAlternates)
|
||||
.ThenInclude(a => a.ProgramSchedule)
|
||||
.ThenInclude(ps => ps.Items)
|
||||
.ThenInclude(psi => psi.Collection)
|
||||
.Include(p => p.ProgramScheduleAlternates)
|
||||
.ThenInclude(a => a.ProgramSchedule)
|
||||
.ThenInclude(ps => ps.Items)
|
||||
.ThenInclude(psi => psi.MediaItem)
|
||||
.Include(p => p.ProgramScheduleAlternates)
|
||||
.ThenInclude(a => a.ProgramSchedule)
|
||||
.ThenInclude(ps => ps.Items)
|
||||
.ThenInclude(psi => psi.PreRollFiller)
|
||||
.Include(p => p.ProgramScheduleAlternates)
|
||||
.ThenInclude(a => a.ProgramSchedule)
|
||||
.ThenInclude(ps => ps.Items)
|
||||
.ThenInclude(psi => psi.MidRollFiller)
|
||||
.Include(p => p.ProgramScheduleAlternates)
|
||||
.ThenInclude(a => a.ProgramSchedule)
|
||||
.ThenInclude(ps => ps.Items)
|
||||
.ThenInclude(psi => psi.PostRollFiller)
|
||||
.Include(p => p.ProgramScheduleAlternates)
|
||||
.ThenInclude(a => a.ProgramSchedule)
|
||||
.ThenInclude(ps => ps.Items)
|
||||
.ThenInclude(psi => psi.TailFiller)
|
||||
.Include(p => p.ProgramScheduleAlternates)
|
||||
.ThenInclude(a => a.ProgramSchedule)
|
||||
.ThenInclude(ps => ps.Items)
|
||||
.ThenInclude(psi => psi.FallbackFiller)
|
||||
|
||||
.Include(p => p.ProgramScheduleAnchors)
|
||||
.ThenInclude(a => a.EnumeratorState)
|
||||
.Include(p => p.ProgramScheduleAnchors)
|
||||
.ThenInclude(a => a.MediaItem)
|
||||
|
||||
.Include(p => p.ProgramSchedule)
|
||||
.ThenInclude(ps => ps.Items)
|
||||
.ThenInclude(psi => psi.Collection)
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
namespace ErsatzTV.Core.Domain;
|
||||
|
||||
public record LogEntry(
|
||||
int Id,
|
||||
DateTime Timestamp,
|
||||
string Level,
|
||||
string Exception,
|
||||
string RenderedMessage,
|
||||
string Properties);
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
namespace ErsatzTV.Core.Domain;
|
||||
|
||||
[DebuggerDisplay("{EpisodeMetadata[0].Title ?? \"[unknown episode]\"}")]
|
||||
[DebuggerDisplay("{EpisodeMetadata != null && EpisodeMetadata.Count > 0 ? EpisodeMetadata[0].Title : \"[unknown episode]\"}")]
|
||||
public class Episode : MediaItem
|
||||
{
|
||||
public int SeasonId { get; set; }
|
||||
|
||||
@@ -4,5 +4,6 @@ public enum MediaItemState
|
||||
{
|
||||
Normal = 0,
|
||||
FileNotFound = 1,
|
||||
Unavailable = 2
|
||||
Unavailable = 2,
|
||||
RemoteOnly = 3
|
||||
}
|
||||
|
||||
@@ -5,5 +5,6 @@ public enum MediaStreamKind
|
||||
Video = 1,
|
||||
Audio = 2,
|
||||
Subtitle = 3,
|
||||
Attachment = 4
|
||||
Attachment = 4,
|
||||
ExternalSubtitle = 5
|
||||
}
|
||||
|
||||
@@ -6,4 +6,5 @@ public class EmbyMediaSource : MediaSource
|
||||
public string OperatingSystem { get; set; }
|
||||
public List<EmbyConnection> Connections { get; set; }
|
||||
public List<EmbyPathReplacement> PathReplacements { get; set; }
|
||||
public DateTime? LastCollectionsScan { get; set; }
|
||||
}
|
||||
|
||||
@@ -8,4 +8,5 @@ public class MusicVideoMetadata : Metadata
|
||||
public int MusicVideoId { get; set; }
|
||||
public MusicVideo MusicVideo { get; set; }
|
||||
public List<MusicVideoArtist> Artists { get; set; }
|
||||
public List<Director> Directors { get; set; }
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user