Compare commits

..
Author SHA1 Message Date
Jason Dove f895ab5304 fix nuget versions 2022-05-14 06:37:22 -05:00
Jason Dove 07c54ff45f update changelog for release v0.5.7-beta [no ci] 2022-05-14 05:54:27 -05:00
Jason DoveandGitHub 6a29ce2049 update dependencies (#805) 2022-05-13 21:16:43 -05:00
Jason DoveandGitHub d19e95fb38 add random start point option (#804) 2022-05-13 20:36:03 -05:00
Jason DoveandGitHub d78daf8735 fix flood checkpoints (#803) 2022-05-13 15:31:04 -05:00
Jason DoveandGitHub 4f6522379d fix custom title scheduling (#802) 2022-05-13 13:06:05 -05:00
Jason DoveandGitHub 9e0972fec0 properly ignore plex other videos libraries (#801) 2022-05-13 12:31:34 -05:00
Jason DoveandGitHub 6d564233ed filler scheduling fix (#800) 2022-05-12 14:02:06 -05:00
Jason DoveandGitHub 47252b1243 read track from music video nfo metadata (#799) 2022-05-12 12:31:40 -05:00
Jason DoveandGitHub bd5b52922d add option to allow watermarks over filler (#796) 2022-05-09 17:51:11 -05:00
Jason DoveandGitHub 59c793b9be add option to skip missing items in playouts (#795) 2022-05-09 09:21:51 -05:00
Jason DoveandGitHub 3ad1ba01f8 add autocomplete to search bar (#791) 2022-05-08 19:58:15 -05:00
Jason DoveandGitHub ab10f0ed81 add metadata_kind to search index (#790)
* more nfo cleanup

* add metadata_kind to search index
2022-05-07 21:24:50 -05:00
Jason DoveandGitHub 44dd68fe59 nfo and memory fixes (#789)
* partial episode nfo metadata

* nfo metadata reliability fixes

* use recyclable memory streams
2022-05-07 20:32:57 -05:00
Jason Dove 6326189444 update changelog for release v0.5.6-beta [no ci] 2022-05-06 12:42:57 -05:00
Jason Dove 198c693208 fix other video fallback metadata 2022-05-05 20:51:24 -05:00
Jason DoveandGitHub 1431b33a98 support movie nfo metadata in other video libraries (#788)
* add other video nfo metadata

* update docs
2022-05-05 20:38:23 -05:00
Jason DoveandGitHub e81a8e58ea fix error continuity (#787)
* fix fallback filler playback

* use new transcoder logic for errors

* use realtime option for error streams
2022-05-05 13:31:09 -05:00
Jason DoveandGitHub daf7114ce2 bug fixes and logging (#786) 2022-05-05 10:04:24 -05:00
Jason DoveandGitHub 8542bc20b1 update dependencies (#785) 2022-05-04 20:45:19 -05:00
Jason DoveandGitHub 9decb91bf7 use aired for music video release date (#784) 2022-05-04 11:36:00 -05:00
Jason DoveandGitHub fcfd579b37 fix search index validation (#782) 2022-05-03 22:26:44 -05:00
Jason DoveandGitHub e9be182bed bug fixes and search tweaks (#781)
* fix movie nfo processing

* fix local movie fallback metadata

* use imagesharp again

* fix search edge case

* add show_genre and show_tag to search index
2022-05-03 21:58:39 -05:00
Jason Dove 610e261cd7 update changelog again [no ci] 2022-05-03 10:23:57 -05:00
Jason Dove f65818c838 update changelog for release v0.5.5-beta [no ci] 2022-05-03 10:22:10 -05:00
Jason Dove 1651d2895e update dependencies 2022-05-03 09:45:25 -05:00
Jason Dove b90c536dcb try to fix quemu condition 2022-05-03 09:33:02 -05:00
Jason Dove 5c98eb3df5 more conditions 2022-05-02 22:46:22 -05:00
Jason Dove bdff5eba75 fix conditions 2022-05-02 22:41:11 -05:00
Jason Dove 7d112eda05 fix tag 2022-05-02 22:28:54 -05:00
Jason Dove 4f16431ca0 use specific arm64v8 tags 2022-05-02 22:14:49 -05:00
Jason Dove 69b39c6940 try building arm64 docker image 2022-05-02 22:10:50 -05:00
Jason Dove fe7181ea1d workflow fixes 2022-05-02 21:47:37 -05:00
Jason Dove 88b287a094 use matrix for docker build workflow 2022-05-02 21:44:35 -05:00
Jason Dove 7953e3ad85 actually fix windows tests [no ci] 2022-05-02 13:41:05 -05:00
Jason DoveandGitHub 8ba6374165 music video nfo multiple artists (#780)
* support multiple artist entries in music video nfo metadata

* clean up other video and song etags
2022-05-02 12:32:15 -05:00
Jason Dove 973dd4b53d try to fix tests on windows again [no ci] 2022-05-02 05:54:56 -05:00
Jason DoveandGitHub 6facd745ec fix extracting embedded mov_text subtitles (#777)
* fix extracting embedded mov_text subtitles

* changelog

* cleanup
2022-05-01 21:24:14 -05:00
Jason Dove 32c4c4ec8b fix failing tests on windows [no ci] 2022-05-01 14:11:55 -05:00
Jason DoveandGitHub ecb6ed37f0 more local metadata parsing improvements (#776)
* extract remaining nfo xml serializers

* add artist nfo tests

* add music video nfo tests

* add tv show nfo reader tests

* custom artist nfo reader

* custom music video nfo reader

* custom tv show nfo reader

* local metadata parsing cleanup

* update changelog
2022-05-01 14:00:10 -05:00
Jason DoveandGitHub 2a8bf57930 ignore emby and jellyfin path infos with unset network path (#775) 2022-04-30 21:48:37 -05:00
Jason DoveandGitHub 1ebc4b62e3 bug fixes (#774)
* add custom movie metadata parsing

* refactor episode nfo reader

* fix emby and jellyfin bugs
2022-04-30 17:39:47 -05:00
Jason DoveandGitHub 4ae671b633 fix trashing episodes with no title (#773) 2022-04-29 21:49:23 -05:00
Jason Dove 87aa69f4cc update changelog for release v0.5.4-beta [no ci] 2022-04-29 17:59:12 -05:00
Jason DoveandGitHub 404ea49e35 jellyfin and emby path infos (#771)
* start adding jellyfin path info; fix some scanning bugs

* sync jellyfin libraries before scanning to ensure latest path infos

* code cleanup

* support emby path infos

* fix periodic scanning of emby and jellyfin libraries

* bug fixes
2022-04-29 15:07:17 -05:00
Jason DoveandGitHub 4ed40acfbe rebuild corrupt search index (#770) 2022-04-28 13:49:06 -05:00
Jason DoveandGitHub 17f540dc99 add more search fields (#769) 2022-04-28 10:40:27 -05:00
Jason DoveandGitHub 780ebc01ee add v2 ffmpeg profile page (#768)
* wip

* remove transcode property; use i18n

* add api

* use computed table headers for i18n
2022-04-28 06:56:01 -05:00
Jason DoveandGitHub 0a0fb71b94 refactor plex, emby and jellyfin television scanners (#767)
* refactor plex television scanner

* refactor emby television scanner

* refactor jellyfin television scanner

* update changelog
2022-04-27 22:34:25 -05:00
Jason Dove 53d6ecae8d fix windows build 2022-04-27 14:20:14 -05:00
Jason DoveandGitHub 837f311ec0 add more search fields (#766)
* properly index show and season state

* add height, width, season_number, episode_number to search index

* update docs
2022-04-27 13:58:33 -05:00
Jason DoveandGitHub a9a89d04ea optimize search-index rebuilding (#765)
* update dependencies

* optimize search-index rebuilding

* cleanup logging
2022-04-27 12:23:37 -05:00
Rafael VieiraandGitHub 2e1073eb53 Add support to internationalization (#764)
* client-app: Improve development documentation

* client-app: add basic support to translation

* client-app: fix i18n and create lang state

* client-app: add language selector

* client-app: add translation EN and PT-BR
2022-04-27 10:58:27 -05:00
Jason DoveandGitHub 7687278b80 health check fixes (#763) 2022-04-26 09:38:03 -05:00
Jason DoveandGitHub 392aebd46f refactor movie library scanners (#761)
* catch health check cancellation

* local library scanner cleanup

* emby and jf library scanner cleanup

* rework emby movie library scanner

* refactor emby movie library scanner

* refactor jellyfin movie library scanner

* clear etag until after new media has been processed

* refactor plex movie library scanner

* update changelog
2022-04-25 20:31:12 -05:00
307 changed files with 63670 additions and 6437 deletions
+2 -2
View File
@@ -190,10 +190,10 @@ jobs:
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
- name: Install dependencies
run: dotnet restore -r "${{ matrix.target}}"
run: dotnet restore -r "${{ matrix.target }}"
- uses: suisei-cn/actions-download-file@v1
if: ${{ matrix.kind }} == "windows"
if: ${{ matrix.kind == 'windows' }}
id: downloadffmpeg
name: Download ffmpeg
with:
+39 -34
View File
@@ -24,23 +24,38 @@ jobs:
name: Build & Publish
runs-on: ubuntu-latest
if: contains(github.event.head_commit.message, '[no build]') == false
strategy:
matrix:
include:
- name: base
path: ''
suffix: ''
qemu: false
- name: nvidia
path: 'nvidia/'
suffix: '-nvidia'
qemu: false
- name: vaapi
path: 'vaapi/'
suffix: '-vaapi'
qemu: false
- name: arm64
path: 'arm64/'
suffix: '-arm64'
qemu: true
steps:
- name: Checkout
uses: actions/checkout@v2
with:
fetch-depth: 0
- name: Set up Docker Buildx Base
uses: docker/setup-buildx-action@v1
id: builder-base
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
if: ${{ matrix.qemu == true }}
- name: Set up Docker Buildx NVIDIA
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
id: builder-nvidia
- name: Set up Docker Buildx VAAPI
uses: docker/setup-buildx-action@v1
id: builder-vaapi
id: docker-buildx
- name: Login to DockerHub
uses: docker/login-action@v1
@@ -48,41 +63,31 @@ jobs:
username: ${{ secrets.docker_hub_username }}
password: ${{ secrets.docker_hub_access_token }}
- name: Build and push base
- name: Build and push
uses: docker/build-push-action@v2
with:
builder: ${{ steps.builder-base.outputs.name }}
builder: ${{ steps.docker-buildx.outputs.name }}
context: .
file: ./docker/Dockerfile
file: ./docker/${{ matrix.path }}Dockerfile
push: true
build-args: |
INFO_VERSION=${{ inputs.info_version }}-docker
INFO_VERSION=${{ inputs.info_version }}-docker${{ matrix.suffix }}
tags: |
jasongdove/ersatztv:${{ inputs.base_version }}
jasongdove/ersatztv:${{ inputs.tag_version }}
jasongdove/ersatztv:${{ inputs.base_version }}${{ matrix.suffix }}
jasongdove/ersatztv:${{ inputs.tag_version }}${{ matrix.suffix }}
if: ${{ matrix.name != 'arm64' }}
- name: Build and push nvidia
- name: Build and push
uses: docker/build-push-action@v2
with:
builder: ${{ steps.builder-nvidia.outputs.name }}
builder: ${{ steps.docker-buildx.outputs.name }}
context: .
file: ./docker/nvidia/Dockerfile
file: ./docker/${{ matrix.path }}Dockerfile
push: true
platforms: 'linux/arm64'
build-args: |
INFO_VERSION=${{ inputs.info_version }}-docker-nvidia
INFO_VERSION=${{ inputs.info_version }}-docker${{ matrix.suffix }}
tags: |
jasongdove/ersatztv:${{ inputs.base_version }}-nvidia
jasongdove/ersatztv:${{ inputs.tag_version }}-nvidia
- name: Build and push vaapi
uses: docker/build-push-action@v2
with:
builder: ${{ steps.builder-vaapi.outputs.name }}
context: .
file: ./docker/vaapi/Dockerfile
push: true
build-args: |
INFO_VERSION=${{ inputs.info_version }}-docker-vaapi
tags: |
jasongdove/ersatztv:${{ inputs.base_version }}-vaapi
jasongdove/ersatztv:${{ inputs.tag_version }}-vaapi
jasongdove/ersatztv:${{ inputs.base_version }}${{ matrix.suffix }}
jasongdove/ersatztv:${{ inputs.tag_version }}${{ matrix.suffix }}
if: ${{ matrix.name == 'arm64' }}
+88 -1
View File
@@ -5,6 +5,89 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased]
## [0.5.7-beta] - 2022-05-14
### Fixed
- Reduce memory use due to library scan operations
- Fix some instances of filler getting "stuck" when a filler item is encountered that's too long for the gap
- Properly ignore Plex `Other Videos` libraries (`movie` libraries where agent is `com.plexapp.agents.none`)
- Fix `Custom Title` for schedule items with `One`, `Multiple` and `Flood` playout modes
- Fix scheduling bug where flood items would sometimes fail to continue after midnight
### Added
- Add `metadata_kind` field to search index to allow searching for items with a particular metdata source
- Valid metadata kinds are `fallback`, `sidecar` (NFO), `external` (from a media server) and `embedded` (songs)
- Add autocomplete functionality to search bar to quickly navigate to channels, ffmpeg profiles, collections and schedules by name
- Add global setting to skip missing (file-not-found or unavailable) items when building playouts
- Add filler preset option to allow watermarks to overlay on top of filler (disabled by default)
- This option is applied when new items are added to a playout; rebuilding is needed if you want the change to take effect immediately
- Read `track` field from music video NFO metadata and use it for chronological sorting (after release date)
- Add `Random Start Point` option to schedules
- When this option is enabled, all `Chronological` or `Shuffle In Order` content groups will have their start points randomized
- When this option is disabled, all `Chronological` or `Shuffle In Order` content groups will start with the chronologically earliest item
### Changed
- Replace invalid (control) characters in NFO metadata with replacement character `` before parsing
- Store partial (incomplete) NFO metadata results when invalid XML is encountered
- Previously, no metadata would be stored if the XML within the NFO failed to validate
## [0.5.6-beta] - 2022-05-06
### Fixed
- Fix processing local movie NFO metadata without a `year` value
- Fix processing local movie fallback metadata
- Fix search edge case where very recently added items (hours) would not be returned by relative date queries
- Fix search index validation on startup; improper validation was causing a rebuild with every startup
- Block library scanning until search index has been recreated/upgraded
- Fix occasional erroneous log messages when HLS channel playback times out because all clients have left
- Fix fallback filler playback
- Fix stream continuity when error messages are displayed
- Fix duplicate scanning within `Other Video` libraries (i.e. folders would be scanned multiple times)
### Added
- Add `show_genre` and `show_tag` to search index for seasons and episodes
- Use `aired` value to source release date from music video nfo metadata
- Add NFO metadata support to `Other Video` libraries
- `Other Video` NFO metadata must be in the movie NFO metadata format
## [0.5.5-beta] - 2022-05-03
### Fixed
- Fix adding episodes with no title to the search index
- This behavior was preventing some items from being removed from the trash
- Support combination NFO metadata for movies, shows, artists and music videos
- Note that ErsatzTV does not scrape any metadata; any URLs after the XML will be ignored
- Fix bug causing some Jellyfin and Emby content to incorrectly show as unavailable
- Fix extracting embedded `mov_text` subtitles
- Properly extract embedded subtitles on playouts where subtitles are only enabled on schedule items (and not on the channel itself)
### Added
- Add experimental `arm64` docker tags (`develop-arm64` and `latest-arm64`)
- Use `Sort Title` from Movie NFO metadata if available
- Support multiple `Artist` entries in music video NFO metadata
## [0.5.4-beta] - 2022-04-29
### Fixed
- Cleanly stop all library scans when service termination is requested
- Fix health check crash when trash contains a show or a season
- Fix ability of health check crash to crash home page
- Remove and ignore Season 0/Specials from Plex shows that have no specials
- Automatically delete and rebuild the search index on startup if it has become corrupt
- Automatically scan Jellyfin and Emby libraries on startup and periodically
- Properly remove un-synchronized Plex, Jellyfin and Emby items from the database and search index
- Fix synchronizing movies within a collection from Jellyfin
### Changed
- Update Plex, Jellyfin and Emby movie and show library scanners to share a significant amount of code
- This should help maintain feature parity going forward
- Optimize search-index rebuilding to complete 100x faster
- **No longer use network paths to source content from Jellyfin and Emby**
- **If you previously used path replacements to convert network paths to local paths, you should remove them**
### Added
- Add `unavailable` state for Jellyfin and Emby movie and show libraries
- Add `height` and `width` to search index for all videos
- Add `season_number` and `episode_number` to search index for all episodes
- Add `season_number` to search index for seasons
- Add `show_title` to search index for seasons and episodes
## [0.5.3-beta] - 2022-04-24
### Fixed
- Cleanly stop Plex library scan when service termination is requested
@@ -1113,7 +1196,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Initial release to facilitate testing outside of Docker.
[Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.5.3-beta...HEAD
[Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.5.7-beta...HEAD
[0.5.7-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.5.6-beta...v0.5.7-beta
[0.5.6-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.5.5-beta...v0.5.6-beta
[0.5.5-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.5.4-beta...v0.5.5-beta
[0.5.4-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.5.3-beta...v0.5.4-beta
[0.5.3-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.5.2-beta...v0.5.3-beta
[0.5.2-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.5.1-beta...v0.5.2-beta
[0.5.1-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.5.0-beta...v0.5.1-beta
+1 -1
View File
@@ -17,7 +17,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="CliWrap" Version="3.4.3" />
<PackageReference Include="CliWrap" Version="3.4.4" />
</ItemGroup>
<ItemGroup>
@@ -1,15 +1,21 @@
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Iptv;
using Microsoft.IO;
namespace ErsatzTV.Application.Channels;
public class GetChannelGuideHandler : IRequestHandler<GetChannelGuide, ChannelGuide>
{
private readonly IChannelRepository _channelRepository;
private readonly RecyclableMemoryStreamManager _recyclableMemoryStreamManager;
public GetChannelGuideHandler(IChannelRepository channelRepository) => _channelRepository = channelRepository;
public GetChannelGuideHandler(IChannelRepository channelRepository, RecyclableMemoryStreamManager recyclableMemoryStreamManager)
{
_channelRepository = channelRepository;
_recyclableMemoryStreamManager = recyclableMemoryStreamManager;
}
public Task<ChannelGuide> Handle(GetChannelGuide request, CancellationToken cancellationToken) =>
_channelRepository.GetAllForGuide()
.Map(channels => new ChannelGuide(request.Scheme, request.Host, channels));
.Map(channels => new ChannelGuide(_recyclableMemoryStreamManager, request.Scheme, request.Host, channels));
}
@@ -1,5 +0,0 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.Configuration;
public record UpdatePlayoutDaysToBuild(int DaysToBuild) : IRequest<Either<BaseError, Unit>>;
@@ -0,0 +1,5 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.Configuration;
public record UpdatePlayoutSettings(PlayoutSettingsViewModel PlayoutSettings) : IRequest<Either<BaseError, Unit>>;
@@ -9,13 +9,13 @@ using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Configuration;
public class UpdatePlayoutDaysToBuildHandler : IRequestHandler<UpdatePlayoutDaysToBuild, Either<BaseError, Unit>>
public class UpdatePlayoutSettingsHandler : IRequestHandler<UpdatePlayoutSettings, Either<BaseError, Unit>>
{
private readonly IConfigElementRepository _configElementRepository;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
public UpdatePlayoutDaysToBuildHandler(
public UpdatePlayoutSettingsHandler(
IConfigElementRepository configElementRepository,
IDbContextFactory<TvContext> dbContextFactory,
ChannelWriter<IBackgroundServiceRequest> workerChannel)
@@ -26,17 +26,20 @@ public class UpdatePlayoutDaysToBuildHandler : IRequestHandler<UpdatePlayoutDays
}
public async Task<Either<BaseError, Unit>> Handle(
UpdatePlayoutDaysToBuild request,
UpdatePlayoutSettings request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Unit> validation = await Validate(request);
return await validation.Apply<Unit, Unit>(_ => ApplyUpdate(dbContext, request.DaysToBuild));
return await validation.Apply<Unit, Unit>(_ => ApplyUpdate(dbContext, request.PlayoutSettings));
}
private async Task<Unit> ApplyUpdate(TvContext dbContext, int daysToBuild)
private async Task<Unit> ApplyUpdate(TvContext dbContext, PlayoutSettingsViewModel playoutSettings)
{
await _configElementRepository.Upsert(ConfigElementKey.PlayoutDaysToBuild, daysToBuild);
await _configElementRepository.Upsert(ConfigElementKey.PlayoutDaysToBuild, playoutSettings.DaysToBuild);
await _configElementRepository.Upsert(
ConfigElementKey.PlayoutSkipMissingItems,
playoutSettings.SkipMissingItems);
// continue all playouts to proper number of days
List<Playout> playouts = await dbContext.Playouts
@@ -50,8 +53,8 @@ public class UpdatePlayoutDaysToBuildHandler : IRequestHandler<UpdatePlayoutDays
return Unit.Default;
}
private static Task<Validation<BaseError, Unit>> Validate(UpdatePlayoutDaysToBuild request) =>
Optional(request.DaysToBuild)
private static Task<Validation<BaseError, Unit>> Validate(UpdatePlayoutSettings request) =>
Optional(request.PlayoutSettings.DaysToBuild)
.Where(days => days > 0)
.Map(_ => Unit.Default)
.ToValidation<BaseError>("Days to build must be greater than zero")
@@ -0,0 +1,7 @@
namespace ErsatzTV.Application.Configuration;
public class PlayoutSettingsViewModel
{
public int DaysToBuild { get; set; }
public bool SkipMissingItems { get; set; }
}
@@ -1,3 +0,0 @@
namespace ErsatzTV.Application.Configuration;
public record GetPlayoutDaysToBuild : IRequest<int>;
@@ -1,16 +0,0 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
namespace ErsatzTV.Application.Configuration;
public class GetPlayoutDaysToBuildHandler : IRequestHandler<GetPlayoutDaysToBuild, int>
{
private readonly IConfigElementRepository _configElementRepository;
public GetPlayoutDaysToBuildHandler(IConfigElementRepository configElementRepository) =>
_configElementRepository = configElementRepository;
public Task<int> Handle(GetPlayoutDaysToBuild request, CancellationToken cancellationToken) =>
_configElementRepository.GetValue<int>(ConfigElementKey.PlayoutDaysToBuild)
.Map(result => result.IfNone(2));
}
@@ -0,0 +1,3 @@
namespace ErsatzTV.Application.Configuration;
public record GetPlayoutSettings : IRequest<PlayoutSettingsViewModel>;
@@ -0,0 +1,26 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
namespace ErsatzTV.Application.Configuration;
public class GetPlayoutSettingsHandler : IRequestHandler<GetPlayoutSettings, PlayoutSettingsViewModel>
{
private readonly IConfigElementRepository _configElementRepository;
public GetPlayoutSettingsHandler(IConfigElementRepository configElementRepository) =>
_configElementRepository = configElementRepository;
public async Task<PlayoutSettingsViewModel> Handle(GetPlayoutSettings request, CancellationToken cancellationToken)
{
Option<int> daysToBuild = await _configElementRepository.GetValue<int>(ConfigElementKey.PlayoutDaysToBuild);
Option<bool> skipMissingItems =
await _configElementRepository.GetValue<bool>(ConfigElementKey.PlayoutSkipMissingItems);
return new PlayoutSettingsViewModel
{
DaysToBuild = await daysToBuild.IfNoneAsync(2),
SkipMissingItems = await skipMissingItems.IfNoneAsync(false)
};
}
}
@@ -8,8 +8,7 @@ using Microsoft.Extensions.Logging;
namespace ErsatzTV.Application.Emby;
public class
SynchronizeEmbyLibrariesHandler : IRequestHandler<SynchronizeEmbyLibraries, Either<BaseError, Unit>>
public class SynchronizeEmbyLibrariesHandler : IRequestHandler<SynchronizeEmbyLibraries, Either<BaseError, Unit>>
{
private readonly IEmbyApiClient _embyApiClient;
private readonly IEmbySecretStore _embySecretStore;
@@ -72,32 +71,33 @@ public class
connectionParameters.ActiveConnection.Address,
connectionParameters.ApiKey);
await maybeLibraries.Match(
async libraries =>
{
var existing = connectionParameters.EmbyMediaSource.Libraries.OfType<EmbyLibrary>()
.ToList();
var toAdd = libraries.Filter(library => existing.All(l => l.ItemId != library.ItemId)).ToList();
var toRemove = existing.Filter(library => libraries.All(l => l.ItemId != library.ItemId)).ToList();
List<int> ids = await _mediaSourceRepository.UpdateLibraries(
connectionParameters.EmbyMediaSource.Id,
toAdd,
toRemove);
if (ids.Any())
{
await _searchIndex.RemoveItems(ids);
_searchIndex.Commit();
}
},
error =>
{
_logger.LogWarning(
"Unable to synchronize libraries from emby server {EmbyServer}: {Error}",
connectionParameters.EmbyMediaSource.ServerName,
error.Value);
foreach (BaseError error in maybeLibraries.LeftToSeq())
{
_logger.LogWarning(
"Unable to synchronize libraries from emby server {EmbyServer}: {Error}",
connectionParameters.EmbyMediaSource.ServerName,
error.Value);
}
return Task.CompletedTask;
});
foreach (List<EmbyLibrary> libraries in maybeLibraries.RightToSeq())
{
var existing = connectionParameters.EmbyMediaSource.Libraries.OfType<EmbyLibrary>()
.ToList();
var toAdd = libraries.Filter(library => existing.All(l => l.ItemId != library.ItemId)).ToList();
var toRemove = existing.Filter(library => libraries.All(l => l.ItemId != library.ItemId)).ToList();
var toUpdate = libraries
.Filter(l => toAdd.All(a => a.ItemId != l.ItemId) && toRemove.All(r => r.ItemId != l.ItemId)).ToList();
List<int> ids = await _mediaSourceRepository.UpdateLibraries(
connectionParameters.EmbyMediaSource.Id,
toAdd,
toRemove,
toUpdate);
if (ids.Any())
{
await _searchIndex.RemoveItems(ids);
_searchIndex.Commit();
}
}
return Unit.Default;
}
@@ -49,19 +49,24 @@ public class SynchronizeEmbyLibraryByIdHandler :
public Task<Either<BaseError, string>> Handle(
ForceSynchronizeEmbyLibraryById request,
CancellationToken cancellationToken) => Handle(request);
CancellationToken cancellationToken) => HandleImpl(request, cancellationToken);
public Task<Either<BaseError, string>> Handle(
SynchronizeEmbyLibraryByIdIfNeeded request,
CancellationToken cancellationToken) => Handle(request);
CancellationToken cancellationToken) => HandleImpl(request, cancellationToken);
private Task<Either<BaseError, string>>
Handle(ISynchronizeEmbyLibraryById request) =>
Validate(request)
.MapT(parameters => Synchronize(parameters).Map(_ => parameters.Library.Name))
.Bind(v => v.ToEitherAsync());
private async Task<Either<BaseError, string>>
HandleImpl(ISynchronizeEmbyLibraryById request, CancellationToken cancellationToken)
{
Validation<BaseError, RequestParameters> validation = await Validate(request);
return await validation.Match(
parameters => Synchronize(parameters, cancellationToken),
error => Task.FromResult<Either<BaseError, string>>(error.Join()));
}
private async Task<Unit> Synchronize(RequestParameters parameters)
private async Task<Either<BaseError, string>> Synchronize(
RequestParameters parameters,
CancellationToken cancellationToken)
{
try
{
@@ -77,15 +82,17 @@ public class SynchronizeEmbyLibraryByIdHandler :
parameters.ConnectionParameters.ApiKey,
parameters.Library,
parameters.FFmpegPath,
parameters.FFprobePath),
parameters.FFprobePath,
cancellationToken),
LibraryMediaKind.Shows =>
await _embyTelevisionLibraryScanner.ScanLibrary(
parameters.ConnectionParameters.ActiveConnection.Address,
parameters.ConnectionParameters.ApiKey,
parameters.Library,
parameters.FFmpegPath,
parameters.FFprobePath),
_ => BaseError.New("Unsupported library media kind")
parameters.FFprobePath,
cancellationToken),
_ => Unit.Default
};
if (result.IsRight)
@@ -94,17 +101,18 @@ public class SynchronizeEmbyLibraryByIdHandler :
await _libraryRepository.UpdateLastScan(parameters.Library);
await _embyWorkerChannel.WriteAsync(
new SynchronizeEmbyCollections(parameters.Library.MediaSourceId));
new SynchronizeEmbyCollections(parameters.Library.MediaSourceId),
cancellationToken);
}
return result.Map(_ => parameters.Library.Name);
}
else
{
_logger.LogDebug(
"Skipping unforced scan of emby media library {Name}",
parameters.Library.Name);
_logger.LogDebug("Skipping unforced scan of emby media library {Name}", parameters.Library.Name);
}
return Unit.Default;
return parameters.Library.Name;
}
finally
{
@@ -3,5 +3,10 @@ using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Emby;
public record EmbyLibraryViewModel(int Id, string Name, LibraryMediaKind MediaKind, bool ShouldSyncItems)
: LibraryViewModel("Emby", Id, Name, MediaKind);
public record EmbyLibraryViewModel(
int Id,
string Name,
LibraryMediaKind MediaKind,
bool ShouldSyncItems,
int MediaSourceId)
: LibraryViewModel("Emby", Id, Name, MediaKind, MediaSourceId);
+1 -1
View File
@@ -11,7 +11,7 @@ internal static class Mapper
embyMediaSource.Connections.HeadOrNone().Match(c => c.Address, string.Empty));
internal static EmbyLibraryViewModel ProjectToViewModel(EmbyLibrary library) =>
new(library.Id, library.Name, library.MediaKind, library.ShouldSyncItems);
new(library.Id, library.Name, library.MediaKind, library.ShouldSyncItems, library.MediaSourceId);
internal static EmbyPathReplacementViewModel ProjectToViewModel(EmbyPathReplacement pathReplacement) =>
new(pathReplacement.Id, pathReplacement.EmbyPath, pathReplacement.LocalPath);
@@ -8,10 +8,11 @@
<ItemGroup>
<PackageReference Include="Bugsnag" Version="3.0.1" />
<PackageReference Include="CliWrap" Version="3.4.3" />
<PackageReference Include="CliWrap" Version="3.4.4" />
<PackageReference Include="Humanizer.Core" Version="2.14.1" />
<PackageReference Include="MediatR" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="6.0.0" />
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.1.46">
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.2.32">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
@@ -1,4 +1,5 @@
using ErsatzTV.Application.Resolutions;
using ErsatzTV.Core.Api.FFmpegProfiles;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.FFmpegProfiles;
@@ -26,6 +27,14 @@ internal static class Mapper
profile.NormalizeFramerate,
profile.DeinterlaceVideo == true);
internal static FFmpegProfileResponseModel ProjectToResponseModel(FFmpegProfile ffmpegProfile) =>
new(
ffmpegProfile.Id,
ffmpegProfile.Name,
$"{ffmpegProfile.Resolution.Width}x{ffmpegProfile.Resolution.Height}",
ffmpegProfile.VideoFormat.ToString().ToLowerInvariant(),
ffmpegProfile.AudioFormat.ToString().ToLowerInvariant());
private static ResolutionViewModel Project(Resolution resolution) =>
new(resolution.Id, resolution.Name, resolution.Width, resolution.Height);
}
@@ -0,0 +1,5 @@
using ErsatzTV.Core.Api.FFmpegProfiles;
namespace ErsatzTV.Application.FFmpegProfiles;
public record GetAllFFmpegProfilesForApi : IRequest<List<FFmpegProfileResponseModel>>;
@@ -0,0 +1,28 @@
using ErsatzTV.Core.Api.FFmpegProfiles;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.FFmpegProfiles.Mapper;
namespace ErsatzTV.Application.FFmpegProfiles;
public class
GetAllFFmpegProfilesForApiHandler : IRequestHandler<GetAllFFmpegProfilesForApi, List<FFmpegProfileResponseModel>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public GetAllFFmpegProfilesForApiHandler(IDbContextFactory<TvContext> dbContextFactory) =>
_dbContextFactory = dbContextFactory;
public async Task<List<FFmpegProfileResponseModel>> Handle(
GetAllFFmpegProfilesForApi request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
List<FFmpegProfile> ffmpegProfiles = await dbContext.FFmpegProfiles
.AsNoTracking()
.Include(p => p.Resolution)
.ToListAsync(cancellationToken);
return ffmpegProfiles.Map(ProjectToResponseModel).ToList();
}
}
@@ -11,6 +11,7 @@ public record CreateFillerPreset(
TimeSpan? Duration,
int? Count,
int? PadToNearestMinute,
bool AllowWatermarks,
ProgramScheduleItemCollectionType CollectionType,
int? CollectionId,
int? MediaItemId,
@@ -31,6 +31,7 @@ public class CreateFillerPresetHandler : IRequestHandler<CreateFillerPreset, Eit
Duration = request.Duration,
Count = request.Count,
PadToNearestMinute = request.PadToNearestMinute,
AllowWatermarks = request.AllowWatermarks,
CollectionType = request.CollectionType,
CollectionId = request.CollectionId,
MediaItemId = request.MediaItemId,
@@ -12,6 +12,7 @@ public record UpdateFillerPreset(
TimeSpan? Duration,
int? Count,
int? PadToNearestMinute,
bool AllowWatermarks,
ProgramScheduleItemCollectionType CollectionType,
int? CollectionId,
int? MediaItemId,
@@ -15,8 +15,7 @@ public class UpdateFillerPresetHandler : IRequestHandler<UpdateFillerPreset, Eit
public async Task<Either<BaseError, Unit>> Handle(UpdateFillerPreset request, CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, FillerPreset> validation = await FillerPresetMustExist(dbContext, request);
return await LanguageExtensions.Apply(validation, ps => ApplyUpdateRequest(dbContext, ps, request));
}
@@ -32,6 +31,7 @@ public class UpdateFillerPresetHandler : IRequestHandler<UpdateFillerPreset, Eit
existing.Duration = request.Duration;
existing.Count = request.Count;
existing.PadToNearestMinute = request.PadToNearestMinute;
existing.AllowWatermarks = request.AllowWatermarks;
existing.CollectionType = request.CollectionType;
existing.CollectionId = request.CollectionId;
existing.MediaItemId = request.MediaItemId;
@@ -11,6 +11,7 @@ public record FillerPresetViewModel(
TimeSpan? Duration,
int? Count,
int? PadToNearestMinute,
bool AllowWatermarks,
ProgramScheduleItemCollectionType CollectionType,
int? CollectionId,
int? MediaItemId,
+1
View File
@@ -13,6 +13,7 @@ internal static class Mapper
fillerPreset.Duration,
fillerPreset.Count,
fillerPreset.PadToNearestMinute,
fillerPreset.AllowWatermarks,
fillerPreset.CollectionType,
fillerPreset.CollectionId,
fillerPreset.MediaItemId,
@@ -13,7 +13,14 @@ public class GetAllHealthCheckResultsHandler : IRequestHandler<GetAllHealthCheck
GetAllHealthCheckResults request,
CancellationToken cancellationToken)
{
List<HealthCheckResult> results = await _healthCheckService.PerformHealthChecks(cancellationToken);
return results.Filter(r => r.Status != HealthCheckStatus.NotApplicable).ToList();
try
{
List<HealthCheckResult> results = await _healthCheckService.PerformHealthChecks(cancellationToken);
return results.Filter(r => r.Status != HealthCheckStatus.NotApplicable).ToList();
}
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
{
return new List<HealthCheckResult>();
}
}
}
@@ -9,9 +9,7 @@ using Microsoft.Extensions.Logging;
namespace ErsatzTV.Application.Jellyfin;
public class
SynchronizeJellyfinLibrariesHandler : IRequestHandler<SynchronizeJellyfinLibraries,
Either<BaseError, Unit>>
SynchronizeJellyfinLibrariesHandler : IRequestHandler<SynchronizeJellyfinLibraries, Either<BaseError, Unit>>
{
private readonly IJellyfinApiClient _jellyfinApiClient;
private readonly IJellyfinSecretStore _jellyfinSecretStore;
@@ -74,32 +72,34 @@ public class
connectionParameters.ActiveConnection.Address,
connectionParameters.ApiKey);
await maybeLibraries.Match(
async libraries =>
{
var existing = connectionParameters.JellyfinMediaSource.Libraries.OfType<JellyfinLibrary>()
.ToList();
var toAdd = libraries.Filter(library => existing.All(l => l.ItemId != library.ItemId)).ToList();
var toRemove = existing.Filter(library => libraries.All(l => l.ItemId != library.ItemId)).ToList();
List<int> ids = await _mediaSourceRepository.UpdateLibraries(
connectionParameters.JellyfinMediaSource.Id,
toAdd,
toRemove);
if (ids.Any())
{
await _searchIndex.RemoveItems(ids);
_searchIndex.Commit();
}
},
error =>
{
_logger.LogWarning(
"Unable to synchronize libraries from jellyfin server {JellyfinServer}: {Error}",
connectionParameters.JellyfinMediaSource.ServerName,
error.Value);
foreach (BaseError error in maybeLibraries.LeftToSeq())
{
_logger.LogWarning(
"Unable to synchronize libraries from jellyfin server {JellyfinServer}: {Error}",
connectionParameters.JellyfinMediaSource.ServerName,
error.Value);
}
return Task.CompletedTask;
});
foreach (List<JellyfinLibrary> libraries in maybeLibraries.RightToSeq())
{
var existing = connectionParameters.JellyfinMediaSource.Libraries
.OfType<JellyfinLibrary>()
.ToList();
var toAdd = libraries.Filter(library => existing.All(l => l.ItemId != library.ItemId)).ToList();
var toRemove = existing.Filter(library => libraries.All(l => l.ItemId != library.ItemId)).ToList();
var toUpdate = libraries
.Filter(l => toAdd.All(a => a.ItemId != l.ItemId) && toRemove.All(r => r.ItemId != l.ItemId)).ToList();
List<int> ids = await _mediaSourceRepository.UpdateLibraries(
connectionParameters.JellyfinMediaSource.Id,
toAdd,
toRemove,
toUpdate);
if (ids.Any())
{
await _searchIndex.RemoveItems(ids);
_searchIndex.Commit();
}
}
return Unit.Default;
}
@@ -49,19 +49,24 @@ public class SynchronizeJellyfinLibraryByIdHandler :
public Task<Either<BaseError, string>> Handle(
ForceSynchronizeJellyfinLibraryById request,
CancellationToken cancellationToken) => Handle(request);
CancellationToken cancellationToken) => HandleImpl(request, cancellationToken);
public Task<Either<BaseError, string>> Handle(
SynchronizeJellyfinLibraryByIdIfNeeded request,
CancellationToken cancellationToken) => Handle(request);
CancellationToken cancellationToken) => HandleImpl(request, cancellationToken);
private Task<Either<BaseError, string>>
Handle(ISynchronizeJellyfinLibraryById request) =>
Validate(request)
.MapT(parameters => Synchronize(parameters).Map(_ => parameters.Library.Name))
.Bind(v => v.ToEitherAsync());
private async Task<Either<BaseError, string>>
HandleImpl(ISynchronizeJellyfinLibraryById request, CancellationToken cancellationToken)
{
Validation<BaseError, RequestParameters> validation = await Validate(request);
return await validation.Match(
parameters => Synchronize(parameters, cancellationToken),
error => Task.FromResult<Either<BaseError, string>>(error.Join()));
}
private async Task<Unit> Synchronize(RequestParameters parameters)
private async Task<Either<BaseError, string>> Synchronize(
RequestParameters parameters,
CancellationToken cancellationToken)
{
try
{
@@ -77,15 +82,17 @@ public class SynchronizeJellyfinLibraryByIdHandler :
parameters.ConnectionParameters.ApiKey,
parameters.Library,
parameters.FFmpegPath,
parameters.FFprobePath),
parameters.FFprobePath,
cancellationToken),
LibraryMediaKind.Shows =>
await _jellyfinTelevisionLibraryScanner.ScanLibrary(
parameters.ConnectionParameters.ActiveConnection.Address,
parameters.ConnectionParameters.ApiKey,
parameters.Library,
parameters.FFmpegPath,
parameters.FFprobePath),
_ => BaseError.New("Unsupported library media kind")
parameters.FFprobePath,
cancellationToken),
_ => Unit.Default
};
if (result.IsRight)
@@ -94,17 +101,18 @@ public class SynchronizeJellyfinLibraryByIdHandler :
await _libraryRepository.UpdateLastScan(parameters.Library);
await _jellyfinWorkerChannel.WriteAsync(
new SynchronizeJellyfinCollections(parameters.Library.MediaSourceId));
new SynchronizeJellyfinCollections(parameters.Library.MediaSourceId),
cancellationToken);
}
return result.Map(_ => parameters.Library.Name);
}
else
{
_logger.LogDebug(
"Skipping unforced scan of jellyfin media library {Name}",
parameters.Library.Name);
_logger.LogDebug("Skipping unforced scan of jellyfin media library {Name}", parameters.Library.Name);
}
return Unit.Default;
return parameters.Library.Name;
}
finally
{
@@ -3,5 +3,10 @@ using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Jellyfin;
public record JellyfinLibraryViewModel(int Id, string Name, LibraryMediaKind MediaKind, bool ShouldSyncItems)
: LibraryViewModel("Jellyfin", Id, Name, MediaKind);
public record JellyfinLibraryViewModel(
int Id,
string Name,
LibraryMediaKind MediaKind,
bool ShouldSyncItems,
int MediaSourceId)
: LibraryViewModel("Jellyfin", Id, Name, MediaKind, MediaSourceId);
+1 -1
View File
@@ -11,7 +11,7 @@ internal static class Mapper
jellyfinMediaSource.Connections.HeadOrNone().Match(c => c.Address, string.Empty));
internal static JellyfinLibraryViewModel ProjectToViewModel(JellyfinLibrary library) =>
new(library.Id, library.Name, library.MediaKind, library.ShouldSyncItems);
new(library.Id, library.Name, library.MediaKind, library.ShouldSyncItems, library.MediaSourceId);
internal static JellyfinPathReplacementViewModel ProjectToViewModel(JellyfinPathReplacement pathReplacement) =>
new(pathReplacement.Id, pathReplacement.JellyfinPath, pathReplacement.LocalPath);
@@ -32,7 +32,7 @@ public class CreateLocalLibraryHandler : LocalLibraryHandlerBase,
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, LocalLibrary> validation = await Validate(dbContext, request);
return await LanguageExtensions.Apply(validation, localLibrary => PersistLocalLibrary(dbContext, localLibrary));
return await validation.Apply(localLibrary => PersistLocalLibrary(dbContext, localLibrary));
}
private async Task<LocalLibraryViewModel> PersistLocalLibrary(
@@ -2,4 +2,4 @@
namespace ErsatzTV.Application.Libraries;
public record LibraryViewModel(string LibraryKind, int Id, string Name, LibraryMediaKind MediaKind);
public record LibraryViewModel(string LibraryKind, int Id, string Name, LibraryMediaKind MediaKind, int MediaSourceId);
@@ -2,5 +2,5 @@
namespace ErsatzTV.Application.Libraries;
public record LocalLibraryViewModel(int Id, string Name, LibraryMediaKind MediaKind)
: LibraryViewModel("Local", Id, Name, MediaKind);
public record LocalLibraryViewModel(int Id, string Name, LibraryMediaKind MediaKind, int MediaSourceId)
: LibraryViewModel("Local", Id, Name, MediaKind, MediaSourceId);
+9 -4
View File
@@ -10,14 +10,19 @@ internal static class Mapper
library switch
{
LocalLibrary l => ProjectToViewModel(l),
PlexLibrary p => new PlexLibraryViewModel(p.Id, p.Name, p.MediaKind),
JellyfinLibrary j => new JellyfinLibraryViewModel(j.Id, j.Name, j.MediaKind, j.ShouldSyncItems),
EmbyLibrary e => new EmbyLibraryViewModel(e.Id, e.Name, e.MediaKind, e.ShouldSyncItems),
PlexLibrary p => new PlexLibraryViewModel(p.Id, p.Name, p.MediaKind, p.MediaSourceId),
JellyfinLibrary j => new JellyfinLibraryViewModel(
j.Id,
j.Name,
j.MediaKind,
j.ShouldSyncItems,
j.MediaSourceId),
EmbyLibrary e => new EmbyLibraryViewModel(e.Id, e.Name, e.MediaKind, e.ShouldSyncItems, e.MediaSourceId),
_ => throw new ArgumentOutOfRangeException(nameof(library))
};
public static LocalLibraryViewModel ProjectToViewModel(LocalLibrary library) =>
new(library.Id, library.Name, library.MediaKind);
new(library.Id, library.Name, library.MediaKind, library.MediaSourceId);
public static LocalLibraryPathViewModel ProjectToViewModel(LibraryPath libraryPath) =>
new(libraryPath.Id, libraryPath.LibraryId, libraryPath.Path);
@@ -2,5 +2,5 @@
namespace ErsatzTV.Application.Libraries;
public record PlexLibraryViewModel(int Id, string Name, LibraryMediaKind MediaKind)
: LibraryViewModel("Plex", Id, Name, MediaKind);
public record PlexLibraryViewModel(int Id, string Name, LibraryMediaKind MediaKind, int MediaSourceId)
: LibraryViewModel("Plex", Id, Name, MediaKind, MediaSourceId);
+1 -1
View File
@@ -148,7 +148,7 @@ internal static class Mapper
Collection collection,
Option<JellyfinMediaSource> maybeJellyfin,
Option<EmbyMediaSource> maybeEmby) =>
new CollectionCardResultsViewModel(
new(
collection.Name,
collection.MediaItems.OfType<Movie>().Map(
m => ProjectToViewModel(m.MovieMetadata.Head(), maybeJellyfin, maybeEmby) with
@@ -56,13 +56,7 @@ public class DeleteTraktListHandler : TraktCommandBase, IRequestHandler<DeleteTr
dbContext.TraktLists.Remove(traktList);
if (await dbContext.SaveChangesAsync() > 0)
{
foreach (int mediaItemId in mediaItemIds)
{
foreach (MediaItem mediaItem in await _searchRepository.GetItemToIndex(mediaItemId))
{
await _searchIndex.UpdateItems(_searchRepository, new[] { mediaItem }.ToList());
}
}
await _searchIndex.RebuildItems(_searchRepository, mediaItemIds);
}
_searchIndex.Commit();
@@ -156,15 +156,9 @@ public abstract class TraktCommandBase
}
}
await dbContext.SaveChangesAsync();
foreach (int mediaItemId in ids)
if (await dbContext.SaveChangesAsync() > 0)
{
Option<MediaItem> maybeItem = await _searchRepository.GetItemToIndex(mediaItemId);
foreach (MediaItem item in maybeItem)
{
await _searchIndex.UpdateItems(_searchRepository, new[] { item }.ToList());
}
await _searchIndex.RebuildItems(_searchRepository, ids.ToList());
}
_searchIndex.Commit();
@@ -5,6 +5,7 @@ using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Metadata;
using Humanizer;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Application.MediaSources;
@@ -85,56 +86,56 @@ public class ScanLocalLibraryHandler : IRequestHandler<ForceScanLocalLibrary, Ei
{
scanned = true;
switch (localLibrary.MediaKind)
Either<BaseError, Unit> result = localLibrary.MediaKind switch
{
case LibraryMediaKind.Movies:
LibraryMediaKind.Movies =>
await _movieFolderScanner.ScanFolder(
libraryPath,
ffmpegPath,
ffprobePath,
progressMin,
progressMax,
cancellationToken);
break;
case LibraryMediaKind.Shows:
cancellationToken),
LibraryMediaKind.Shows =>
await _televisionFolderScanner.ScanFolder(
libraryPath,
ffmpegPath,
ffprobePath,
progressMin,
progressMax,
cancellationToken);
break;
case LibraryMediaKind.MusicVideos:
cancellationToken),
LibraryMediaKind.MusicVideos =>
await _musicVideoFolderScanner.ScanFolder(
libraryPath,
ffmpegPath,
ffprobePath,
progressMin,
progressMax,
cancellationToken);
break;
case LibraryMediaKind.OtherVideos:
cancellationToken),
LibraryMediaKind.OtherVideos =>
await _otherVideoFolderScanner.ScanFolder(
libraryPath,
ffmpegPath,
ffprobePath,
progressMin,
progressMax);
break;
case LibraryMediaKind.Songs:
progressMax,
cancellationToken),
LibraryMediaKind.Songs =>
await _songFolderScanner.ScanFolder(
libraryPath,
ffprobePath,
ffmpegPath,
progressMin,
progressMax,
cancellationToken);
break;
}
cancellationToken),
_ => Unit.Default
};
libraryPath.LastScan = DateTime.UtcNow;
await _libraryRepository.UpdateLastScan(libraryPath);
if (result.IsRight)
{
libraryPath.LastScan = DateTime.UtcNow;
await _libraryRepository.UpdateLastScan(libraryPath);
}
}
await _mediator.Publish(new LibraryScanProgress(libraryPath.LibraryId, progressMax), cancellationToken);
@@ -147,7 +148,7 @@ public class ScanLocalLibraryHandler : IRequestHandler<ForceScanLocalLibrary, Ei
_logger.LogDebug(
"Scan of library {Name} completed in {Duration}",
localLibrary.Name,
TimeSpan.FromMilliseconds(sw.ElapsedMilliseconds));
sw.Elapsed.Humanize());
}
else
{
@@ -71,31 +71,29 @@ public class
connectionParameters.ActiveConnection,
connectionParameters.PlexServerAuthToken);
await maybeLibraries.Match(
async libraries =>
{
var existing = connectionParameters.PlexMediaSource.Libraries.OfType<PlexLibrary>().ToList();
var toAdd = libraries.Filter(library => existing.All(l => l.Key != library.Key)).ToList();
var toRemove = existing.Filter(library => libraries.All(l => l.Key != library.Key)).ToList();
List<int> ids = await _mediaSourceRepository.UpdateLibraries(
connectionParameters.PlexMediaSource.Id,
toAdd,
toRemove);
if (ids.Any())
{
await _searchIndex.RemoveItems(ids);
_searchIndex.Commit();
}
},
error =>
{
_logger.LogWarning(
"Unable to synchronize libraries from plex server {PlexServer}: {Error}",
connectionParameters.PlexMediaSource.ServerName,
error.Value);
foreach (BaseError error in maybeLibraries.LeftToSeq())
{
_logger.LogWarning(
"Unable to synchronize libraries from plex server {PlexServer}: {Error}",
connectionParameters.PlexMediaSource.ServerName,
error.Value);
}
return Task.CompletedTask;
});
foreach (List<PlexLibrary> libraries in maybeLibraries.RightToSeq())
{
var existing = connectionParameters.PlexMediaSource.Libraries.OfType<PlexLibrary>().ToList();
var toAdd = libraries.Filter(library => existing.All(l => l.Key != library.Key)).ToList();
var toRemove = existing.Filter(library => libraries.All(l => l.Key != library.Key)).ToList();
List<int> ids = await _mediaSourceRepository.UpdateLibraries(
connectionParameters.PlexMediaSource.Id,
toAdd,
toRemove);
if (ids.Any())
{
await _searchIndex.RemoveItems(ids);
_searchIndex.Commit();
}
}
return Unit.Default;
}
@@ -6,4 +6,5 @@ public record CreateProgramSchedule(
string Name,
bool KeepMultiPartEpisodesTogether,
bool TreatCollectionsAsShows,
bool ShuffleScheduleItems) : IRequest<Either<BaseError, CreateProgramScheduleResult>>;
bool ShuffleScheduleItems,
bool RandomStartPoint) : IRequest<Either<BaseError, CreateProgramScheduleResult>>;
@@ -44,7 +44,8 @@ public class CreateProgramScheduleHandler :
Name = name,
KeepMultiPartEpisodesTogether = keepMultiPartEpisodesTogether,
TreatCollectionsAsShows = keepMultiPartEpisodesTogether && request.TreatCollectionsAsShows,
ShuffleScheduleItems = request.ShuffleScheduleItems
ShuffleScheduleItems = request.ShuffleScheduleItems,
RandomStartPoint = request.RandomStartPoint
};
});
@@ -8,4 +8,5 @@ public record UpdateProgramSchedule
string Name,
bool KeepMultiPartEpisodesTogether,
bool TreatCollectionsAsShows,
bool ShuffleScheduleItems) : IRequest<Either<BaseError, UpdateProgramScheduleResult>>;
bool ShuffleScheduleItems,
bool RandomStartPoint) : IRequest<Either<BaseError, UpdateProgramScheduleResult>>;
@@ -41,13 +41,15 @@ public class UpdateProgramScheduleHandler :
bool needToRefreshPlayout =
programSchedule.KeepMultiPartEpisodesTogether != request.KeepMultiPartEpisodesTogether ||
programSchedule.TreatCollectionsAsShows != request.TreatCollectionsAsShows ||
programSchedule.ShuffleScheduleItems != request.ShuffleScheduleItems;
programSchedule.ShuffleScheduleItems != request.ShuffleScheduleItems ||
programSchedule.RandomStartPoint != request.RandomStartPoint;
programSchedule.Name = request.Name;
programSchedule.KeepMultiPartEpisodesTogether = request.KeepMultiPartEpisodesTogether;
programSchedule.TreatCollectionsAsShows = programSchedule.KeepMultiPartEpisodesTogether &&
request.TreatCollectionsAsShows;
programSchedule.ShuffleScheduleItems = request.ShuffleScheduleItems;
programSchedule.RandomStartPoint = request.RandomStartPoint;
await dbContext.SaveChangesAsync();
@@ -10,7 +10,8 @@ internal static class Mapper
programSchedule.Name,
programSchedule.KeepMultiPartEpisodesTogether,
programSchedule.TreatCollectionsAsShows,
programSchedule.ShuffleScheduleItems);
programSchedule.ShuffleScheduleItems,
programSchedule.RandomStartPoint);
internal static ProgramScheduleItemViewModel ProjectToViewModel(ProgramScheduleItem programScheduleItem) =>
programScheduleItem switch
@@ -5,4 +5,5 @@ public record ProgramScheduleViewModel(
string Name,
bool KeepMultiPartEpisodesTogether,
bool TreatCollectionsAsShows,
bool ShuffleScheduleItems);
bool ShuffleScheduleItems,
bool RandomStartPoint);
@@ -22,7 +22,8 @@ public class GetAllProgramSchedulesHandler : IRequestHandler<GetAllProgramSchedu
ps.Name,
ps.KeepMultiPartEpisodesTogether,
ps.TreatCollectionsAsShows,
ps.ShuffleScheduleItems))
ps.ShuffleScheduleItems,
ps.RandomStartPoint))
.ToListAsync(cancellationToken);
}
}
@@ -1,8 +1,10 @@
using ErsatzTV.Core;
using System.Diagnostics;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using Humanizer;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Application.Search;
@@ -33,7 +35,7 @@ public class RebuildSearchIndexHandler : IRequestHandler<RebuildSearchIndex, Uni
{
bool indexFolderExists = Directory.Exists(FileSystemLayout.SearchIndexFolder);
await _searchIndex.Initialize(_localFileSystem);
await _searchIndex.Initialize(_localFileSystem, _configElementRepository);
if (!indexFolderExists ||
await _configElementRepository.GetValue<int>(ConfigElementKey.SearchIndexVersion) <
@@ -41,12 +43,13 @@ public class RebuildSearchIndexHandler : IRequestHandler<RebuildSearchIndex, Uni
{
_logger.LogInformation("Migrating search index to version {Version}", _searchIndex.Version);
List<int> itemIds = await _searchRepository.GetItemIdsToIndex();
await _searchIndex.Rebuild(_searchRepository, itemIds);
var sw = Stopwatch.StartNew();
await _searchIndex.Rebuild(_searchRepository);
await _configElementRepository.Upsert(ConfigElementKey.SearchIndexVersion, _searchIndex.Version);
sw.Stop();
_logger.LogInformation("Done migrating search index");
_logger.LogInformation("Done migrating search index in {Duration}", sw.Elapsed.Humanize());
}
else
{
@@ -0,0 +1,3 @@
namespace ErsatzTV.Application.Search;
public record QuerySearchTargets : IRequest<List<SearchTargetViewModel>>;
@@ -0,0 +1,54 @@
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Search;
public class QuerySearchTargetsHandler : IRequestHandler<QuerySearchTargets, List<SearchTargetViewModel>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public QuerySearchTargetsHandler(IDbContextFactory<TvContext> dbContextFactory) =>
_dbContextFactory = dbContextFactory;
public async Task<List<SearchTargetViewModel>> Handle(
QuerySearchTargets request,
CancellationToken cancellationToken)
{
var result = new List<SearchTargetViewModel>();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
result.AddRange(
dbContext.Channels
.Map(c => new SearchTargetViewModel(c.Id, c.Name, SearchTargetKind.Channel)));
result.AddRange(
dbContext.FFmpegProfiles
.Map(f => new SearchTargetViewModel(f.Id, f.Name, SearchTargetKind.FFmpegProfile)));
result.AddRange(
dbContext.ChannelWatermarks
.Map(w => new SearchTargetViewModel(w.Id, w.Name, SearchTargetKind.ChannelWatermark)));
result.AddRange(
dbContext.Collections
.Map(c => new SearchTargetViewModel(c.Id, c.Name, SearchTargetKind.Collection)));
result.AddRange(
dbContext.MultiCollections
.Map(mc => new SearchTargetViewModel(mc.Id, mc.Name, SearchTargetKind.MultiCollection)));
result.AddRange(
dbContext.SmartCollections
.Map(sc => new SmartCollectionSearchTargetViewModel(sc.Id, sc.Name, sc.Query)));
var schedules = await dbContext.ProgramSchedules
.Map(s => new { s.Id, s.Name })
.ToListAsync(cancellationToken);
result.AddRange(
schedules.SelectMany(
s => new[]
{
new SearchTargetViewModel(s.Id, s.Name, SearchTargetKind.Schedule),
new SearchTargetViewModel(s.Id, s.Name, SearchTargetKind.ScheduleItems)
}));
return result;
}
}
@@ -0,0 +1,18 @@
namespace ErsatzTV.Application.Search;
public record SearchTargetViewModel(int Id, string Name, SearchTargetKind Kind);
public record SmartCollectionSearchTargetViewModel(int Id, string Name, string Query)
: SearchTargetViewModel(Id, Name, SearchTargetKind.SmartCollection);
public enum SearchTargetKind
{
Channel = 1,
FFmpegProfile = 2,
ChannelWatermark = 3,
Collection = 4,
MultiCollection = 5,
SmartCollection = 6,
Schedule = 7,
ScheduleItems = 8
}
@@ -276,7 +276,7 @@ public class HlsSessionWorker : IHlsSessionWorker
return false;
}
}
catch (TaskCanceledException)
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
{
_logger.LogInformation("Terminating HLS process for channel {Channel}", _channelNumber);
return false;
@@ -14,6 +14,7 @@ using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Application.Streaming;
@@ -24,6 +25,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
private readonly IFFmpegProcessService _ffmpegProcessService;
private readonly IJellyfinPathReplacementService _jellyfinPathReplacementService;
private readonly ILocalFileSystem _localFileSystem;
private readonly ILogger<GetPlayoutItemProcessByChannelNumberHandler> _logger;
private readonly IMediaCollectionRepository _mediaCollectionRepository;
private readonly IPlexPathReplacementService _plexPathReplacementService;
private readonly ISongVideoGenerator _songVideoGenerator;
@@ -39,7 +41,8 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
IMediaCollectionRepository mediaCollectionRepository,
ITelevisionRepository televisionRepository,
IArtistRepository artistRepository,
ISongVideoGenerator songVideoGenerator)
ISongVideoGenerator songVideoGenerator,
ILogger<GetPlayoutItemProcessByChannelNumberHandler> logger)
: base(dbContextFactory)
{
_ffmpegProcessService = ffmpegProcessService;
@@ -51,6 +54,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
_televisionRepository = televisionRepository;
_artistRepository = artistRepository;
_songVideoGenerator = songVideoGenerator;
_logger = logger;
}
protected override async Task<Either<BaseError, PlayoutItemProcessModel>> GetProcess(
@@ -178,7 +182,8 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
playoutItemWithPath.PlayoutItem.InPoint,
playoutItemWithPath.PlayoutItem.OutPoint,
request.PtsOffset,
request.TargetFramerate);
request.TargetFramerate,
playoutItemWithPath.PlayoutItem.DisableWatermarks);
var result = new PlayoutItemProcessModel(
process,
@@ -201,6 +206,12 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
DateTimeOffset finish = maybeDuration.Match(d => now.Add(d), () => now);
_logger.LogWarning(
"Error locating playout item {@Error}. Will display error from {Start} to {Finish}",
error,
now,
finish);
switch (error)
{
case UnableToLocatePlayoutItem:
@@ -244,16 +255,16 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
List<Subtitle> allSubtitles = playoutItemWithPath.PlayoutItem.MediaItem switch
{
Episode episode => Optional(episode.EpisodeMetadata).Flatten().HeadOrNone()
.Map(mm => mm.Subtitles)
.Map(mm => mm.Subtitles ?? new List<Subtitle>())
.IfNone(new List<Subtitle>()),
Movie movie => Optional(movie.MovieMetadata).Flatten().HeadOrNone()
.Map(mm => mm.Subtitles)
.Map(mm => mm.Subtitles ?? new List<Subtitle>())
.IfNone(new List<Subtitle>()),
MusicVideo musicVideo => Optional(musicVideo.MusicVideoMetadata).Flatten().HeadOrNone()
.Map(mm => mm.Subtitles)
.Map(mm => mm.Subtitles ?? new List<Subtitle>())
.IfNone(new List<Subtitle>()),
OtherVideo otherVideo => Optional(otherVideo.OtherVideoMetadata).Flatten().HeadOrNone()
.Map(mm => mm.Subtitles)
.Map(mm => mm.Subtitles ?? new List<Subtitle>())
.IfNone(new List<Subtitle>()),
_ => new List<Subtitle>()
};
@@ -361,7 +372,8 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
Finish = finish.UtcDateTime,
FillerKind = FillerKind.Fallback,
InPoint = TimeSpan.Zero,
OutPoint = version.Duration
OutPoint = version.Duration,
DisableWatermarks = !fallbackPreset.AllowWatermarks
};
return await ValidatePlayoutItemPath(playoutItem);
@@ -68,7 +68,9 @@ public class ExtractEmbeddedSubtitlesHandler : IRequestHandler<ExtractEmbeddedSu
// only check the requested playout if subtitles are enabled
Option<Playout> requestedPlayout = await dbContext.Playouts
.Filter(p => p.Channel.SubtitleMode != ChannelSubtitleMode.None)
.Filter(
p => p.Channel.SubtitleMode != ChannelSubtitleMode.None ||
p.ProgramSchedule.Items.Any(psi => psi.SubtitleMode != ChannelSubtitleMode.None))
.SelectOneAsync(p => p.Id, p => p.Id == request.PlayoutId.IfNone(-1));
playoutIdsToCheck.AddRange(requestedPlayout.Map(p => p.Id));
@@ -77,7 +79,9 @@ public class ExtractEmbeddedSubtitlesHandler : IRequestHandler<ExtractEmbeddedSu
if (request.PlayoutId.IsNone)
{
playoutIdsToCheck = dbContext.Playouts
.Filter(p => p.Channel.SubtitleMode != ChannelSubtitleMode.None)
.Filter(
p => p.Channel.SubtitleMode != ChannelSubtitleMode.None ||
p.ProgramSchedule.Items.Any(psi => psi.SubtitleMode != ChannelSubtitleMode.None))
.Map(p => p.Id)
.ToList();
}
@@ -151,7 +155,7 @@ public class ExtractEmbeddedSubtitlesHandler : IRequestHandler<ExtractEmbeddedSu
return Unit.Default;
}
catch (TaskCanceledException)
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
{
return Unit.Default;
}
@@ -206,7 +210,7 @@ public class ExtractEmbeddedSubtitlesHandler : IRequestHandler<ExtractEmbeddedSu
.ToListAsync(cancellationToken);
result.AddRange(otherVideoIds);
}
catch (TaskCanceledException)
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
{
// do nothing
}
@@ -285,7 +289,8 @@ public class ExtractEmbeddedSubtitlesHandler : IRequestHandler<ExtractEmbeddedSu
File.Delete(fullOutputPath);
}
args.Add("-map").Add($"0:{subtitle.Subtitle.StreamIndex}").Add("-c").Add("copy")
args.Add("-map").Add($"0:{subtitle.Subtitle.StreamIndex}")
.Add("-c:s").Add(subtitle.Subtitle.Codec == "mov_text" ? "text" : "copy")
.Add(fullOutputPath);
}
@@ -381,6 +386,7 @@ public class ExtractEmbeddedSubtitlesHandler : IRequestHandler<ExtractEmbeddedSu
"subrip" => $"{name}.srt",
"ass" => $"{name}.ass",
"webvtt" => $"{name}.vtt",
"mov_text" => $"{name}.srt",
_ => string.Empty
};
@@ -77,6 +77,30 @@ public class EmbyPathReplacementServiceTests
result.Should().Be(@"/mnt/something else/Some Shared Folder/Some Movie/Some Movie.mkv");
}
[Test]
public void EmbyWindows_To_EtvLinux_NetworkPath()
{
var mediaSource = new EmbyMediaSource { OperatingSystem = "Windows" };
var repo = new Mock<IMediaSourceRepository>();
var runtime = new Mock<IRuntimeInfo>();
runtime.Setup(x => x.IsOSPlatform(OSPlatform.Windows)).Returns(false);
var service = new EmbyPathReplacementService(
repo.Object,
runtime.Object,
new Mock<ILogger<EmbyPathReplacementService>>().Object);
string result = service.ReplaceNetworkPath(
mediaSource,
@"\\192.168.1.100\Something\Some Shared Folder\Some Movie\Some Movie.mkv",
@"\\192.168.1.100\Something\Some Shared Folder",
@"C:\mnt\something else\Some Shared Folder");
result.Should().Be(@"C:\mnt\something else\Some Shared Folder\Some Movie\Some Movie.mkv");
}
[Test]
public async Task EmbyWindows_To_EtvLinux_UncPath()
{
+15 -6
View File
@@ -8,23 +8,23 @@
<ItemGroup>
<PackageReference Include="Bugsnag" Version="3.0.1" />
<PackageReference Include="CliWrap" Version="3.4.3" />
<PackageReference Include="CliWrap" Version="3.4.4" />
<PackageReference Include="FluentAssertions" Version="6.6.0" />
<PackageReference Include="LanguageExt.Core" Version="4.0.4" />
<PackageReference Include="LanguageExt.Core" Version="4.1.0" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="6.0.1" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="6.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="6.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="6.0.1" />
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="6.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.1.0" />
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.1.46">
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.2.0" />
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.2.32">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Moq" Version="4.17.2" />
<PackageReference Include="Moq" Version="4.18.0" />
<PackageReference Include="NUnit" Version="3.13.3" />
<PackageReference Include="NUnit3TestAdapter" Version="4.2.1" />
<PackageReference Include="Serilog" Version="2.10.0" />
<PackageReference Include="Serilog" Version="2.11.0" />
<PackageReference Include="Serilog.Extensions.Logging" Version="3.1.0" />
<PackageReference Include="Serilog.Sinks.Debug" Version="2.0.0" />
</ItemGroup>
@@ -37,12 +37,21 @@
<Content Include="Resources\ErsatzTV.png">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
<Content Include="Resources\Nfo\ArtistInvalidCharacters1.nfo">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
<Content Include="Resources\Nfo\ArtistInvalidCharacters2.nfo">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
<Content Include="Resources\test.sup">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
<Content Include="Resources\test.srt">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
<Content Include="Resources\Nfo\EpisodeInvalidCharacters.nfo">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project>
@@ -301,6 +301,7 @@ public class TranscodingTests
oldService,
new FFmpegPlaybackSettingsCalculator(),
new FakeStreamSelector(),
new Mock<ITempFilePool>().Object,
LoggerFactory.CreateLogger<FFmpegLibraryProcessService>());
var v = new MediaVersion
@@ -488,7 +489,8 @@ public class TranscodingTests
TimeSpan.Zero,
TimeSpan.FromSeconds(5),
0,
None);
None,
false);
// Console.WriteLine($"ffmpeg arguments {string.Join(" ", process.StartInfo.ArgumentList)}");
@@ -60,6 +60,22 @@ 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> AddTag(Domain.Metadata metadata, Tag tag) => throw new NotSupportedException();
public Task<bool> AddStudio(ShowMetadata metadata, Studio studio) => throw new NotSupportedException();
public Task<bool> AddActor(ShowMetadata metadata, Actor actor) => throw new NotSupportedException();
public Task<bool> AddActor(EpisodeMetadata metadata, Actor actor) => throw new NotSupportedException();
public Task<Unit> RemoveMetadata(Episode episode, EpisodeMetadata metadata) =>
throw new NotSupportedException();
public Task<bool> AddDirector(EpisodeMetadata metadata, Director director) => throw new NotSupportedException();
public Task<bool> AddWriter(EpisodeMetadata metadata, Writer writer) => throw new NotSupportedException();
public Task<Unit> UpdatePath(int mediaFileId, string path) => throw new NotSupportedException();
public Task<Either<BaseError, MediaItemScanResult<PlexShow>>> GetOrAddPlexShow(
PlexLibrary library,
PlexShow item) =>
@@ -73,14 +89,6 @@ public class FakeTelevisionRepository : ITelevisionRepository
PlexEpisode item) =>
throw new NotSupportedException();
public Task<bool> AddGenre(ShowMetadata 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();
public Task<bool> AddActor(ShowMetadata metadata, Actor actor) => throw new NotSupportedException();
public Task<bool> AddActor(EpisodeMetadata metadata, Actor actor) => throw new NotSupportedException();
public Task<List<int>> RemoveMissingPlexShows(PlexLibrary library, List<string> showKeys) =>
throw new NotSupportedException();
@@ -90,13 +98,6 @@ public class FakeTelevisionRepository : ITelevisionRepository
public Task<List<int>> RemoveMissingPlexEpisodes(string seasonKey, List<string> episodeKeys) =>
throw new NotSupportedException();
public Task<Unit> RemoveMetadata(Episode episode, EpisodeMetadata metadata) =>
throw new NotSupportedException();
public Task<bool> AddDirector(EpisodeMetadata metadata, Director director) => throw new NotSupportedException();
public Task<bool> AddWriter(EpisodeMetadata metadata, Writer writer) => throw new NotSupportedException();
public Task<Unit> UpdatePath(int mediaFileId, string path) => throw new NotSupportedException();
public Task<Unit> SetPlexEtag(PlexShow show, string etag) => throw new NotSupportedException();
public Task<Unit> SetPlexEtag(PlexSeason season, string etag) => throw new NotSupportedException();
@@ -77,6 +77,30 @@ public class JellyfinPathReplacementServiceTests
result.Should().Be(@"/mnt/something else/Some Shared Folder/Some Movie/Some Movie.mkv");
}
[Test]
public void JellyfinWindows_To_EtvLinux_NetworkPath()
{
var mediaSource = new JellyfinMediaSource { OperatingSystem = "Windows" };
var repo = new Mock<IMediaSourceRepository>();
var runtime = new Mock<IRuntimeInfo>();
runtime.Setup(x => x.IsOSPlatform(OSPlatform.Windows)).Returns(false);
var service = new JellyfinPathReplacementService(
repo.Object,
runtime.Object,
new Mock<ILogger<JellyfinPathReplacementService>>().Object);
string result = service.ReplaceNetworkPath(
mediaSource,
@"\\192.168.1.100\Something\Some Shared Folder\Some Movie\Some Movie.mkv",
@"\\192.168.1.100\Something\Some Shared Folder",
@"C:\mnt\something else\Some Shared Folder");
result.Should().Be(@"C:\mnt\something else\Some Shared Folder\Some Movie\Some Movie.mkv");
}
[Test]
public async Task JellyfinWindows_To_EtvLinux_UncPath()
{
@@ -0,0 +1,216 @@
using System.Text;
using Bugsnag;
using ErsatzTV.Core.Metadata.Nfo;
using FluentAssertions;
using Microsoft.Extensions.Logging;
using Microsoft.IO;
using Moq;
using NUnit.Framework;
using Serilog;
namespace ErsatzTV.Core.Tests.Metadata.Nfo;
[TestFixture]
public class ArtistNfoReaderTests
{
[SetUp]
public void SetUp() => _artistNfoReader = new ArtistNfoReader(
new RecyclableMemoryStreamManager(),
new Mock<IClient>().Object,
_logger);
private readonly ILogger<ArtistNfoReader> _logger;
public ArtistNfoReaderTests()
{
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Debug()
.WriteTo.Console()
.CreateLogger();
ILoggerFactory loggerFactory = new LoggerFactory().AddSerilog(Log.Logger);
_logger = loggerFactory.CreateLogger<ArtistNfoReader>();
}
private ArtistNfoReader _artistNfoReader;
[Test]
public async Task ParsingNfo_Should_Return_Error()
{
await using var stream =
new MemoryStream(Encoding.UTF8.GetBytes(@"https://www.themoviedb.org/movie/11-star-wars"));
Either<BaseError, ArtistNfo> result = await _artistNfoReader.Read(stream);
result.IsLeft.Should().BeTrue();
}
[Test]
public async Task MetadataNfo_Should_Return_Nfo()
{
await using var stream = new MemoryStream(Encoding.UTF8.GetBytes(@"<artist></artist>"));
Either<BaseError, ArtistNfo> result = await _artistNfoReader.Read(stream);
result.IsRight.Should().BeTrue();
}
[Test]
public async Task CombinationNfo_Should_Return_Nfo()
{
await using var stream = new MemoryStream(
Encoding.UTF8.GetBytes(
@"<artist></artist>
https://www.themoviedb.org/movie/11-star-wars"));
Either<BaseError, ArtistNfo> result = await _artistNfoReader.Read(stream);
result.IsRight.Should().BeTrue();
}
[Test]
public async Task FullSample_Should_Return_Nfo()
{
await using var stream = new MemoryStream(
Encoding.UTF8.GetBytes(
NormalizeLineEndingsLF(
@"<?xml version=""1.0"" encoding=""UTF-8"" standalone=""yes"" ?>
<artist>
<name>Billy Joel</name>
<musicBrainzArtistID>64b94289-9474-4d43-8c93-918ccc1920d1</musicBrainzArtistID>
<sortname>Joel, Billy</sortname>
<type>Person</type>
<gender>Male</gender>
<disambiguation></disambiguation>
<genre>Pop/Rock</genre>
<style>Album Rock</style>
<style>Contemporary Pop/Rock</style>
<style>Singer/Songwriter</style>
<style>Soft Rock</style>
<style>Keyboard</style>
<mood>Amiable/Good-Natured</mood>
<mood>Autumnal</mood>
<mood>Nostalgic</mood>
<mood>Refined</mood>
<mood>Acerbic</mood>
<mood>Bittersweet</mood>
<mood>Brash</mood>
<mood>Cynical/Sarcastic</mood>
<mood>Earnest</mood>
<yearsactive>1960s - 2010s</yearsactive>
<born>1949-05-09</born>
<formed>1964</formed>
<biography>William Martin &quot;Billy&quot; Joel (born May 9, 1949, New York, USA) is an American pianist, singer-songwriter, and composer. Since releasing his first hit song, &quot;Piano Man&quot;, in 1973, Joel has become the sixth-best-selling recording artist and the third-best-selling solo artist in the United States, according to the RIAA. His compilation album Greatest Hits Vol. 1 &amp; 2 is the third-best-selling album in the United States by discs shipped.&#x0A;Joel had Top 40 hits in the 1970s, 1980s, and 1990s, achieving 33 Top 40 hits in the United States, all of which he wrote himself. He is also a six-time Grammy Award winner, a 23-time Grammy nominee and one of the world&apos;s best-selling artists of all time, having sold over 150 million records worldwide. He was inducted into the Songwriter&apos;s Hall of Fame (1992), the Rock and Roll Hall of Fame (1999), and the Long Island Music Hall of Fame (2006). In 2008, Billboard magazine released a list of the Hot 100 All-Time Top Artists to celebrate the US singles chart&apos;s 50th anniversary, with Billy Joel positioned at No. 23. With the exception of the 2007 songs &quot;All My Life&quot; and &quot;Christmas in Fallujah&quot;, Joel stopped writing and recording popular music after 1993&apos;s River of Dreams, but he continued to tour extensively until 2010. Joel was born in the Bronx, May 9, 1949 and raised in Hicksville, New York in a Levitt home. His father, Howard (born Helmuth), was born in Germany, the son of German merchant and manufacturer Karl Amson Joel, who, after the advent of the Nazi regime, emigrated to Switzerland and later to the United States. Billy Joel&apos;s mother, Rosalind Nyman, was born in England to Philip and Rebecca Nyman. Both of Joel&apos;s parents were Jewish. They divorced in 1960, and his father moved to Vienna, Austria. Billy has a sister, Judith Joel, and a half-brother, Alexander Joel, who is an acclaimed classical conductor in Europe and currently chief musical director of the Staatstheater Braunschweig.&#x0A;Joel&apos;s father was an accomplished classical pianist. Billy reluctantly began piano lessons at an early age, at his mother&apos;s insistence; his teachers included the noted American pianist Morton Estrin and musician/songwriter Timothy Ford. His interest in music, rather than sports, was a source of teasing and bullying in his early years. (He has said in interviews that his piano instructor also taught ballet. Her name was Frances Neiman, and she was a Juilliard trained musician. She gave both classical piano and ballet lessons in the studio attached to the rear of her house, leading neighborhood bullies to mistakenly assume that he was learning to dance.) As a teenager, Joel took up boxing so that he would be able to defend himself. He boxed successfully on the amateur Golden Gloves circuit for a short time, winning twenty-two bouts, but abandoned the sport shortly after breaking his nose in his twenty-fourth boxing match.&#x0A;Joel attended Hicksville High School in 1967, but he did not graduate with his class. He had been helping his single mother make ends meet by playing at a piano bar, which interfered with his school attendance. At the end of his senior year, Joel did not have enough credits to graduate. Rather than attend summer school to earn his diploma, however, Joel decided to immediately begin a career in music. Joel recounted, &quot;I told them, &apos;To hell with it. If I&apos;m not going to Columbia University, I&apos;m going to Columbia Records, and you don&apos;t need a high school diploma over there&apos;.&quot; Columbia did, in fact, become the label that eventually signed him. In 1992, he submitted essays to the school board and was awarded his diploma at Hicksville High&apos;s annual graduation ceremony, 25 years after he had left.</biography>
<died></died>
<disbanded></disbanded>
<thumb spoof="""" cache="""" aspect=""thumb"" preview=""https://assets.fanart.tv/preview/music/64b94289-9474-4d43-8c93-918ccc1920d1/artistthumb/joel-billy-541603848114c.jpg"">https://assets.fanart.tv/fanart/music/64b94289-9474-4d43-8c93-918ccc1920d1/artistthumb/joel-billy-541603848114c.jpg</thumb>
<thumb spoof="""" cache="""" aspect=""thumb"" preview=""https://www.theaudiodb.com/images/media/artist/thumb/ttsxwr1425765041.jpg/preview"">https://www.theaudiodb.com/images/media/artist/thumb/ttsxwr1425765041.jpg</thumb>
<thumb spoof="""" cache="""" aspect=""thumb"" preview=""https://rovimusic.rovicorp.com/image.jpg?c=73pC-Gp0OovlmiQL7Wp5Yd_M69_UI9rrJSVvWL2-yAg=&amp;f=2"">https://rovimusic.rovicorp.com/image.jpg?c=73pC-Gp0OovlmiQL7Wp5Yd_M69_UI9rrJSVvWL2-yAg=&amp;f=0</thumb>
<thumb spoof="""" cache="""" aspect=""thumb"" preview=""https://img.discogs.com/J3bqAiLmdr2gXsetNgSQF2W-f6M=/150x150/smart/filters:strip_icc():format(jpeg):mode_rgb():quality(40)/discogs-images/A-137418-1143052539.jpeg.jpg"">https://img.discogs.com/u7cfC3lZo9JGRdukSttJTZKr9Go=/350x255/smart/filters:strip_icc():format(jpeg):mode_rgb():quality(90)/discogs-images/A-137418-1143052539.jpeg.jpg</thumb>
<thumb spoof="""" cache="""" aspect=""clearlogo"" preview=""https://www.theaudiodb.com/images/media/artist/logo/tvqpys1367246337.png/preview"">https://www.theaudiodb.com/images/media/artist/logo/tvqpys1367246337.png</thumb>
<thumb spoof="""" cache="""" aspect=""clearart"" preview=""https://www.theaudiodb.com/images/media/artist/clearart/yqpsuq1523892204.png/preview"">https://www.theaudiodb.com/images/media/artist/clearart/yqpsuq1523892204.png</thumb>
<thumb spoof="""" cache="""" aspect=""landscape"" preview=""https://www.theaudiodb.com/images/media/artist/widethumb/tywpqx1530815867.jpg/preview"">https://www.theaudiodb.com/images/media/artist/widethumb/tywpqx1530815867.jpg</thumb>
<thumb spoof="""" cache="""" aspect=""banner"" preview=""https://assets.fanart.tv/preview/music/64b94289-9474-4d43-8c93-918ccc1920d1/musicbanner/joel-billy-5914e7759bfcd.jpg"">https://assets.fanart.tv/fanart/music/64b94289-9474-4d43-8c93-918ccc1920d1/musicbanner/joel-billy-5914e7759bfcd.jpg</thumb>
<thumb spoof="""" cache="""" aspect=""clearlogo"" preview=""https://assets.fanart.tv/preview/music/64b94289-9474-4d43-8c93-918ccc1920d1/hdmusiclogo/joel-billy-550b259604412.png"">https://assets.fanart.tv/fanart/music/64b94289-9474-4d43-8c93-918ccc1920d1/hdmusiclogo/joel-billy-550b259604412.png</thumb>
<thumb spoof="""" cache="""" aspect=""fanart"" preview=""https://assets.fanart.tv/preview/music/64b94289-9474-4d43-8c93-918ccc1920d1/artistbackground/joel-billy-4fc0c2dad9ab7.jpg"">https://assets.fanart.tv/fanart/music/64b94289-9474-4d43-8c93-918ccc1920d1/artistbackground/joel-billy-4fc0c2dad9ab7.jpg</thumb>
<thumb spoof="""" cache="""" aspect=""fanart"" preview=""https://www.theaudiodb.com/images/media/artist/fanart/uwqtup1521206367.jpg/preview"">https://www.theaudiodb.com/images/media/artist/fanart/uwqtup1521206367.jpg</thumb>
<path>F:\Music\ArtistInfoKodi\Billy Joel</path>
</artist>")));
Either<BaseError, ArtistNfo> result = await _artistNfoReader.Read(stream);
result.IsRight.Should().BeTrue();
foreach (ArtistNfo nfo in result.RightToSeq())
{
nfo.Name.Should().Be("Billy Joel");
nfo.Disambiguation.Should().BeNullOrEmpty();
nfo.Genres.Should().BeEquivalentTo(new List<string> { "Pop/Rock" });
nfo.Styles.Should().BeEquivalentTo(
new List<string>
{
"Album Rock",
"Contemporary Pop/Rock",
"Singer/Songwriter",
"Soft Rock",
"Keyboard"
});
nfo.Moods.Should().BeEquivalentTo(
new List<string>
{
"Amiable/Good-Natured",
"Autumnal",
"Nostalgic",
"Refined",
"Acerbic",
"Bittersweet",
"Brash",
"Cynical/Sarcastic",
"Earnest"
});
nfo.Biography.Should().Be(
NormalizeLineEndingsLF(
@"William Martin ""Billy"" Joel (born May 9, 1949, New York, USA) is an American pianist, singer-songwriter, and composer. Since releasing his first hit song, ""Piano Man"", in 1973, Joel has become the sixth-best-selling recording artist and the third-best-selling solo artist in the United States, according to the RIAA. His compilation album Greatest Hits Vol. 1 & 2 is the third-best-selling album in the United States by discs shipped.
Joel had Top 40 hits in the 1970s, 1980s, and 1990s, achieving 33 Top 40 hits in the United States, all of which he wrote himself. He is also a six-time Grammy Award winner, a 23-time Grammy nominee and one of the world's best-selling artists of all time, having sold over 150 million records worldwide. He was inducted into the Songwriter's Hall of Fame (1992), the Rock and Roll Hall of Fame (1999), and the Long Island Music Hall of Fame (2006). In 2008, Billboard magazine released a list of the Hot 100 All-Time Top Artists to celebrate the US singles chart's 50th anniversary, with Billy Joel positioned at No. 23. With the exception of the 2007 songs ""All My Life"" and ""Christmas in Fallujah"", Joel stopped writing and recording popular music after 1993's River of Dreams, but he continued to tour extensively until 2010. Joel was born in the Bronx, May 9, 1949 and raised in Hicksville, New York in a Levitt home. His father, Howard (born Helmuth), was born in Germany, the son of German merchant and manufacturer Karl Amson Joel, who, after the advent of the Nazi regime, emigrated to Switzerland and later to the United States. Billy Joel's mother, Rosalind Nyman, was born in England to Philip and Rebecca Nyman. Both of Joel's parents were Jewish. They divorced in 1960, and his father moved to Vienna, Austria. Billy has a sister, Judith Joel, and a half-brother, Alexander Joel, who is an acclaimed classical conductor in Europe and currently chief musical director of the Staatstheater Braunschweig.
Joel's father was an accomplished classical pianist. Billy reluctantly began piano lessons at an early age, at his mother's insistence; his teachers included the noted American pianist Morton Estrin and musician/songwriter Timothy Ford. His interest in music, rather than sports, was a source of teasing and bullying in his early years. (He has said in interviews that his piano instructor also taught ballet. Her name was Frances Neiman, and she was a Juilliard trained musician. She gave both classical piano and ballet lessons in the studio attached to the rear of her house, leading neighborhood bullies to mistakenly assume that he was learning to dance.) As a teenager, Joel took up boxing so that he would be able to defend himself. He boxed successfully on the amateur Golden Gloves circuit for a short time, winning twenty-two bouts, but abandoned the sport shortly after breaking his nose in his twenty-fourth boxing match.
Joel attended Hicksville High School in 1967, but he did not graduate with his class. He had been helping his single mother make ends meet by playing at a piano bar, which interfered with his school attendance. At the end of his senior year, Joel did not have enough credits to graduate. Rather than attend summer school to earn his diploma, however, Joel decided to immediately begin a career in music. Joel recounted, ""I told them, 'To hell with it. If I'm not going to Columbia University, I'm going to Columbia Records, and you don't need a high school diploma over there'."" Columbia did, in fact, become the label that eventually signed him. In 1992, he submitted essays to the school board and was awarded his diploma at Hicksville High's annual graduation ceremony, 25 years after he had left."));
}
}
[Test]
public async Task MetadataNfo_With_Disambiguation_Should_Return_Nfo()
{
await using var stream = new MemoryStream(
Encoding.UTF8.GetBytes(@"<artist><disambiguation>Test Disambiguation</disambiguation></artist>"));
Either<BaseError, ArtistNfo> result = await _artistNfoReader.Read(stream);
result.IsRight.Should().BeTrue();
foreach (ArtistNfo nfo in result.RightToSeq())
{
nfo.Disambiguation.Should().Be("Test Disambiguation");
}
}
[Test]
public async Task Invalid_Characters_End_Should_Abort_And_Return_Nfo()
{
string sourceFile = Path.Combine(
TestContext.CurrentContext.TestDirectory,
"Resources",
"Nfo",
"ArtistInvalidCharacters1.nfo");
Either<BaseError, ArtistNfo> result = await _artistNfoReader.ReadFromFile(sourceFile);
result.IsRight.Should().BeTrue();
foreach (ArtistNfo nfo in result.RightToSeq())
{
nfo.Name.Should().Be("Test Name");
}
}
[Test]
public async Task Invalid_Characters_Middle_Should_Continue_And_Return_Nfo()
{
string sourceFile = Path.Combine(
TestContext.CurrentContext.TestDirectory,
"Resources",
"Nfo",
"ArtistInvalidCharacters2.nfo");
Either<BaseError, ArtistNfo> result = await _artistNfoReader.ReadFromFile(sourceFile);
result.IsRight.Should().BeTrue();
foreach (ArtistNfo nfo in result.RightToSeq())
{
nfo.Name.Should().Be("Test Name");
nfo.Moods.Should().BeEquivalentTo(new List<string> { "Test Mood" });
nfo.Styles.Count.Should().Be(1);
}
}
private static string NormalizeLineEndingsLF(string str) =>
str
.Replace("\r\n", "\n")
.Replace("\r", "\n");
}
@@ -1,17 +1,43 @@
using System.Text;
using Bugsnag;
using ErsatzTV.Core.Metadata.Nfo;
using FluentAssertions;
using Microsoft.Extensions.Logging;
using Microsoft.IO;
using Moq;
using NUnit.Framework;
using Serilog;
namespace ErsatzTV.Core.Tests.Metadata.Nfo;
[TestFixture]
public class EpisodeNfoReaderTests
{
[SetUp]
public void SetUp() => _episodeNfoReader = new EpisodeNfoReader(
new RecyclableMemoryStreamManager(),
new Mock<IClient>().Object,
_logger);
private readonly ILogger<EpisodeNfoReader> _logger;
public EpisodeNfoReaderTests()
{
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Debug()
.WriteTo.Console()
.CreateLogger();
ILoggerFactory loggerFactory = new LoggerFactory().AddSerilog(Log.Logger);
_logger = loggerFactory.CreateLogger<EpisodeNfoReader>();
}
private EpisodeNfoReader _episodeNfoReader;
[Test]
public async Task One()
{
var reader = new EpisodeNfoReader();
var stream = new MemoryStream(
Encoding.UTF8.GetBytes(
@"<?xml version=""1.0"" encoding=""UTF-8"" standalone=""yes""?>
@@ -19,15 +45,18 @@ public class EpisodeNfoReaderTests
<episodedetails>
</episodedetails>"));
List<TvShowEpisodeNfo> result = await reader.Read(stream);
Either<BaseError, List<TvShowEpisodeNfo>> result = await _episodeNfoReader.Read(stream);
result.Count.Should().Be(1);
result.IsRight.Should().BeTrue();
foreach (List<TvShowEpisodeNfo> list in result.RightToSeq())
{
list.Count.Should().Be(1);
}
}
[Test]
public async Task Two()
{
var reader = new EpisodeNfoReader();
var stream = new MemoryStream(
Encoding.UTF8.GetBytes(
@"<?xml version=""1.0"" encoding=""UTF-8"" standalone=""yes""?>
@@ -45,19 +74,22 @@ public class EpisodeNfoReaderTests
<season>1</season>
</episodedetails>"));
List<TvShowEpisodeNfo> result = await reader.Read(stream);
Either<BaseError, List<TvShowEpisodeNfo>> result = await _episodeNfoReader.Read(stream);
result.Count.Should().Be(2);
result.All(nfo => nfo.ShowTitle == "show").Should().BeTrue();
result.All(nfo => nfo.Season == 1).Should().BeTrue();
result.Count(nfo => nfo.Title == "episode-one" && nfo.Episode == 1).Should().Be(1);
result.Count(nfo => nfo.Title == "episode-two" && nfo.Episode == 2).Should().Be(1);
result.IsRight.Should().BeTrue();
foreach (List<TvShowEpisodeNfo> list in result.RightToSeq())
{
list.Count.Should().Be(2);
list.All(nfo => nfo.ShowTitle == "show").Should().BeTrue();
list.All(nfo => nfo.Season == 1).Should().BeTrue();
list.Count(nfo => nfo.Title == "episode-one" && nfo.Episode == 1).Should().Be(1);
list.Count(nfo => nfo.Title == "episode-two" && nfo.Episode == 2).Should().Be(1);
}
}
[Test]
public async Task UniqueIds()
{
var reader = new EpisodeNfoReader();
var stream = new MemoryStream(
Encoding.UTF8.GetBytes(
@"<?xml version=""1.0"" encoding=""UTF-8"" standalone=""yes""?>
@@ -67,18 +99,21 @@ public class EpisodeNfoReaderTests
<uniqueid default=""false"" type=""imdb"">tt54321</uniqueid>
</episodedetails>"));
List<TvShowEpisodeNfo> result = await reader.Read(stream);
Either<BaseError, List<TvShowEpisodeNfo>> result = await _episodeNfoReader.Read(stream);
result.Count.Should().Be(1);
result[0].UniqueIds.Count.Should().Be(2);
result[0].UniqueIds.Count(id => id.Default && id.Type == "tvdb" && id.Guid == "12345").Should().Be(1);
result[0].UniqueIds.Count(id => !id.Default && id.Type == "imdb" && id.Guid == "tt54321").Should().Be(1);
result.IsRight.Should().BeTrue();
foreach (List<TvShowEpisodeNfo> list in result.RightToSeq())
{
list.Count.Should().Be(1);
list[0].UniqueIds.Count.Should().Be(2);
list[0].UniqueIds.Count(id => id.Default && id.Type == "tvdb" && id.Guid == "12345").Should().Be(1);
list[0].UniqueIds.Count(id => !id.Default && id.Type == "imdb" && id.Guid == "tt54321").Should().Be(1);
}
}
[Test]
public async Task No_ContentRating()
{
var reader = new EpisodeNfoReader();
var stream = new MemoryStream(
Encoding.UTF8.GetBytes(
@"<?xml version=""1.0"" encoding=""UTF-8"" standalone=""yes""?>
@@ -87,16 +122,19 @@ public class EpisodeNfoReaderTests
<mpaa/>
</episodedetails>"));
List<TvShowEpisodeNfo> result = await reader.Read(stream);
Either<BaseError, List<TvShowEpisodeNfo>> result = await _episodeNfoReader.Read(stream);
result.Count.Should().Be(1);
result[0].ContentRating.Should().BeNullOrEmpty();
result.IsRight.Should().BeTrue();
foreach (List<TvShowEpisodeNfo> list in result.RightToSeq())
{
list.Count.Should().Be(1);
list[0].ContentRating.Should().BeNullOrEmpty();
}
}
[Test]
public async Task ContentRating()
{
var reader = new EpisodeNfoReader();
var stream = new MemoryStream(
Encoding.UTF8.GetBytes(
@"<?xml version=""1.0"" encoding=""UTF-8"" standalone=""yes""?>
@@ -108,17 +146,20 @@ public class EpisodeNfoReaderTests
<mpaa>US:Something / US:SomethingElse</mpaa>
</episodedetails>"));
List<TvShowEpisodeNfo> result = await reader.Read(stream);
Either<BaseError, List<TvShowEpisodeNfo>> result = await _episodeNfoReader.Read(stream);
result.Count.Should().Be(2);
result.Count(nfo => nfo.ContentRating == "US:Something").Should().Be(1);
result.Count(nfo => nfo.ContentRating == "US:Something / US:SomethingElse").Should().Be(1);
result.IsRight.Should().BeTrue();
foreach (List<TvShowEpisodeNfo> list in result.RightToSeq())
{
list.Count.Should().Be(2);
list.Count(nfo => nfo.ContentRating == "US:Something").Should().Be(1);
list.Count(nfo => nfo.ContentRating == "US:Something / US:SomethingElse").Should().Be(1);
}
}
[Test]
public async Task No_Plot()
{
var reader = new EpisodeNfoReader();
var stream = new MemoryStream(
Encoding.UTF8.GetBytes(
@"<?xml version=""1.0"" encoding=""UTF-8"" standalone=""yes""?>
@@ -127,16 +168,19 @@ public class EpisodeNfoReaderTests
<plot/>
</episodedetails>"));
List<TvShowEpisodeNfo> result = await reader.Read(stream);
Either<BaseError, List<TvShowEpisodeNfo>> result = await _episodeNfoReader.Read(stream);
result.Count.Should().Be(1);
result[0].Plot.Should().BeNullOrEmpty();
result.IsRight.Should().BeTrue();
foreach (List<TvShowEpisodeNfo> list in result.RightToSeq())
{
list.Count.Should().Be(1);
list[0].Plot.Should().BeNullOrEmpty();
}
}
[Test]
public async Task Plot()
{
var reader = new EpisodeNfoReader();
var stream = new MemoryStream(
Encoding.UTF8.GetBytes(
@"<?xml version=""1.0"" encoding=""UTF-8"" standalone=""yes""?>
@@ -145,16 +189,19 @@ public class EpisodeNfoReaderTests
<plot>Some Plot</plot>
</episodedetails>"));
List<TvShowEpisodeNfo> result = await reader.Read(stream);
Either<BaseError, List<TvShowEpisodeNfo>> result = await _episodeNfoReader.Read(stream);
result.Count.Should().Be(1);
result[0].Plot.Should().Be("Some Plot");
result.IsRight.Should().BeTrue();
foreach (List<TvShowEpisodeNfo> list in result.RightToSeq())
{
list.Count.Should().Be(1);
list[0].Plot.Should().Be("Some Plot");
}
}
[Test]
public async Task Actors()
{
var reader = new EpisodeNfoReader();
var stream = new MemoryStream(
Encoding.UTF8.GetBytes(
@"<?xml version=""1.0"" encoding=""UTF-8"" standalone=""yes""?>
@@ -172,20 +219,23 @@ public class EpisodeNfoReaderTests
</actor>
</episodedetails>"));
List<TvShowEpisodeNfo> result = await reader.Read(stream);
Either<BaseError, List<TvShowEpisodeNfo>> result = await _episodeNfoReader.Read(stream);
result.Count.Should().Be(1);
result[0].Actors.Count.Should().Be(2);
result[0].Actors.Count(a => a.Name == "Name 1" && a.Role == "Role 1" && a.Thumb == "Thumb 1")
.Should().Be(1);
result[0].Actors.Count(a => a.Name == "Name 2" && a.Role == "Role 2" && a.Thumb == "Thumb 2")
.Should().Be(1);
result.IsRight.Should().BeTrue();
foreach (List<TvShowEpisodeNfo> list in result.RightToSeq())
{
list.Count.Should().Be(1);
list[0].Actors.Count.Should().Be(2);
list[0].Actors.Count(a => a.Name == "Name 1" && a.Role == "Role 1" && a.Thumb == "Thumb 1")
.Should().Be(1);
list[0].Actors.Count(a => a.Name == "Name 2" && a.Role == "Role 2" && a.Thumb == "Thumb 2")
.Should().Be(1);
}
}
[Test]
public async Task Writers()
{
var reader = new EpisodeNfoReader();
var stream = new MemoryStream(
Encoding.UTF8.GetBytes(
@"<?xml version=""1.0"" encoding=""UTF-8"" standalone=""yes""?>
@@ -198,18 +248,21 @@ public class EpisodeNfoReaderTests
<credits>Writer 3</credits>
</episodedetails>"));
List<TvShowEpisodeNfo> result = await reader.Read(stream);
Either<BaseError, List<TvShowEpisodeNfo>> result = await _episodeNfoReader.Read(stream);
result.Count.Should().Be(2);
result.Count(nfo => nfo.Writers.Count == 1 && nfo.Writers[0] == "Writer 1").Should().Be(1);
result.Count(nfo => nfo.Writers.Count == 2 && nfo.Writers[0] == "Writer 2" && nfo.Writers[1] == "Writer 3")
.Should().Be(1);
result.IsRight.Should().BeTrue();
foreach (List<TvShowEpisodeNfo> list in result.RightToSeq())
{
list.Count.Should().Be(2);
list.Count(nfo => nfo.Writers.Count == 1 && nfo.Writers[0] == "Writer 1").Should().Be(1);
list.Count(nfo => nfo.Writers.Count == 2 && nfo.Writers[0] == "Writer 2" && nfo.Writers[1] == "Writer 3")
.Should().Be(1);
}
}
[Test]
public async Task Directors()
{
var reader = new EpisodeNfoReader();
var stream = new MemoryStream(
Encoding.UTF8.GetBytes(
@"<?xml version=""1.0"" encoding=""UTF-8"" standalone=""yes""?>
@@ -222,13 +275,171 @@ public class EpisodeNfoReaderTests
<director>Director 3</director>
</episodedetails>"));
List<TvShowEpisodeNfo> result = await reader.Read(stream);
Either<BaseError, List<TvShowEpisodeNfo>> result = await _episodeNfoReader.Read(stream);
result.Count.Should().Be(2);
result.Count(nfo => nfo.Directors.Count == 1 && nfo.Directors[0] == "Director 1").Should().Be(1);
result.Count(
nfo => nfo.Directors.Count == 2 && nfo.Directors[0] == "Director 2" &&
nfo.Directors[1] == "Director 3")
.Should().Be(1);
result.IsRight.Should().BeTrue();
foreach (List<TvShowEpisodeNfo> list in result.RightToSeq())
{
list.Count.Should().Be(2);
list.Count(nfo => nfo.Directors.Count == 1 && nfo.Directors[0] == "Director 1").Should().Be(1);
list.Count(
nfo => nfo.Directors.Count == 2 && nfo.Directors[0] == "Director 2" &&
nfo.Directors[1] == "Director 3")
.Should().Be(1);
}
}
[Test]
public async Task FullSample_Should_Return_Nfo()
{
await using var stream = new MemoryStream(
Encoding.UTF8.GetBytes(
@"<?xml version=""1.0"" encoding=""UTF-8"" standalone=""yes"" ?>
<episodedetails>
<title>Filmed Before a Live Studio Audience</title>
<showtitle>WandaVision</showtitle>
<ratings>
<rating name=""imdb"" max=""10"" default=""true"">
<value>7.500000</value>
<votes>18766</votes>
</rating>
<rating name=""tmdb"" max=""10"">
<value>7.500000</value>
<votes>42</votes>
</rating>
<rating name=""trakt"" max=""10"">
<value>6.952780</value>
<votes>3621</votes>
</rating>
</ratings>
<userrating>0</userrating>
<top250>0</top250>
<season>1</season>
<episode>1</episode>
<displayseason>-1</displayseason>
<displayepisode>-1</displayepisode>
<outline></outline>
<plot>Wanda and Vision struggle to conceal their powers during dinner with Visions boss and his wife.</plot>
<tagline></tagline>
<runtime>26</runtime>
<thumb spoof="""" cache="""" aspect=""thumb"" preview=""https://image.tmdb.org/t/p/w780/cbe8l0Hnbvu07ePgoOopyWYrcdL.jpg"">https://image.tmdb.org/t/p/original/cbe8l0Hnbvu07ePgoOopyWYrcdL.jpg</thumb>
<thumb spoof="""" cache="""" aspect=""thumb"" preview=""https://image.tmdb.org/t/p/w780/oNCzeCXFanVEWNpzRzyffhLLfZs.jpg"">https://image.tmdb.org/t/p/original/oNCzeCXFanVEWNpzRzyffhLLfZs.jpg</thumb>
<mpaa>Australia:TV-14</mpaa>
<playcount>1</playcount>
<lastplayed>2021-03-27</lastplayed>
<id>1830976</id>
<uniqueid type=""imdb"">tt9601584</uniqueid>
<uniqueid type=""tmdb"" default=""true"">1830976</uniqueid>
<uniqueid type=""tvdb"">8042515</uniqueid>
<genre>Sci-Fi &amp; Fantasy</genre>
<genre>Mystery</genre>
<genre>Drama</genre>
<credits>Jac Schaeffer</credits>
<director>Matt Shakman</director>
<premiered>2021-01-15</premiered>
<year>2021</year>
<status></status>
<code></code>
<aired>2021-01-15</aired>
<studio>Disney+ (US)</studio>
<trailer></trailer>
<fileinfo>
<streamdetails>
<video>
<codec>h264</codec>
<aspect>1.777778</aspect>
<width>1280</width>
<height>720</height>
<durationinseconds>1593</durationinseconds>
<stereomode></stereomode>
</video>
<audio>
<codec>aac</codec>
<language>eng</language>
<channels>2</channels>
</audio>
</streamdetails>
</fileinfo>
<actor>
<name>Randall Park</name>
<role>Jimmy Woo</role>
<order>4</order>
<thumb>https://image.tmdb.org/t/p/original/1QJ4cBQZoOaLR8Hc3V2NgBLvB0f.jpg</thumb>
</actor>
<actor>
<name>Kat Dennings</name>
<role>Darcy Lewis / The Escape Artist</role>
<order>5</order>
<thumb>https://image.tmdb.org/t/p/original/rrfyo9z1wW5nY9ZsFlj1Ozfj9g2.jpg</thumb>
</actor>
<resume>
<position>0.000000</position>
<total>0.000000</total>
</resume>
<dateadded>2021-02-02 11:57:44</dateadded>
</episodedetails>"));
Either<BaseError, List<TvShowEpisodeNfo>> result = await _episodeNfoReader.Read(stream);
result.IsRight.Should().BeTrue();
foreach (TvShowEpisodeNfo nfo in result.RightToSeq().Flatten())
{
nfo.ShowTitle.Should().Be("WandaVision");
nfo.Title.Should().Be("Filmed Before a Live Studio Audience");
nfo.Episode.Should().Be(1);
nfo.Season.Should().Be(1);
nfo.ContentRating.Should().Be("Australia:TV-14");
nfo.Aired.IsSome.Should().BeTrue();
foreach (DateTime aired in nfo.Aired)
{
aired.Should().Be(new DateTime(2021, 01, 15));
}
nfo.Plot.Should().Be(
"Wanda and Vision struggle to conceal their powers during dinner with Visions boss and his wife.");
nfo.Actors.Should().BeEquivalentTo(
new List<ActorNfo>
{
new()
{
Name = "Randall Park", Order = 4, Role = "Jimmy Woo",
Thumb = "https://image.tmdb.org/t/p/original/1QJ4cBQZoOaLR8Hc3V2NgBLvB0f.jpg"
},
new()
{
Name = "Kat Dennings", Order = 5, Role = "Darcy Lewis / The Escape Artist",
Thumb = "https://image.tmdb.org/t/p/original/rrfyo9z1wW5nY9ZsFlj1Ozfj9g2.jpg"
}
});
nfo.Writers.Should().BeEquivalentTo(new List<string> { "Jac Schaeffer" });
nfo.Directors.Should().BeEquivalentTo(new List<string> { "Matt Shakman" });
nfo.UniqueIds.Should().BeEquivalentTo(
new List<UniqueIdNfo>
{
new() { Type = "imdb", Guid = "tt9601584", Default = false },
new() { Type = "tmdb", Guid = "1830976", Default = true },
new() { Type = "tvdb", Guid = "8042515", Default = false }
});
}
}
[Test]
public async Task Invalid_Characters_Should_Abort_And_Return_Nfo()
{
string sourceFile = Path.Combine(
TestContext.CurrentContext.TestDirectory,
"Resources",
"Nfo",
"EpisodeInvalidCharacters.nfo");
Either<BaseError, List<TvShowEpisodeNfo>> result = await _episodeNfoReader.ReadFromFile(sourceFile);
result.IsRight.Should().BeTrue();
foreach (List<TvShowEpisodeNfo> list in result.RightToSeq())
{
list.Count.Should().Be(1);
list[0].Title.Should().Be("Test Title");
}
}
}
@@ -0,0 +1,252 @@
using System.Text;
using Bugsnag;
using ErsatzTV.Core.Metadata.Nfo;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.IO;
using Moq;
using NUnit.Framework;
namespace ErsatzTV.Core.Tests.Metadata.Nfo;
[TestFixture]
public class MovieNfoReaderTests
{
[SetUp]
public void SetUp() => _movieNfoReader = new MovieNfoReader(
new RecyclableMemoryStreamManager(),
new Mock<IClient>().Object,
new NullLogger<MovieNfoReader>());
private MovieNfoReader _movieNfoReader;
[Test]
public async Task ParsingNfo_Should_Return_Error()
{
await using var stream =
new MemoryStream(Encoding.UTF8.GetBytes(@"https://www.themoviedb.org/movie/11-star-wars"));
Either<BaseError, MovieNfo> result = await _movieNfoReader.Read(stream);
result.IsLeft.Should().BeTrue();
}
[Test]
public async Task MetadataNfo_Should_Return_Nfo()
{
await using var stream = new MemoryStream(Encoding.UTF8.GetBytes(@"<movie></movie>"));
Either<BaseError, MovieNfo> result = await _movieNfoReader.Read(stream);
result.IsRight.Should().BeTrue();
}
[Test]
public async Task CombinationNfo_Should_Return_Nfo()
{
await using var stream = new MemoryStream(
Encoding.UTF8.GetBytes(
@"<movie></movie>
https://www.themoviedb.org/movie/11-star-wars"));
Either<BaseError, MovieNfo> result = await _movieNfoReader.Read(stream);
result.IsRight.Should().BeTrue();
}
[Test]
public async Task FullSample_Should_Return_Nfo()
{
await using var stream = new MemoryStream(
Encoding.UTF8.GetBytes(
@"<?xml version=""1.0"" encoding=""UTF-8"" standalone=""yes"" ?>
<movie>
<title>Zack Snyder&apos;s Justice League</title>
<originaltitle>Zack Snyder&apos;s Justice League</originaltitle>
<sorttitle>Justice League 2</sorttitle>
<ratings>
<rating name=""imdb"" max=""10"" default=""true"">
<value>8.300000</value>
<votes>197786</votes>
</rating>
<rating name=""themoviedb"" max=""10"">
<value>8.700000</value>
<votes>3461</votes>
</rating>
<rating name=""trakt"" max=""10"">
<value>8.195670</value>
<votes>4247</votes>
</rating>
</ratings>
<userrating>0</userrating>
<top250>140</top250>
<outline></outline>
<plot>Determined to ensure Superman&apos;s ultimate sacrifice was not in vain, Bruce Wayne aligns forces with Diana Prince with plans to recruit a team of metahumans to protect the world from an approaching threat of catastrophic proportions.</plot>
<tagline></tagline>
<runtime>242</runtime>
<thumb spoof="""" cache="""" aspect=""poster"" preview="""">https://assets.fanart.tv/fanart/movies/791373/movieposter/zack-snyders-justice-league-603fdb873f474.jpg</thumb>
<thumb spoof="""" cache="""" aspect=""poster"" preview="""">https://image.tmdb.org/t/p/original/tnAuB8q5vv7Ax9UAEje5Xi4BXik.jpg</thumb>
<thumb spoof="""" cache="""" aspect=""landscape"" preview="""">https://assets.fanart.tv/fanart/movies/791373/moviethumb/zack-snyders-justice-league-6050310135cf6.jpg</thumb>
<thumb spoof="""" cache="""" aspect=""landscape"" preview="""">https://image.tmdb.org/t/p/original/wcYBuOZDP6Vi8Ye4qax3Zx9dCan.jpg</thumb>
<thumb spoof="""" cache="""" aspect=""keyart"" preview="""">https://assets.fanart.tv/fanart/movies/791373/movieposter/zack-snyders-justice-league-603fdba9bdd16.jpg</thumb>
<thumb spoof="""" cache="""" aspect=""clearlogo"" preview="""">https://assets.fanart.tv/fanart/movies/791373/hdmovielogo/zack-snyders-justice-league-5ed3f2e4952e9.png</thumb>
<thumb spoof="""" cache="""" aspect=""banner"" preview="""">https://assets.fanart.tv/fanart/movies/791373/moviebanner/zack-snyders-justice-league-6050049514d4c.jpg</thumb>
<fanart>
<thumb colors="""" preview=""https://assets.fanart.tv/preview/movies/791373/moviebackground/zack-snyders-justice-league-5fee5b9fe0e0d.jpg"">https://assets.fanart.tv/fanart/movies/791373/moviebackground/zack-snyders-justice-league-5fee5b9fe0e0d.jpg</thumb>
<thumb colors="""" preview=""https://image.tmdb.org/t/p/w780/43NwryODVEsbBDC0jK3wYfVyb5q.jpg"">https://image.tmdb.org/t/p/original/43NwryODVEsbBDC0jK3wYfVyb5q.jpg</thumb>
</fanart>
<mpaa>Australia:M</mpaa>
<playcount>0</playcount>
<lastplayed></lastplayed>
<id>791373</id>
<uniqueid type=""imdb"">tt12361974</uniqueid>
<uniqueid type=""tmdb"" default=""true"">791373</uniqueid>
<genre>SuperHero</genre>
<tag>TV Recording</tag>
<set>
<name>Justice League Collection</name>
<overview>Based on the DC Comics superhero team</overview>
</set>
<country>USA</country>
<credits>Chris Terrio</credits>
<director>Zack Snyder</director>
<premiered>2021-03-18</premiered>
<year>2021</year>
<status></status>
<code></code>
<aired></aired>
<studio>Warner Bros. Pictures</studio>
<trailer></trailer>
<fileinfo>
<streamdetails>
<video>
<codec>hevc</codec>
<aspect>1.777778</aspect>
<width>1920</width>
<height>1080</height>
<durationinseconds>14528</durationinseconds>
<stereomode></stereomode>
</video>
<audio>
<codec>ac3</codec>
<language>eng</language>
<channels>6</channels>
</audio>
<audio>
<codec>ac3</codec>
<language>fre</language>
<channels>6</channels>
</audio>
<subtitle>
<language>eng</language>
</subtitle>
</streamdetails>
</fileinfo>
<actor>
<name>Ben Affleck</name>
<role>Bruce Wayne / Batman</role>
<order>0</order>
<thumb>https://image.tmdb.org/t/p/original/u525jeDOzg9hVdvYfeehTGnw7Aa.jpg</thumb>
</actor>
<actor>
<name>Henry Cavill</name>
<role>Clark Kent / Superman / Kal-El</role>
<order>1</order>
<thumb>https://image.tmdb.org/t/p/original/hErUwonrQgY5Y7RfxOfv8Fq11MB.jpg</thumb>
</actor>
<actor>
<name>Gal Gadot</name>
<role>Diana Prince / Wonder Woman</role>
<order>2</order>
<thumb>https://image.tmdb.org/t/p/original/fysvehTvU6bE3JgxaOTRfvQJzJ4.jpg</thumb>
</actor>
<resume>
<position>0.000000</position>
<total>0.000000</total>
</resume>
<dateadded>2021-03-26 11:35:50</dateadded>
</movie>"));
Either<BaseError, MovieNfo> result = await _movieNfoReader.Read(stream);
result.IsRight.Should().BeTrue();
foreach (MovieNfo nfo in result.RightToSeq())
{
nfo.Title.Should().Be("Zack Snyder's Justice League");
nfo.SortTitle.Should().Be("Justice League 2");
nfo.Outline.Should().BeNullOrEmpty();
nfo.Year.Should().Be(2021);
nfo.ContentRating.Should().Be("Australia:M");
nfo.Premiered.IsSome.Should().BeTrue();
foreach (DateTime premiered in nfo.Premiered)
{
premiered.Should().Be(new DateTime(2021, 03, 18));
}
nfo.Plot.Should().Be(
"Determined to ensure Superman's ultimate sacrifice was not in vain, Bruce Wayne aligns forces with Diana Prince with plans to recruit a team of metahumans to protect the world from an approaching threat of catastrophic proportions.");
nfo.Tagline.Should().BeNullOrEmpty();
nfo.Genres.Should().BeEquivalentTo(new List<string> { "SuperHero" });
nfo.Tags.Should().BeEquivalentTo(new List<string> { "TV Recording" });
nfo.Studios.Should().BeEquivalentTo(new List<string> { "Warner Bros. Pictures" });
nfo.Actors.Should().BeEquivalentTo(
new List<ActorNfo>
{
new()
{
Name = "Ben Affleck", Order = 0, Role = "Bruce Wayne / Batman",
Thumb = "https://image.tmdb.org/t/p/original/u525jeDOzg9hVdvYfeehTGnw7Aa.jpg"
},
new()
{
Name = "Henry Cavill", Order = 1, Role = "Clark Kent / Superman / Kal-El",
Thumb = "https://image.tmdb.org/t/p/original/hErUwonrQgY5Y7RfxOfv8Fq11MB.jpg"
},
new()
{
Name = "Gal Gadot", Order = 2, Role = "Diana Prince / Wonder Woman",
Thumb = "https://image.tmdb.org/t/p/original/fysvehTvU6bE3JgxaOTRfvQJzJ4.jpg"
}
});
nfo.Writers.Should().BeEquivalentTo(new List<string> { "Chris Terrio" });
nfo.Directors.Should().BeEquivalentTo(new List<string> { "Zack Snyder" });
nfo.UniqueIds.Should().BeEquivalentTo(
new List<UniqueIdNfo>
{
new() { Type = "imdb", Guid = "tt12361974", Default = false },
new() { Type = "tmdb", Guid = "791373", Default = true }
});
}
}
[Test]
public async Task MetadataNfo_With_Tag_Should_Return_Nfo()
{
await using var stream = new MemoryStream(Encoding.UTF8.GetBytes(@"<movie><tag>Test Tag</tag></movie>"));
Either<BaseError, MovieNfo> result = await _movieNfoReader.Read(stream);
result.IsRight.Should().BeTrue();
foreach (MovieNfo nfo in result.RightToSeq())
{
nfo.Tags.Should().BeEquivalentTo(new List<string> { "Test Tag" });
}
}
[Test]
public async Task MetadataNfo_With_Outline_Should_Return_Nfo()
{
await using var stream =
new MemoryStream(Encoding.UTF8.GetBytes(@"<movie><outline>Test Outline</outline></movie>"));
Either<BaseError, MovieNfo> result = await _movieNfoReader.Read(stream);
result.IsRight.Should().BeTrue();
foreach (MovieNfo nfo in result.RightToSeq())
{
nfo.Outline.Should().Be("Test Outline");
}
}
}
@@ -0,0 +1,189 @@
using System.Text;
using Bugsnag;
using ErsatzTV.Core.Metadata.Nfo;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.IO;
using Moq;
using NUnit.Framework;
namespace ErsatzTV.Core.Tests.Metadata.Nfo;
[TestFixture]
public class MusicVideoNfoReaderTests
{
[SetUp]
public void SetUp() => _musicVideoNfoReader = new MusicVideoNfoReader(
new RecyclableMemoryStreamManager(),
new Mock<IClient>().Object,
new NullLogger<MusicVideoNfoReader>());
private MusicVideoNfoReader _musicVideoNfoReader;
[Test]
public async Task ParsingNfo_Should_Return_Error()
{
await using var stream =
new MemoryStream(Encoding.UTF8.GetBytes(@"https://www.themoviedb.org/movie/11-star-wars"));
Either<BaseError, MusicVideoNfo> result = await _musicVideoNfoReader.Read(stream);
result.IsLeft.Should().BeTrue();
}
[Test]
public async Task MetadataNfo_Should_Return_Nfo()
{
await using var stream = new MemoryStream(Encoding.UTF8.GetBytes(@"<musicvideo></musicvideo>"));
Either<BaseError, MusicVideoNfo> result = await _musicVideoNfoReader.Read(stream);
result.IsRight.Should().BeTrue();
}
[Test]
public async Task CombinationNfo_Should_Return_Nfo()
{
await using var stream = new MemoryStream(
Encoding.UTF8.GetBytes(
@"<musicvideo></musicvideo>
https://www.themoviedb.org/movie/11-star-wars"));
Either<BaseError, MusicVideoNfo> result = await _musicVideoNfoReader.Read(stream);
result.IsRight.Should().BeTrue();
}
[Test]
public async Task FullSample_Should_Return_Nfo()
{
await using var stream = new MemoryStream(
Encoding.UTF8.GetBytes(
NormalizeLineEndingsLF(
@"<?xml version=""1.0"" encoding=""UTF-8"" standalone=""yes"" ?>
<musicvideo>
<title>Dancing Queen</title>
<userrating>0</userrating>
<top250>0</top250>
<track>-1</track>
<album>Arrival</album>
<outline></outline>
<plot>Dancing Queen est un des tubes emblématiques de l&apos;ère disco produits par le groupe suédois ABBA en 1976. Ce tube connaît un regain de popularité en 1994 lors de la sortie de Priscilla, folle du désert, et fait « presque » partie de la distribution du film Muriel.&#x0A;Le groupe a également enregistré une version espagnole de ce titre, La reina del baile, pour le marché d&apos;Amérique latine. On peut retrouver ces versions en espagnol des succès de ABBA sur l&apos;abum Oro. Le 18 juin 1976, ABBA a interprété cette chanson lors d&apos;un spectacle télévisé organisé en l&apos;honneur du roi Charles XVI Gustave de Suède, qui venait de se marier. Le titre sera repris en 2011 par Glee dans la saison 2, épisode 20.</plot>
<tagline></tagline>
<runtime>2</runtime>
<thumb preview=""https://www.theaudiodb.com/images/media/album/thumb/arrival-4ee244732bbde.jpg/preview"">https://www.theaudiodb.com/images/media/album/thumb/arrival-4ee244732bbde.jpg</thumb>
<thumb preview=""https://assets.fanart.tv/preview/music/d87e52c5-bb8d-4da8-b941-9f4928627dc8/albumcover/arrival-548ab7a698b49.jpg"">https://assets.fanart.tv/fanart/music/d87e52c5-bb8d-4da8-b941-9f4928627dc8/albumcover/arrival-548ab7a698b49.jpg</thumb>
<mpaa></mpaa>
<playcount>0</playcount>
<lastplayed></lastplayed>
<id></id>
<genre>Pop</genre>
<year>1976</year>
<status></status>
<director>Director 1</director>
<director>Director 2</director>
<director>Director 3</director>
<director>Director 4</director>
<code></code>
<aired></aired>
<trailer></trailer>
<fileinfo>
<streamdetails>
<video>
<codec>hevc</codec>
<aspect>1.792230</aspect>
<width>716</width>
<height>568</height>
<durationinseconds>143</durationinseconds>
<stereomode></stereomode>
</video>
<audio>
<codec>ac3</codec>
<language>eng</language>
<channels>2</channels>
</audio>
</streamdetails>
</fileinfo>
<artist>ABBA</artist>
<resume>
<position>0.000000</position>
<total>0.000000</total>
</resume>
<dateadded>2018-09-10 09:46:06</dateadded>
</musicvideo>")));
Either<BaseError, MusicVideoNfo> result = await _musicVideoNfoReader.Read(stream);
result.IsRight.Should().BeTrue();
foreach (MusicVideoNfo nfo in result.RightToSeq())
{
nfo.Artists.Should().BeEquivalentTo(new List<string> { "ABBA" });
nfo.Title.Should().Be("Dancing Queen");
nfo.Album.Should().Be("Arrival");
nfo.Plot.Should().Be(
NormalizeLineEndingsLF(
@"Dancing Queen est un des tubes emblématiques de l'ère disco produits par le groupe suédois ABBA en 1976. Ce tube connaît un regain de popularité en 1994 lors de la sortie de Priscilla, folle du désert, et fait « presque » partie de la distribution du film Muriel.
Le groupe a également enregistré une version espagnole de ce titre, La reina del baile, pour le marché d'Amérique latine. On peut retrouver ces versions en espagnol des succès de ABBA sur l'abum Oro. Le 18 juin 1976, ABBA a interprété cette chanson lors d'un spectacle télévisé organisé en l'honneur du roi Charles XVI Gustave de Suède, qui venait de se marier. Le titre sera repris en 2011 par Glee dans la saison 2, épisode 20."));
nfo.Year.Should().Be(1976);
nfo.Aired.IsNone.Should().BeTrue();
nfo.Genres.Should().BeEquivalentTo(new List<string> { "Pop" });
nfo.Track.Should().Be(-1);
}
}
[Test]
public async Task MetadataNfo_With_Tags_Should_Return_Nfo()
{
await using var stream = new MemoryStream(
Encoding.UTF8.GetBytes(@"<musicvideo><tag>Test Tag</tag></musicvideo>"));
Either<BaseError, MusicVideoNfo> result = await _musicVideoNfoReader.Read(stream);
result.IsRight.Should().BeTrue();
foreach (MusicVideoNfo nfo in result.RightToSeq())
{
nfo.Tags.Should().BeEquivalentTo(new List<string> { "Test Tag" });
}
}
[Test]
public async Task MetadataNfo_With_Aired_Should_Return_Nfo()
{
await using var stream = new MemoryStream(
Encoding.UTF8.GetBytes(@"<musicvideo><aired>2022-02-03</aired></musicvideo>"));
Either<BaseError, MusicVideoNfo> result = await _musicVideoNfoReader.Read(stream);
result.IsRight.Should().BeTrue();
foreach (MusicVideoNfo nfo in result.RightToSeq())
{
nfo.Aired.IsSome.Should().BeTrue();
foreach (DateTime aired in nfo.Aired)
{
aired.Should().Be(new DateTime(2022, 02, 03));
}
}
}
[Test]
public async Task MetadataNfo_With_Studios_Should_Return_Nfo()
{
await using var stream = new MemoryStream(
Encoding.UTF8.GetBytes(@"<musicvideo><studio>Test Studio</studio></musicvideo>"));
Either<BaseError, MusicVideoNfo> result = await _musicVideoNfoReader.Read(stream);
result.IsRight.Should().BeTrue();
foreach (MusicVideoNfo nfo in result.RightToSeq())
{
nfo.Studios.Should().BeEquivalentTo(new List<string> { "Test Studio" });
}
}
private static string NormalizeLineEndingsLF(string str) =>
str
.Replace("\r\n", "\n")
.Replace("\r", "\n");
}
@@ -0,0 +1,252 @@
using System.Text;
using Bugsnag;
using ErsatzTV.Core.Metadata.Nfo;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.IO;
using Moq;
using NUnit.Framework;
namespace ErsatzTV.Core.Tests.Metadata.Nfo;
[TestFixture]
public class OtherVideoNfoReaderTests
{
[SetUp]
public void SetUp() => _otherVideoNfoReader = new OtherVideoNfoReader(
new RecyclableMemoryStreamManager(),
new Mock<IClient>().Object,
new NullLogger<OtherVideoNfoReader>());
private OtherVideoNfoReader _otherVideoNfoReader;
[Test]
public async Task ParsingNfo_Should_Return_Error()
{
await using var stream =
new MemoryStream(Encoding.UTF8.GetBytes(@"https://www.themoviedb.org/movie/11-star-wars"));
Either<BaseError, OtherVideoNfo> result = await _otherVideoNfoReader.Read(stream);
result.IsLeft.Should().BeTrue();
}
[Test]
public async Task MetadataNfo_Should_Return_Nfo()
{
await using var stream = new MemoryStream(Encoding.UTF8.GetBytes(@"<movie></movie>"));
Either<BaseError, OtherVideoNfo> result = await _otherVideoNfoReader.Read(stream);
result.IsRight.Should().BeTrue();
}
[Test]
public async Task CombinationNfo_Should_Return_Nfo()
{
await using var stream = new MemoryStream(
Encoding.UTF8.GetBytes(
@"<movie></movie>
https://www.themoviedb.org/movie/11-star-wars"));
Either<BaseError, OtherVideoNfo> result = await _otherVideoNfoReader.Read(stream);
result.IsRight.Should().BeTrue();
}
[Test]
public async Task FullSample_Should_Return_Nfo()
{
await using var stream = new MemoryStream(
Encoding.UTF8.GetBytes(
@"<?xml version=""1.0"" encoding=""UTF-8"" standalone=""yes"" ?>
<movie>
<title>Zack Snyder&apos;s Justice League</title>
<originaltitle>Zack Snyder&apos;s Justice League</originaltitle>
<sorttitle>Justice League 2</sorttitle>
<ratings>
<rating name=""imdb"" max=""10"" default=""true"">
<value>8.300000</value>
<votes>197786</votes>
</rating>
<rating name=""themoviedb"" max=""10"">
<value>8.700000</value>
<votes>3461</votes>
</rating>
<rating name=""trakt"" max=""10"">
<value>8.195670</value>
<votes>4247</votes>
</rating>
</ratings>
<userrating>0</userrating>
<top250>140</top250>
<outline></outline>
<plot>Determined to ensure Superman&apos;s ultimate sacrifice was not in vain, Bruce Wayne aligns forces with Diana Prince with plans to recruit a team of metahumans to protect the world from an approaching threat of catastrophic proportions.</plot>
<tagline></tagline>
<runtime>242</runtime>
<thumb spoof="""" cache="""" aspect=""poster"" preview="""">https://assets.fanart.tv/fanart/movies/791373/movieposter/zack-snyders-justice-league-603fdb873f474.jpg</thumb>
<thumb spoof="""" cache="""" aspect=""poster"" preview="""">https://image.tmdb.org/t/p/original/tnAuB8q5vv7Ax9UAEje5Xi4BXik.jpg</thumb>
<thumb spoof="""" cache="""" aspect=""landscape"" preview="""">https://assets.fanart.tv/fanart/movies/791373/moviethumb/zack-snyders-justice-league-6050310135cf6.jpg</thumb>
<thumb spoof="""" cache="""" aspect=""landscape"" preview="""">https://image.tmdb.org/t/p/original/wcYBuOZDP6Vi8Ye4qax3Zx9dCan.jpg</thumb>
<thumb spoof="""" cache="""" aspect=""keyart"" preview="""">https://assets.fanart.tv/fanart/movies/791373/movieposter/zack-snyders-justice-league-603fdba9bdd16.jpg</thumb>
<thumb spoof="""" cache="""" aspect=""clearlogo"" preview="""">https://assets.fanart.tv/fanart/movies/791373/hdmovielogo/zack-snyders-justice-league-5ed3f2e4952e9.png</thumb>
<thumb spoof="""" cache="""" aspect=""banner"" preview="""">https://assets.fanart.tv/fanart/movies/791373/moviebanner/zack-snyders-justice-league-6050049514d4c.jpg</thumb>
<fanart>
<thumb colors="""" preview=""https://assets.fanart.tv/preview/movies/791373/moviebackground/zack-snyders-justice-league-5fee5b9fe0e0d.jpg"">https://assets.fanart.tv/fanart/movies/791373/moviebackground/zack-snyders-justice-league-5fee5b9fe0e0d.jpg</thumb>
<thumb colors="""" preview=""https://image.tmdb.org/t/p/w780/43NwryODVEsbBDC0jK3wYfVyb5q.jpg"">https://image.tmdb.org/t/p/original/43NwryODVEsbBDC0jK3wYfVyb5q.jpg</thumb>
</fanart>
<mpaa>Australia:M</mpaa>
<playcount>0</playcount>
<lastplayed></lastplayed>
<id>791373</id>
<uniqueid type=""imdb"">tt12361974</uniqueid>
<uniqueid type=""tmdb"" default=""true"">791373</uniqueid>
<genre>SuperHero</genre>
<tag>TV Recording</tag>
<set>
<name>Justice League Collection</name>
<overview>Based on the DC Comics superhero team</overview>
</set>
<country>USA</country>
<credits>Chris Terrio</credits>
<director>Zack Snyder</director>
<premiered>2021-03-18</premiered>
<year>2021</year>
<status></status>
<code></code>
<aired></aired>
<studio>Warner Bros. Pictures</studio>
<trailer></trailer>
<fileinfo>
<streamdetails>
<video>
<codec>hevc</codec>
<aspect>1.777778</aspect>
<width>1920</width>
<height>1080</height>
<durationinseconds>14528</durationinseconds>
<stereomode></stereomode>
</video>
<audio>
<codec>ac3</codec>
<language>eng</language>
<channels>6</channels>
</audio>
<audio>
<codec>ac3</codec>
<language>fre</language>
<channels>6</channels>
</audio>
<subtitle>
<language>eng</language>
</subtitle>
</streamdetails>
</fileinfo>
<actor>
<name>Ben Affleck</name>
<role>Bruce Wayne / Batman</role>
<order>0</order>
<thumb>https://image.tmdb.org/t/p/original/u525jeDOzg9hVdvYfeehTGnw7Aa.jpg</thumb>
</actor>
<actor>
<name>Henry Cavill</name>
<role>Clark Kent / Superman / Kal-El</role>
<order>1</order>
<thumb>https://image.tmdb.org/t/p/original/hErUwonrQgY5Y7RfxOfv8Fq11MB.jpg</thumb>
</actor>
<actor>
<name>Gal Gadot</name>
<role>Diana Prince / Wonder Woman</role>
<order>2</order>
<thumb>https://image.tmdb.org/t/p/original/fysvehTvU6bE3JgxaOTRfvQJzJ4.jpg</thumb>
</actor>
<resume>
<position>0.000000</position>
<total>0.000000</total>
</resume>
<dateadded>2021-03-26 11:35:50</dateadded>
</movie>"));
Either<BaseError, OtherVideoNfo> result = await _otherVideoNfoReader.Read(stream);
result.IsRight.Should().BeTrue();
foreach (OtherVideoNfo nfo in result.RightToSeq())
{
nfo.Title.Should().Be("Zack Snyder's Justice League");
nfo.SortTitle.Should().Be("Justice League 2");
nfo.Outline.Should().BeNullOrEmpty();
nfo.Year.Should().Be(2021);
nfo.ContentRating.Should().Be("Australia:M");
nfo.Premiered.IsSome.Should().BeTrue();
foreach (DateTime premiered in nfo.Premiered)
{
premiered.Should().Be(new DateTime(2021, 03, 18));
}
nfo.Plot.Should().Be(
"Determined to ensure Superman's ultimate sacrifice was not in vain, Bruce Wayne aligns forces with Diana Prince with plans to recruit a team of metahumans to protect the world from an approaching threat of catastrophic proportions.");
nfo.Tagline.Should().BeNullOrEmpty();
nfo.Genres.Should().BeEquivalentTo(new List<string> { "SuperHero" });
nfo.Tags.Should().BeEquivalentTo(new List<string> { "TV Recording" });
nfo.Studios.Should().BeEquivalentTo(new List<string> { "Warner Bros. Pictures" });
nfo.Actors.Should().BeEquivalentTo(
new List<ActorNfo>
{
new()
{
Name = "Ben Affleck", Order = 0, Role = "Bruce Wayne / Batman",
Thumb = "https://image.tmdb.org/t/p/original/u525jeDOzg9hVdvYfeehTGnw7Aa.jpg"
},
new()
{
Name = "Henry Cavill", Order = 1, Role = "Clark Kent / Superman / Kal-El",
Thumb = "https://image.tmdb.org/t/p/original/hErUwonrQgY5Y7RfxOfv8Fq11MB.jpg"
},
new()
{
Name = "Gal Gadot", Order = 2, Role = "Diana Prince / Wonder Woman",
Thumb = "https://image.tmdb.org/t/p/original/fysvehTvU6bE3JgxaOTRfvQJzJ4.jpg"
}
});
nfo.Writers.Should().BeEquivalentTo(new List<string> { "Chris Terrio" });
nfo.Directors.Should().BeEquivalentTo(new List<string> { "Zack Snyder" });
nfo.UniqueIds.Should().BeEquivalentTo(
new List<UniqueIdNfo>
{
new() { Type = "imdb", Guid = "tt12361974", Default = false },
new() { Type = "tmdb", Guid = "791373", Default = true }
});
}
}
[Test]
public async Task MetadataNfo_With_Tag_Should_Return_Nfo()
{
await using var stream = new MemoryStream(Encoding.UTF8.GetBytes(@"<movie><tag>Test Tag</tag></movie>"));
Either<BaseError, OtherVideoNfo> result = await _otherVideoNfoReader.Read(stream);
result.IsRight.Should().BeTrue();
foreach (OtherVideoNfo nfo in result.RightToSeq())
{
nfo.Tags.Should().BeEquivalentTo(new List<string> { "Test Tag" });
}
}
[Test]
public async Task MetadataNfo_With_Outline_Should_Return_Nfo()
{
await using var stream =
new MemoryStream(Encoding.UTF8.GetBytes(@"<movie><outline>Test Outline</outline></movie>"));
Either<BaseError, OtherVideoNfo> result = await _otherVideoNfoReader.Read(stream);
result.IsRight.Should().BeTrue();
foreach (OtherVideoNfo nfo in result.RightToSeq())
{
nfo.Outline.Should().Be("Test Outline");
}
}
}
@@ -0,0 +1,221 @@
using System.Text;
using Bugsnag;
using ErsatzTV.Core.Metadata.Nfo;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.IO;
using Moq;
using NUnit.Framework;
namespace ErsatzTV.Core.Tests.Metadata.Nfo;
[TestFixture]
public class TvShowNfoReaderTests
{
[SetUp]
public void SetUp() => _tvShowNfoReader = new TvShowNfoReader(
new RecyclableMemoryStreamManager(),
new Mock<IClient>().Object,
new NullLogger<TvShowNfoReader>());
private TvShowNfoReader _tvShowNfoReader;
[Test]
public async Task ParsingNfo_Should_Return_Error()
{
await using var stream =
new MemoryStream(Encoding.UTF8.GetBytes(@"https://www.themoviedb.org/movie/11-star-wars"));
Either<BaseError, TvShowNfo> result = await _tvShowNfoReader.Read(stream);
result.IsLeft.Should().BeTrue();
}
[Test]
public async Task MetadataNfo_Should_Return_Nfo()
{
await using var stream = new MemoryStream(Encoding.UTF8.GetBytes(@"<tvshow></tvshow>"));
Either<BaseError, TvShowNfo> result = await _tvShowNfoReader.Read(stream);
result.IsRight.Should().BeTrue();
}
[Test]
public async Task CombinationNfo_Should_Return_Nfo()
{
await using var stream = new MemoryStream(
Encoding.UTF8.GetBytes(
@"<tvshow></tvshow>
https://www.themoviedb.org/movie/11-star-wars"));
Either<BaseError, TvShowNfo> result = await _tvShowNfoReader.Read(stream);
result.IsRight.Should().BeTrue();
}
[Test]
public async Task FullSample_Should_Return_Nfo()
{
await using var stream = new MemoryStream(
Encoding.UTF8.GetBytes(
@"<?xml version=""1.0"" encoding=""UTF-8"" standalone=""yes"" ?>
<tvshow>
<title>WandaVision</title>
<originaltitle>WandaVision</originaltitle>
<showtitle>WandaVision</showtitle>
<ratings>
<rating name=""imdb"" max=""10"" default=""true"">
<value>8.200000</value>
<votes>105359</votes>
</rating>
<rating name=""tmdb"" max=""10"">
<value>8.500000</value>
<votes>7230</votes>
</rating>
<rating name=""trakt"" max=""10"">
<value>8.077950</value>
<votes>3284</votes>
</rating>
</ratings>
<userrating>0</userrating>
<top250>0</top250>
<season>1</season>
<episode>9</episode>
<displayseason>-1</displayseason>
<displayepisode>-1</displayepisode>
<outline></outline>
<plot>Wanda Maximoff and Vision—two super-powered beings living idealized suburban lives—begin to suspect that everything is not as it seems.</plot>
<tagline></tagline>
<runtime>0</runtime>
<thumb spoof="""" cache="""" aspect=""landscape"" preview=""https://image.tmdb.org/t/p/w780/dUWto4NaeJFrGx7jm8m3KLymUGf.jpg"">https://image.tmdb.org/t/p/original/dUWto4NaeJFrGx7jm8m3KLymUGf.jpg</thumb>
<thumb spoof="""" cache="""" aspect=""poster"" preview=""https://image.tmdb.org/t/p/w780/8UsAB1hgwnd80eI2ociyppB6UXL.jpg"">https://image.tmdb.org/t/p/original/8UsAB1hgwnd80eI2ociyppB6UXL.jpg</thumb>
<thumb spoof="""" cache="""" aspect=""poster"" preview=""https://assets.fanart.tv/preview/tv/362392/tvposter/wandavision-6009571d1ed1f.jpg"">https://assets.fanart.tv/fanart/tv/362392/tvposter/wandavision-6009571d1ed1f.jpg</thumb>
<thumb spoof="""" cache="""" aspect=""clearlogo"" preview=""https://assets.fanart.tv/preview/tv/362392/hdtvlogo/marvels-wandavision-5f6ac3b1e9458.png"">https://assets.fanart.tv/fanart/tv/362392/hdtvlogo/marvels-wandavision-5f6ac3b1e9458.png</thumb>
<thumb spoof="""" cache="""" aspect=""clearart"" preview=""https://assets.fanart.tv/preview/tv/362392/hdclearart/wandavision-6009b6875a285.png"">https://assets.fanart.tv/fanart/tv/362392/hdclearart/wandavision-6009b6875a285.png</thumb>
<thumb spoof="""" cache="""" aspect=""landscape"" preview=""https://assets.fanart.tv/preview/tv/362392/tvthumb/wandavision-603032a5349b9.jpg"">https://assets.fanart.tv/fanart/tv/362392/tvthumb/wandavision-603032a5349b9.jpg</thumb>
<thumb spoof="""" cache="""" season=""1"" type=""season"" aspect=""poster"" preview=""https://image.tmdb.org/t/p/w780/7u443QI5xNIfLgNzEsV43CYZCWX.jpg"">https://image.tmdb.org/t/p/original/7u443QI5xNIfLgNzEsV43CYZCWX.jpg</thumb>
<fanart>
<thumb colors="""" preview="""">https://image.tmdb.org/t/p/original/57vVjteucIF3bGnZj6PmaoJRScw.jpg</thumb>
<thumb colors="""" preview="""">https://assets.fanart.tv/fanart/tv/362392/showbackground/marvels-wandavision-5ff4fef387a43.jpg</thumb>
</fanart>
<mpaa>Australia:M</mpaa>
<playcount>0</playcount>
<lastplayed>2021-03-29</lastplayed>
<id>85271</id>
<uniqueid type=""imdb"">tt9140560</uniqueid>
<uniqueid type=""tmdb"" default=""true"">85271</uniqueid>
<uniqueid type=""tvdb"">362392</uniqueid>
<genre>SuperHero</genre>
<premiered>2021-01-15</premiered>
<year>2021</year>
<status>Ended</status>
<code></code>
<aired></aired>
<studio>Disney+</studio>
<actor>
<name>Elizabeth Olsen</name>
<role>Wanda Maximoff / The Scarlet Witch</role>
<order>0</order>
<thumb>https://image.tmdb.org/t/p/original/wIU675y4dofIDVuhaNWPizJNtep.jpg</thumb>
</actor>
<actor>
<name>Paul Bettany</name>
<role>Vision / The Vision</role>
<order>1</order>
<thumb>https://image.tmdb.org/t/p/original/vcAVrAOZrpqmi37qjFdztRAv1u9.jpg</thumb>
</actor>
<namedseason number=""1"">Season 1</namedseason>
<resume>
<position>0.000000</position>
<total>0.000000</total>
</resume>
<dateadded>2021-03-12 06:15:51</dateadded>
</tvshow>"));
Either<BaseError, TvShowNfo> result = await _tvShowNfoReader.Read(stream);
result.IsRight.Should().BeTrue();
foreach (TvShowNfo nfo in result.RightToSeq())
{
nfo.Title.Should().Be("WandaVision");
nfo.Year.Should().Be(2021);
nfo.Plot.Should().Be(
"Wanda Maximoff and Vision—two super-powered beings living idealized suburban lives—begin to suspect that everything is not as it seems.");
nfo.ContentRating.Should().Be("Australia:M");
nfo.Genres.Should().BeEquivalentTo(new List<string> { "SuperHero" });
nfo.Studios.Should().BeEquivalentTo(new List<string> { "Disney+" });
nfo.Actors.Should().BeEquivalentTo(
new List<ActorNfo>
{
new()
{
Name = "Elizabeth Olsen", Order = 0, Role = "Wanda Maximoff / The Scarlet Witch",
Thumb = "https://image.tmdb.org/t/p/original/wIU675y4dofIDVuhaNWPizJNtep.jpg"
},
new()
{
Name = "Paul Bettany", Order = 1, Role = "Vision / The Vision",
Thumb = "https://image.tmdb.org/t/p/original/vcAVrAOZrpqmi37qjFdztRAv1u9.jpg"
}
});
nfo.UniqueIds.Should().BeEquivalentTo(
new List<UniqueIdNfo>
{
new() { Type = "imdb", Guid = "tt9140560", Default = false },
new() { Type = "tmdb", Guid = "85271", Default = true },
new() { Type = "tvdb", Guid = "362392", Default = false }
});
nfo.Premiered.IsSome.Should().BeTrue();
foreach (DateTime premiered in nfo.Premiered)
{
premiered.Should().Be(new DateTime(2021, 1, 15));
}
}
}
[Test]
public async Task MetadataNfo_With_Outline_Should_Return_Nfo()
{
await using var stream =
new MemoryStream(Encoding.UTF8.GetBytes(@"<tvshow><outline>Test Outline</outline></tvshow>"));
Either<BaseError, TvShowNfo> result = await _tvShowNfoReader.Read(stream);
result.IsRight.Should().BeTrue();
foreach (TvShowNfo nfo in result.RightToSeq())
{
nfo.Outline.Should().Be("Test Outline");
}
}
[Test]
public async Task MetadataNfo_With_Tagline_Should_Return_Nfo()
{
await using var stream =
new MemoryStream(Encoding.UTF8.GetBytes(@"<tvshow><tagline>Test Tagline</tagline></tvshow>"));
Either<BaseError, TvShowNfo> result = await _tvShowNfoReader.Read(stream);
result.IsRight.Should().BeTrue();
foreach (TvShowNfo nfo in result.RightToSeq())
{
nfo.Tagline.Should().Be("Test Tagline");
}
}
[Test]
public async Task MetadataNfo_With_Tag_Should_Return_Nfo()
{
await using var stream = new MemoryStream(Encoding.UTF8.GetBytes(@"<tvshow><tag>Test Tag</tag></tvshow>"));
Either<BaseError, TvShowNfo> result = await _tvShowNfoReader.Read(stream);
result.IsRight.Should().BeTrue();
foreach (TvShowNfo nfo in result.RightToSeq())
{
nfo.Tags.Should().BeEquivalentTo(new List<string> { "Test Tag" });
}
}
}
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<!--created on whatever - comment-->
<artist>
<name>Test Name</name>
</artist>
ÐPS½NÞ5Þ*˜¡¡ã·Ýq×ÍâeVk—¯¬}É
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<!--created on whatever - comment-->
<artist>
<name>Test Name</name>
<style>ÐPS½NÞ5Þ*˜¡¡ã·Ýq×ÍâeVk—¯¬}É</style>
<mood>Test Mood</mood>
</artist>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<!--created on whatever - comment-->
<episodedetails>
<title>Test Title</title>
</episodedetails>
ÐPS½NÞ5Þ*˜¡¡ã·Ýq×ÍâeVk—¯¬}É
@@ -70,6 +70,180 @@ public class PlayoutBuilderTests
result.Items.Head().FinishOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(6));
}
[Test]
[Timeout(2000)]
public async Task OnlyFileNotFoundItem_Should_Abort()
{
var mediaItems = new List<MediaItem>
{
TestMovie(1, TimeSpan.FromHours(1), DateTime.Today)
};
mediaItems[0].State = MediaItemState.FileNotFound;
var configRepo = new Mock<IConfigElementRepository>();
configRepo.Setup(
repo => repo.GetValue<bool>(
It.Is<ConfigElementKey>(k => k.Key == ConfigElementKey.PlayoutSkipMissingItems.Key)))
.ReturnsAsync(Some(true));
(PlayoutBuilder builder, Playout playout) =
TestDataFloodForItems(mediaItems, PlaybackOrder.Random, configRepo);
Playout result = await builder.Build(playout, PlayoutBuildMode.Reset);
configRepo.Verify();
result.Items.Should().BeEmpty();
}
[Test]
public async Task FileNotFoundItem_Should_BeSkipped()
{
var mediaItems = new List<MediaItem>
{
TestMovie(1, TimeSpan.FromHours(1), DateTime.Today),
TestMovie(2, TimeSpan.FromHours(6), DateTime.Today)
};
mediaItems[0].State = MediaItemState.FileNotFound;
var configRepo = new Mock<IConfigElementRepository>();
configRepo.Setup(
repo => repo.GetValue<bool>(
It.Is<ConfigElementKey>(k => k.Key == ConfigElementKey.PlayoutSkipMissingItems.Key)))
.ReturnsAsync(Some(true));
(PlayoutBuilder builder, Playout playout) =
TestDataFloodForItems(mediaItems, PlaybackOrder.Random, configRepo);
DateTimeOffset start = HoursAfterMidnight(0);
DateTimeOffset finish = start + TimeSpan.FromHours(6);
Playout result = await builder.Build(playout, PlayoutBuildMode.Reset, start, finish);
configRepo.Verify();
result.Items.Count.Should().Be(1);
result.Items.Head().MediaItemId.Should().Be(2);
result.Items.Head().StartOffset.TimeOfDay.Should().Be(TimeSpan.Zero);
result.Items.Head().FinishOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(6));
}
[Test]
[Timeout(2000)]
public async Task OnlyUnavailableItem_Should_Abort()
{
var mediaItems = new List<MediaItem>
{
TestMovie(1, TimeSpan.FromHours(1), DateTime.Today)
};
mediaItems[0].State = MediaItemState.Unavailable;
var configRepo = new Mock<IConfigElementRepository>();
configRepo.Setup(
repo => repo.GetValue<bool>(
It.Is<ConfigElementKey>(k => k.Key == ConfigElementKey.PlayoutSkipMissingItems.Key)))
.ReturnsAsync(Some(true));
(PlayoutBuilder builder, Playout playout) =
TestDataFloodForItems(mediaItems, PlaybackOrder.Random, configRepo);
Playout result = await builder.Build(playout, PlayoutBuildMode.Reset);
configRepo.Verify();
result.Items.Should().BeEmpty();
}
[Test]
public async Task UnavailableItem_Should_BeSkipped()
{
var mediaItems = new List<MediaItem>
{
TestMovie(1, TimeSpan.FromHours(1), DateTime.Today),
TestMovie(2, TimeSpan.FromHours(6), DateTime.Today)
};
mediaItems[0].State = MediaItemState.Unavailable;
var configRepo = new Mock<IConfigElementRepository>();
configRepo.Setup(
repo => repo.GetValue<bool>(
It.Is<ConfigElementKey>(k => k.Key == ConfigElementKey.PlayoutSkipMissingItems.Key)))
.ReturnsAsync(Some(true));
(PlayoutBuilder builder, Playout playout) =
TestDataFloodForItems(mediaItems, PlaybackOrder.Random, configRepo);
DateTimeOffset start = HoursAfterMidnight(0);
DateTimeOffset finish = start + TimeSpan.FromHours(6);
Playout result = await builder.Build(playout, PlayoutBuildMode.Reset, start, finish);
configRepo.Verify();
result.Items.Count.Should().Be(1);
result.Items.Head().MediaItemId.Should().Be(2);
result.Items.Head().StartOffset.TimeOfDay.Should().Be(TimeSpan.Zero);
result.Items.Head().FinishOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(6));
}
[Test]
public async Task FileNotFound_Should_NotBeSkippedIfConfigured()
{
var mediaItems = new List<MediaItem>
{
TestMovie(1, TimeSpan.FromHours(6), DateTime.Today)
};
mediaItems[0].State = MediaItemState.FileNotFound;
var configRepo = new Mock<IConfigElementRepository>();
configRepo.Setup(
repo => repo.GetValue<bool>(
It.Is<ConfigElementKey>(k => k.Key == ConfigElementKey.PlayoutSkipMissingItems.Key)))
.ReturnsAsync(Some(false));
(PlayoutBuilder builder, Playout playout) =
TestDataFloodForItems(mediaItems, PlaybackOrder.Random, configRepo);
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(1);
result.Items.Head().StartOffset.TimeOfDay.Should().Be(TimeSpan.Zero);
result.Items.Head().FinishOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(6));
}
[Test]
public async Task Unavailable_Should_NotBeSkippedIfConfigured()
{
var mediaItems = new List<MediaItem>
{
TestMovie(1, TimeSpan.FromHours(6), DateTime.Today)
};
mediaItems[0].State = MediaItemState.Unavailable;
var configRepo = new Mock<IConfigElementRepository>();
configRepo.Setup(
repo => repo.GetValue<bool>(
It.Is<ConfigElementKey>(k => k.Key == ConfigElementKey.PlayoutSkipMissingItems.Key)))
.ReturnsAsync(Some(false));
(PlayoutBuilder builder, Playout playout) =
TestDataFloodForItems(mediaItems, PlaybackOrder.Random, configRepo);
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(1);
result.Items.Head().StartOffset.TimeOfDay.Should().Be(TimeSpan.Zero);
result.Items.Head().FinishOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(6));
}
[Test]
public async Task InitialFlood_Should_StartAtMidnight()
{
@@ -376,6 +550,140 @@ public class PlayoutBuilderTests
result.Items[4].MediaItemId.Should().Be(2);
}
[Test]
public async Task FloodContent_Should_FloodAroundFixedContent_One_Multiple_Days()
{
var floodCollection = new Collection
{
Id = 1,
Name = "Flood Items",
MediaItems = new List<MediaItem>
{
TestMovie(1, TimeSpan.FromHours(1), new DateTime(2020, 1, 1)),
TestMovie(2, TimeSpan.FromHours(1), new DateTime(2020, 2, 1))
}
};
var fixedCollection = new Collection
{
Id = 2,
Name = "Fixed Items",
MediaItems = new List<MediaItem>
{
TestMovie(3, TimeSpan.FromHours(2), new DateTime(2020, 1, 1))
}
};
var fakeRepository = new FakeMediaCollectionRepository(
Map(
(floodCollection.Id, floodCollection.MediaItems.ToList()),
(fixedCollection.Id, fixedCollection.MediaItems.ToList())));
var items = new List<ProgramScheduleItem>
{
new ProgramScheduleItemFlood
{
Index = 1,
Collection = floodCollection,
CollectionId = floodCollection.Id,
StartTime = null,
PlaybackOrder = PlaybackOrder.Chronological
},
new ProgramScheduleItemOne
{
Index = 2,
Collection = fixedCollection,
CollectionId = fixedCollection.Id,
StartTime = TimeSpan.FromHours(3),
PlaybackOrder = PlaybackOrder.Chronological
}
};
var playout = new Playout
{
ProgramSchedule = new ProgramSchedule
{
Items = items
},
Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" },
ProgramScheduleAnchors = new List<PlayoutProgramScheduleAnchor>(),
Items = new List<PlayoutItem>()
};
var configRepo = new Mock<IConfigElementRepository>();
var televisionRepo = new FakeTelevisionRepository();
var artistRepo = new Mock<IArtistRepository>();
var builder = new PlayoutBuilder(
configRepo.Object,
fakeRepository,
televisionRepo,
artistRepo.Object,
_logger);
DateTimeOffset start = HoursAfterMidnight(0);
DateTimeOffset finish = start + TimeSpan.FromHours(30);
Playout result = await builder.Build(playout, PlayoutBuildMode.Reset, start, finish);
result.Items.Count.Should().Be(28);
result.Items[0].StartOffset.TimeOfDay.Should().Be(TimeSpan.Zero);
result.Items[0].MediaItemId.Should().Be(1);
result.Items[1].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(1));
result.Items[1].MediaItemId.Should().Be(2);
result.Items[2].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(2));
result.Items[2].MediaItemId.Should().Be(1);
result.Items[3].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(3));
result.Items[3].MediaItemId.Should().Be(3);
result.Items[4].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(5));
result.Items[4].MediaItemId.Should().Be(2);
result.Items[5].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(6));
result.Items[5].MediaItemId.Should().Be(1);
result.Items[6].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(7));
result.Items[6].MediaItemId.Should().Be(2);
result.Items[7].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(8));
result.Items[7].MediaItemId.Should().Be(1);
result.Items[8].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(9));
result.Items[8].MediaItemId.Should().Be(2);
result.Items[9].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(10));
result.Items[9].MediaItemId.Should().Be(1);
result.Items[10].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(11));
result.Items[10].MediaItemId.Should().Be(2);
result.Items[11].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(12));
result.Items[11].MediaItemId.Should().Be(1);
result.Items[12].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(13));
result.Items[12].MediaItemId.Should().Be(2);
result.Items[13].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(14));
result.Items[13].MediaItemId.Should().Be(1);
result.Items[14].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(15));
result.Items[14].MediaItemId.Should().Be(2);
result.Items[15].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(16));
result.Items[15].MediaItemId.Should().Be(1);
result.Items[16].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(17));
result.Items[16].MediaItemId.Should().Be(2);
result.Items[17].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(18));
result.Items[17].MediaItemId.Should().Be(1);
result.Items[18].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(19));
result.Items[18].MediaItemId.Should().Be(2);
result.Items[19].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(20));
result.Items[19].MediaItemId.Should().Be(1);
result.Items[20].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(21));
result.Items[20].MediaItemId.Should().Be(2);
result.Items[21].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(22));
result.Items[21].MediaItemId.Should().Be(1);
result.Items[22].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(23));
result.Items[22].MediaItemId.Should().Be(2);
result.Items[23].StartOffset.TimeOfDay.Should().Be(TimeSpan.Zero);
result.Items[23].MediaItemId.Should().Be(1);
result.Items[24].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(1));
result.Items[24].MediaItemId.Should().Be(2);
result.Items[25].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(2));
result.Items[25].MediaItemId.Should().Be(1);
result.Items[26].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(3));
result.Items[26].MediaItemId.Should().Be(3);
result.Items[27].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(5));
result.Items[27].MediaItemId.Should().Be(2);
}
[Test]
public async Task FloodContent_Should_FloodAroundFixedContent_Multiple()
{
@@ -2142,11 +2450,20 @@ public class PlayoutBuilderTests
MovieMetadata = new List<MovieMetadata> { new() { ReleaseDate = aired } },
MediaVersions = new List<MediaVersion>
{
new() { Duration = duration }
new()
{
Duration = duration, MediaFiles = new List<MediaFile>
{
new() { Path = $"/fake/path/{id}" }
}
}
}
};
private TestData TestDataFloodForItems(List<MediaItem> mediaItems, PlaybackOrder playbackOrder)
private TestData TestDataFloodForItems(
List<MediaItem> mediaItems,
PlaybackOrder playbackOrder,
Mock<IConfigElementRepository> configMock = null)
{
var mediaCollection = new Collection
{
@@ -2154,7 +2471,8 @@ public class PlayoutBuilderTests
MediaItems = mediaItems
};
var configRepo = new Mock<IConfigElementRepository>();
Mock<IConfigElementRepository> configRepo = configMock ?? new Mock<IConfigElementRepository>();
var collectionRepo = new FakeMediaCollectionRepository(Map((mediaCollection.Id, mediaItems)));
var televisionRepo = new FakeTelevisionRepository();
var artistRepo = new Mock<IArtistRepository>();
@@ -25,7 +25,8 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
StartTime = null,
PlayoutDuration = TimeSpan.FromHours(3),
TailMode = TailMode.None,
PlaybackOrder = PlaybackOrder.Chronological
PlaybackOrder = PlaybackOrder.Chronological,
CustomTitle = "CustomTitle"
};
var scheduleItemsEnumerator = new OrderedScheduleItemsEnumerator(
@@ -49,7 +50,7 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
playoutBuilderState.CurrentTime.Should().Be(startState.CurrentTime.AddHours(3));
playoutItems.Last().FinishOffset.Should().Be(playoutBuilderState.CurrentTime);
playoutBuilderState.NextGuideGroup.Should().Be(4);
playoutBuilderState.NextGuideGroup.Should().Be(2); // one guide group here because of custom title
playoutBuilderState.DurationFinish.IsNone.Should().BeTrue();
playoutBuilderState.InFlood.Should().BeFalse();
playoutBuilderState.MultipleRemaining.IsNone.Should().BeTrue();
@@ -65,18 +66,21 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
playoutItems[0].GuideGroup.Should().Be(1);
playoutItems[0].FillerKind.Should().Be(FillerKind.None);
playoutItems[0].GuideFinish.HasValue.Should().BeFalse();
playoutItems[0].CustomTitle.Should().Be("CustomTitle");
playoutItems[1].MediaItemId.Should().Be(2);
playoutItems[1].StartOffset.Should().Be(startState.CurrentTime.AddHours(1));
playoutItems[1].GuideGroup.Should().Be(2);
playoutItems[1].GuideGroup.Should().Be(1);
playoutItems[1].FillerKind.Should().Be(FillerKind.None);
playoutItems[1].GuideFinish.HasValue.Should().BeFalse();
playoutItems[1].CustomTitle.Should().Be("CustomTitle");
playoutItems[2].MediaItemId.Should().Be(1);
playoutItems[2].StartOffset.Should().Be(startState.CurrentTime.AddHours(2));
playoutItems[2].GuideGroup.Should().Be(3);
playoutItems[2].GuideGroup.Should().Be(1);
playoutItems[2].FillerKind.Should().Be(FillerKind.None);
playoutItems[2].GuideFinish.HasValue.Should().BeTrue();
playoutItems[2].CustomTitle.Should().Be("CustomTitle");
}
[Test]
@@ -25,7 +25,8 @@ public class PlayoutModeSchedulerFloodTests : SchedulerTestBase
StartTime = null,
PlaybackOrder = PlaybackOrder.Chronological,
TailFiller = null,
FallbackFiller = null
FallbackFiller = null,
CustomTitle = "CustomTitle"
};
var enumerator = new ChronologicalMediaCollectionEnumerator(
@@ -55,7 +56,7 @@ public class PlayoutModeSchedulerFloodTests : SchedulerTestBase
playoutBuilderState.CurrentTime.Should().Be(startState.CurrentTime.AddHours(3));
playoutItems.Last().FinishOffset.Should().Be(playoutBuilderState.CurrentTime);
playoutBuilderState.NextGuideGroup.Should().Be(4);
playoutBuilderState.NextGuideGroup.Should().Be(2); // one guide group here because of custom title
playoutBuilderState.DurationFinish.IsNone.Should().BeTrue();
playoutBuilderState.InFlood.Should().BeFalse();
playoutBuilderState.MultipleRemaining.IsNone.Should().BeTrue();
@@ -70,16 +71,19 @@ public class PlayoutModeSchedulerFloodTests : SchedulerTestBase
playoutItems[0].StartOffset.Should().Be(startState.CurrentTime);
playoutItems[0].GuideGroup.Should().Be(1);
playoutItems[0].FillerKind.Should().Be(FillerKind.None);
playoutItems[0].CustomTitle.Should().Be("CustomTitle");
playoutItems[1].MediaItemId.Should().Be(2);
playoutItems[1].StartOffset.Should().Be(startState.CurrentTime.AddHours(1));
playoutItems[1].GuideGroup.Should().Be(2);
playoutItems[1].GuideGroup.Should().Be(1);
playoutItems[1].FillerKind.Should().Be(FillerKind.None);
playoutItems[1].CustomTitle.Should().Be("CustomTitle");
playoutItems[2].MediaItemId.Should().Be(1);
playoutItems[2].StartOffset.Should().Be(startState.CurrentTime.AddHours(2));
playoutItems[2].GuideGroup.Should().Be(3);
playoutItems[2].GuideGroup.Should().Be(1);
playoutItems[2].FillerKind.Should().Be(FillerKind.None);
playoutItems[2].CustomTitle.Should().Be("CustomTitle");
}
[Test]
@@ -27,7 +27,8 @@ public class PlayoutModeSchedulerMultipleTests : SchedulerTestBase
PlaybackOrder = PlaybackOrder.Chronological,
TailFiller = null,
FallbackFiller = null,
Count = 3
Count = 3,
CustomTitle = "CustomTitle"
};
var scheduleItemsEnumerator = new OrderedScheduleItemsEnumerator(
@@ -56,7 +57,7 @@ public class PlayoutModeSchedulerMultipleTests : SchedulerTestBase
playoutBuilderState.CurrentTime.Should().Be(startState.CurrentTime.AddHours(3));
playoutItems.Last().FinishOffset.Should().Be(playoutBuilderState.CurrentTime);
playoutBuilderState.NextGuideGroup.Should().Be(4);
playoutBuilderState.NextGuideGroup.Should().Be(2); // one guide group here because of custom title
playoutBuilderState.DurationFinish.IsNone.Should().BeTrue();
playoutBuilderState.InFlood.Should().BeFalse();
playoutBuilderState.MultipleRemaining.IsNone.Should().BeTrue();
@@ -71,16 +72,19 @@ public class PlayoutModeSchedulerMultipleTests : SchedulerTestBase
playoutItems[0].StartOffset.Should().Be(startState.CurrentTime);
playoutItems[0].GuideGroup.Should().Be(1);
playoutItems[0].FillerKind.Should().Be(FillerKind.None);
playoutItems[0].CustomTitle.Should().Be("CustomTitle");
playoutItems[1].MediaItemId.Should().Be(2);
playoutItems[1].StartOffset.Should().Be(startState.CurrentTime.AddHours(1));
playoutItems[1].GuideGroup.Should().Be(2);
playoutItems[1].GuideGroup.Should().Be(1);
playoutItems[1].FillerKind.Should().Be(FillerKind.None);
playoutItems[1].CustomTitle.Should().Be("CustomTitle");
playoutItems[2].MediaItemId.Should().Be(1);
playoutItems[2].StartOffset.Should().Be(startState.CurrentTime.AddHours(2));
playoutItems[2].GuideGroup.Should().Be(3);
playoutItems[2].GuideGroup.Should().Be(1);
playoutItems[2].FillerKind.Should().Be(FillerKind.None);
playoutItems[2].CustomTitle.Should().Be("CustomTitle");
}
[Test]
@@ -25,7 +25,8 @@ public class PlayoutModeSchedulerOneTests : SchedulerTestBase
StartTime = null,
PlaybackOrder = PlaybackOrder.Chronological,
TailFiller = null,
FallbackFiller = null
FallbackFiller = null,
CustomTitle = "CustomTitle"
};
var scheduleItemsEnumerator = new OrderedScheduleItemsEnumerator(
@@ -64,6 +65,7 @@ public class PlayoutModeSchedulerOneTests : SchedulerTestBase
playoutItems[0].StartOffset.Should().Be(startState.CurrentTime);
playoutItems[0].GuideGroup.Should().Be(1);
playoutItems[0].FillerKind.Should().Be(FillerKind.None);
playoutItems[0].CustomTitle.Should().Be("CustomTitle");
}
[Test]
@@ -1,9 +1,12 @@
namespace ErsatzTV.Core.Api.Channels;
using Newtonsoft.Json;
namespace ErsatzTV.Core.Api.Channels;
public record ChannelResponseModel(
int Id,
string Number,
string Name,
[property: JsonProperty("ffmpegProfile")]
string FFmpegProfile,
string Language,
string StreamingMode);
@@ -0,0 +1,8 @@
namespace ErsatzTV.Core.Api.FFmpegProfiles;
public record FFmpegProfileResponseModel(
int Id,
string Name,
string Resolution,
string Video,
string Audio);
+2 -1
View File
@@ -5,4 +5,5 @@ public enum ChannelSubtitleMode
None = 0,
Forced = 1,
Default = 2,
Any = 3}
Any = 3
}
+1
View File
@@ -33,4 +33,5 @@ public class ConfigElementKey
public static ConfigElementKey FillerPresetsPageSize => new("pages.filler_presets.page_size");
public static ConfigElementKey LibraryRefreshInterval => new("scanner.library_refresh_interval");
public static ConfigElementKey PlayoutDaysToBuild => new("playout.days_to_build");
public static ConfigElementKey PlayoutSkipMissingItems => new("playout.skip_missing_items");
}
@@ -9,6 +9,7 @@ public class FillerPreset
public TimeSpan? Duration { get; set; }
public int? Count { get; set; }
public int? PadToNearestMinute { get; set; }
public bool AllowWatermarks { get; set; }
public ProgramScheduleItemCollectionType CollectionType { get; set; }
public int? CollectionId { get; set; }
public Collection Collection { get; set; }
+4 -1
View File
@@ -1,7 +1,10 @@
namespace ErsatzTV.Core.Domain;
using ErsatzTV.Core.Emby;
namespace ErsatzTV.Core.Domain;
public class EmbyLibrary : Library
{
public string ItemId { get; set; }
public bool ShouldSyncItems { get; set; }
public List<EmbyPathInfo> PathInfos { get; set; }
}
@@ -1,7 +1,10 @@
namespace ErsatzTV.Core.Domain;
using ErsatzTV.Core.Jellyfin;
namespace ErsatzTV.Core.Domain;
public class JellyfinLibrary : Library
{
public string ItemId { get; set; }
public bool ShouldSyncItems { get; set; }
public List<JellyfinPathInfo> PathInfos { get; set; }
}
@@ -1,5 +1,31 @@
namespace ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.FFmpeg.Format;
namespace ErsatzTV.Core.Domain;
public class BackgroundImageMediaVersion : MediaVersion
{
public static BackgroundImageMediaVersion ForPath(string path, IDisplaySize resolution) =>
new()
{
Chapters = new List<MediaChapter>(),
// image has been pre-generated with correct size
Height = resolution.Height,
Width = resolution.Width,
SampleAspectRatio = "1:1",
Streams = new List<MediaStream>
{
new()
{
MediaStreamKind = MediaStreamKind.Video,
Index = 0,
Codec = VideoFormat.GeneratedImage,
PixelFormat = new PixelFormatUnknown().Name // the resulting pixel format is unknown
}
},
MediaFiles = new List<MediaFile>
{
new() { Path = path }
}
};
}
@@ -0,0 +1,3 @@
namespace ErsatzTV.Core.Domain.MediaServer;
public abstract record MediaServerConnectionParameters;
@@ -0,0 +1,8 @@
namespace ErsatzTV.Core.Domain;
public abstract class MediaServerItemEtag
{
public abstract string MediaServerItemId { get; }
public abstract string Etag { get; set; }
public abstract MediaItemState State { get; set; }
}
@@ -0,0 +1,7 @@
namespace ErsatzTV.Core.Domain;
public class MusicVideoArtist
{
public int Id { get; set; }
public string Name { get; set; }
}
@@ -4,6 +4,8 @@ public class MusicVideoMetadata : Metadata
{
public string Album { get; set; }
public string Plot { get; set; }
public int? Track { get; set; }
public int MusicVideoId { get; set; }
public MusicVideo MusicVideo { get; set; }
public List<MusicVideoArtist> Artists { get; set; }
}
@@ -2,6 +2,12 @@
public class OtherVideoMetadata : Metadata
{
public string ContentRating { get; set; }
public string Outline { get; set; }
public string Plot { get; set; }
public string Tagline { get; set; }
public int OtherVideoId { get; set; }
public OtherVideo OtherVideo { get; set; }
public List<Director> Directors { get; set; }
public List<Writer> Writers { get; set; }
}
+1
View File
@@ -22,6 +22,7 @@ public class PlayoutItem
public string ChapterTitle { get; set; }
public ChannelWatermark Watermark { get; set; }
public int? WatermarkId { get; set; }
public bool DisableWatermarks { get; set; }
public string PreferredAudioLanguageCode { get; set; }
public string PreferredSubtitleLanguageCode { get; set; }
public ChannelSubtitleMode? SubtitleMode { get; set; }
+1
View File
@@ -7,6 +7,7 @@ public class ProgramSchedule
public bool KeepMultiPartEpisodesTogether { get; set; }
public bool TreatCollectionsAsShows { get; set; }
public bool ShuffleScheduleItems { get; set; }
public bool RandomStartPoint { get; set; }
public List<ProgramScheduleItem> Items { get; set; }
public List<Playout> Playouts { get; set; }
}
@@ -0,0 +1,5 @@
using ErsatzTV.Core.Domain.MediaServer;
namespace ErsatzTV.Core.Emby;
public record EmbyConnectionParameters(string Address, string ApiKey) : MediaServerConnectionParameters;
+7 -3
View File
@@ -1,7 +1,11 @@
namespace ErsatzTV.Core.Emby;
using ErsatzTV.Core.Domain;
public class EmbyItemEtag
namespace ErsatzTV.Core.Emby;
public class EmbyItemEtag : MediaServerItemEtag
{
public string ItemId { get; set; }
public string Etag { get; set; }
public override string MediaServerItemId => ItemId;
public override string Etag { get; set; }
public override MediaItemState State { get; set; }
}
+60 -209
View File
@@ -1,53 +1,49 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Emby;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Metadata;
using LanguageExt.UnsafeValueAccess;
using MediatR;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Core.Emby;
public class EmbyMovieLibraryScanner : IEmbyMovieLibraryScanner
public class EmbyMovieLibraryScanner :
MediaServerMovieLibraryScanner<EmbyConnectionParameters, EmbyLibrary, EmbyMovie, EmbyItemEtag>,
IEmbyMovieLibraryScanner
{
private readonly IEmbyApiClient _embyApiClient;
private readonly ILocalFileSystem _localFileSystem;
private readonly ILocalStatisticsProvider _localStatisticsProvider;
private readonly ILocalSubtitlesProvider _localSubtitlesProvider;
private readonly ILogger<EmbyMovieLibraryScanner> _logger;
private readonly IEmbyMovieRepository _embyMovieRepository;
private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly IMediator _mediator;
private readonly IMovieRepository _movieRepository;
private readonly IEmbyPathReplacementService _pathReplacementService;
private readonly ISearchIndex _searchIndex;
private readonly ISearchRepository _searchRepository;
public EmbyMovieLibraryScanner(
IEmbyApiClient embyApiClient,
ISearchIndex searchIndex,
IMediator mediator,
IMovieRepository movieRepository,
IMediaSourceRepository mediaSourceRepository,
IEmbyMovieRepository embyMovieRepository,
ISearchRepository searchRepository,
IEmbyPathReplacementService pathReplacementService,
IMediaSourceRepository mediaSourceRepository,
ILocalFileSystem localFileSystem,
ILocalStatisticsProvider localStatisticsProvider,
ILocalSubtitlesProvider localSubtitlesProvider,
ILogger<EmbyMovieLibraryScanner> logger)
: base(
localStatisticsProvider,
localSubtitlesProvider,
localFileSystem,
mediator,
searchIndex,
searchRepository,
logger)
{
_embyApiClient = embyApiClient;
_searchIndex = searchIndex;
_mediator = mediator;
_movieRepository = movieRepository;
_searchRepository = searchRepository;
_pathReplacementService = pathReplacementService;
_mediaSourceRepository = mediaSourceRepository;
_localFileSystem = localFileSystem;
_localStatisticsProvider = localStatisticsProvider;
_localSubtitlesProvider = localSubtitlesProvider;
_logger = logger;
_embyMovieRepository = embyMovieRepository;
_pathReplacementService = pathReplacementService;
}
public async Task<Either<BaseError, Unit>> ScanLibrary(
@@ -55,197 +51,52 @@ public class EmbyMovieLibraryScanner : IEmbyMovieLibraryScanner
string apiKey,
EmbyLibrary library,
string ffmpegPath,
string ffprobePath)
string ffprobePath,
CancellationToken cancellationToken)
{
List<EmbyItemEtag> existingMovies = await _movieRepository.GetExistingEmbyMovies(library);
List<EmbyPathReplacement> pathReplacements =
await _mediaSourceRepository.GetEmbyPathReplacements(library.MediaSourceId);
// TODO: maybe get quick list of item ids and etags from api to compare first
// TODO: paging?
string GetLocalPath(EmbyMovie movie)
{
return _pathReplacementService.GetReplacementEmbyPath(
pathReplacements,
movie.GetHeadVersion().MediaFiles.Head().Path,
false);
}
List<EmbyPathReplacement> pathReplacements = await _mediaSourceRepository
.GetEmbyPathReplacements(library.MediaSourceId);
Either<BaseError, List<EmbyMovie>> maybeMovies = await _embyApiClient.GetMovieLibraryItems(
address,
apiKey,
library.ItemId);
await maybeMovies.Match(
async movies =>
{
var validMovies = new List<EmbyMovie>();
foreach (EmbyMovie movie in movies.OrderBy(m => m.MovieMetadata.Head().Title))
{
string localPath = _pathReplacementService.GetReplacementEmbyPath(
pathReplacements,
movie.MediaVersions.Head().MediaFiles.Head().Path,
false);
if (!_localFileSystem.FileExists(localPath))
{
_logger.LogWarning("Skipping emby movie that does not exist at {Path}", localPath);
}
else
{
validMovies.Add(movie);
}
}
foreach (EmbyMovie incoming in validMovies)
{
EmbyMovie incomingMovie = incoming;
decimal percentCompletion = (decimal)validMovies.IndexOf(incoming) / validMovies.Count;
await _mediator.Publish(new LibraryScanProgress(library.Id, percentCompletion));
Option<EmbyItemEtag> maybeExisting =
existingMovies.Find(ie => ie.ItemId == incoming.ItemId);
var updateStatistics = false;
await maybeExisting.Match(
async existing =>
{
try
{
if (existing.Etag == incoming.Etag)
{
// _logger.LogDebug(
// $"NOOP: Etag has not changed for movie {incoming.MovieMetadata.Head().Title}");
return;
}
_logger.LogDebug(
"UPDATE: Etag has changed for movie {Movie}",
incoming.MovieMetadata.Head().Title);
updateStatistics = true;
incoming.LibraryPathId = library.Paths.Head().Id;
Option<EmbyMovie> maybeUpdated = await _movieRepository.UpdateEmby(incoming);
foreach (EmbyMovie updated in maybeUpdated)
{
await _searchIndex.UpdateItems(
_searchRepository,
new List<MediaItem> { updated });
incomingMovie = updated;
}
}
catch (Exception ex)
{
updateStatistics = false;
_logger.LogError(
ex,
"Error updating movie {Movie}",
incoming.MovieMetadata.Head().Title);
}
},
async () =>
{
try
{
// _logger.LogDebug(
// $"INSERT: Item id is new for movie {incoming.MovieMetadata.Head().Title}");
updateStatistics = true;
incoming.LibraryPathId = library.Paths.Head().Id;
if (await _movieRepository.AddEmby(incoming))
{
await _searchIndex.AddItems(
_searchRepository,
new List<MediaItem> { incoming });
}
}
catch (Exception ex)
{
updateStatistics = false;
_logger.LogError(
ex,
"Error adding movie {Movie}",
incoming.MovieMetadata.Head().Title);
}
});
if (updateStatistics)
{
string localPath = _pathReplacementService.GetReplacementEmbyPath(
pathReplacements,
incoming.MediaVersions.Head().MediaFiles.Head().Path,
false);
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", localPath);
Either<BaseError, bool> refreshResult =
await _localStatisticsProvider.RefreshStatistics(
ffmpegPath,
ffprobePath,
incomingMovie,
localPath);
if (refreshResult.Map(t => t).IfLeft(false))
{
refreshResult = await UpdateSubtitles(incomingMovie, localPath);
}
await refreshResult.Match(
async _ =>
{
Option<MediaItem> updated = await _searchRepository.GetItemToIndex(incomingMovie.Id);
if (updated.IsSome)
{
await _searchIndex.UpdateItems(
_searchRepository,
new List<MediaItem> { updated.ValueUnsafe() });
}
},
error =>
{
_logger.LogWarning(
"Unable to refresh {Attribute} for media item {Path}. Error: {Error}",
"Statistics",
localPath,
error.Value);
return Task.CompletedTask;
});
}
// TODO: figure out how to rebuild playlists
}
var incomingMovieIds = validMovies.Map(s => s.ItemId).ToList();
var movieIds = existingMovies
.Filter(i => !incomingMovieIds.Contains(i.ItemId))
.Map(m => m.ItemId)
.ToList();
List<int> ids = await _movieRepository.RemoveMissingEmbyMovies(library, movieIds);
await _searchIndex.RemoveItems(ids);
await _mediator.Publish(new LibraryScanProgress(library.Id, 0));
_searchIndex.Commit();
},
error =>
{
_logger.LogWarning(
"Error synchronizing emby library {Path}: {Error}",
library.Name,
error.Value);
return Task.CompletedTask;
});
_searchIndex.Commit();
return Unit.Default;
return await ScanLibrary(
_embyMovieRepository,
new EmbyConnectionParameters(address, apiKey),
library,
GetLocalPath,
ffmpegPath,
ffprobePath,
false,
cancellationToken);
}
private async Task<Either<BaseError, bool>> UpdateSubtitles(EmbyMovie movie, string localPath)
{
try
{
return await _localSubtitlesProvider.UpdateSubtitles(movie, localPath, false);
}
catch (Exception ex)
{
return BaseError.New(ex.ToString());
}
}
protected override string MediaServerItemId(EmbyMovie movie) => movie.ItemId;
protected override string MediaServerEtag(EmbyMovie movie) => movie.Etag;
protected override Task<Either<BaseError, List<EmbyMovie>>> GetMovieLibraryItems(
EmbyConnectionParameters connectionParameters,
EmbyLibrary library) =>
_embyApiClient.GetMovieLibraryItems(
connectionParameters.Address,
connectionParameters.ApiKey,
library);
protected override Task<Option<MovieMetadata>> GetFullMetadata(
EmbyConnectionParameters connectionParameters,
EmbyLibrary library,
MediaItemScanResult<EmbyMovie> result,
EmbyMovie incoming,
bool deepScan) =>
Task.FromResult<Option<MovieMetadata>>(None);
protected override Task<Either<BaseError, MediaItemScanResult<EmbyMovie>>> UpdateMetadata(
MediaItemScanResult<EmbyMovie> result,
MovieMetadata fullMetadata) =>
Task.FromResult<Either<BaseError, MediaItemScanResult<EmbyMovie>>>(result);
}
+8
View File
@@ -0,0 +1,8 @@
namespace ErsatzTV.Core.Emby;
public class EmbyPathInfo
{
public int Id { get; set; }
public string Path { get; set; }
public string NetworkPath { get; set; }
}
@@ -34,7 +34,36 @@ public class EmbyPathReplacementService : IEmbyPathReplacementService
public string GetReplacementEmbyPath(
List<EmbyPathReplacement> pathReplacements,
string path,
bool log = true)
bool log = true) =>
GetReplacementEmbyPath(pathReplacements, path, _runtimeInfo.IsOSPlatform(OSPlatform.Windows), log);
public string ReplaceNetworkPath(
EmbyMediaSource embyMediaSource,
string path,
string networkPath,
string replacement)
{
var replacements = new List<EmbyPathReplacement>
{
new() { EmbyPath = networkPath, LocalPath = replacement, EmbyMediaSource = embyMediaSource }
};
// we want to target the emby platform with the network path replacement
bool isTargetPlatformWindows = embyMediaSource.OperatingSystem.ToLowerInvariant().StartsWith("windows");
return GetReplacementEmbyPath(replacements, path, isTargetPlatformWindows, false);
}
private static bool IsWindows(EmbyMediaSource embyMediaSource, string path)
{
bool isUnc = Uri.TryCreate(path, UriKind.Absolute, out Uri uri) && uri.IsUnc;
return isUnc || embyMediaSource.OperatingSystem.ToLowerInvariant().StartsWith("windows");
}
private string GetReplacementEmbyPath(
List<EmbyPathReplacement> pathReplacements,
string path,
bool isTargetPlatformWindows,
bool log)
{
Option<EmbyPathReplacement> maybeReplacement = pathReplacements
.SingleOrDefault(
@@ -46,21 +75,18 @@ public class EmbyPathReplacementService : IEmbyPathReplacementService
}
string separatorChar = IsWindows(r.EmbyMediaSource, path) ? @"\" : @"/";
string prefix = r.EmbyPath.EndsWith(separatorChar)
? r.EmbyPath
: r.EmbyPath + separatorChar;
string prefix = r.EmbyPath.EndsWith(separatorChar) ? r.EmbyPath : r.EmbyPath + separatorChar;
return path.StartsWith(prefix);
});
foreach (EmbyPathReplacement replacement in maybeReplacement)
{
string finalPath = path.Replace(replacement.EmbyPath, replacement.LocalPath);
if (IsWindows(replacement.EmbyMediaSource, path) && !_runtimeInfo.IsOSPlatform(OSPlatform.Windows))
if (IsWindows(replacement.EmbyMediaSource, path) && !isTargetPlatformWindows)
{
finalPath = finalPath.Replace(@"\", @"/");
}
else if (!IsWindows(replacement.EmbyMediaSource, path) &&
_runtimeInfo.IsOSPlatform(OSPlatform.Windows))
else if (!IsWindows(replacement.EmbyMediaSource, path) && isTargetPlatformWindows)
{
finalPath = finalPath.Replace(@"/", @"\");
}
@@ -79,10 +105,4 @@ public class EmbyPathReplacementService : IEmbyPathReplacementService
return path;
}
private static bool IsWindows(EmbyMediaSource embyMediaSource, string path)
{
bool isUnc = Uri.TryCreate(path, UriKind.Absolute, out Uri uri) && uri.IsUnc;
return isUnc || embyMediaSource.OperatingSystem.ToLowerInvariant().StartsWith("windows");
}
}
@@ -1,27 +1,22 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Emby;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Metadata;
using LanguageExt.UnsafeValueAccess;
using MediatR;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Core.Emby;
public class EmbyTelevisionLibraryScanner : IEmbyTelevisionLibraryScanner
public class EmbyTelevisionLibraryScanner : MediaServerTelevisionLibraryScanner<EmbyConnectionParameters, EmbyLibrary,
EmbyShow, EmbySeason, EmbyEpisode,
EmbyItemEtag>, IEmbyTelevisionLibraryScanner
{
private readonly IEmbyApiClient _embyApiClient;
private readonly ILocalFileSystem _localFileSystem;
private readonly ILocalStatisticsProvider _localStatisticsProvider;
private readonly ILocalSubtitlesProvider _localSubtitlesProvider;
private readonly ILogger<EmbyTelevisionLibraryScanner> _logger;
private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly IMediator _mediator;
private readonly IEmbyPathReplacementService _pathReplacementService;
private readonly ISearchIndex _searchIndex;
private readonly ISearchRepository _searchRepository;
private readonly IEmbyTelevisionRepository _televisionRepository;
public EmbyTelevisionLibraryScanner(
@@ -36,18 +31,19 @@ public class EmbyTelevisionLibraryScanner : IEmbyTelevisionLibraryScanner
ILocalSubtitlesProvider localSubtitlesProvider,
IMediator mediator,
ILogger<EmbyTelevisionLibraryScanner> logger)
: base(
localStatisticsProvider,
localSubtitlesProvider,
localFileSystem,
searchRepository,
searchIndex,
mediator,
logger)
{
_embyApiClient = embyApiClient;
_mediaSourceRepository = mediaSourceRepository;
_televisionRepository = televisionRepository;
_searchIndex = searchIndex;
_searchRepository = searchRepository;
_pathReplacementService = pathReplacementService;
_localFileSystem = localFileSystem;
_localStatisticsProvider = localStatisticsProvider;
_localSubtitlesProvider = localSubtitlesProvider;
_mediator = mediator;
_logger = logger;
}
public async Task<Either<BaseError, Unit>> ScanLibrary(
@@ -55,385 +51,102 @@ public class EmbyTelevisionLibraryScanner : IEmbyTelevisionLibraryScanner
string apiKey,
EmbyLibrary library,
string ffmpegPath,
string ffprobePath)
string ffprobePath,
CancellationToken cancellationToken)
{
List<EmbyItemEtag> existingShows = await _televisionRepository.GetExistingShows(library);
List<EmbyPathReplacement> pathReplacements =
await _mediaSourceRepository.GetEmbyPathReplacements(library.MediaSourceId);
// TODO: maybe get quick list of item ids and etags from api to compare first
// TODO: paging?
string GetLocalPath(EmbyEpisode episode)
{
return _pathReplacementService.GetReplacementEmbyPath(
pathReplacements,
episode.GetHeadVersion().MediaFiles.Head().Path,
false);
}
List<EmbyPathReplacement> pathReplacements = await _mediaSourceRepository
.GetEmbyPathReplacements(library.MediaSourceId);
return await ScanLibrary(
_televisionRepository,
new EmbyConnectionParameters(address, apiKey),
library,
GetLocalPath,
ffmpegPath,
ffprobePath,
false,
cancellationToken);
}
Either<BaseError, List<EmbyShow>> maybeShows = await _embyApiClient.GetShowLibraryItems(
address,
apiKey,
protected override Task<Either<BaseError, List<EmbyShow>>> GetShowLibraryItems(
EmbyConnectionParameters connectionParameters,
EmbyLibrary library) =>
_embyApiClient.GetShowLibraryItems(
connectionParameters.Address,
connectionParameters.ApiKey,
library.ItemId);
foreach (BaseError error in maybeShows.LeftToSeq())
{
_logger.LogWarning(
"Error synchronizing emby library {Path}: {Error}",
library.Name,
error.Value);
}
protected override string MediaServerItemId(EmbyShow show) => show.ItemId;
protected override string MediaServerItemId(EmbySeason season) => season.ItemId;
protected override string MediaServerItemId(EmbyEpisode episode) => episode.ItemId;
foreach (List<EmbyShow> shows in maybeShows.RightToSeq())
{
await ProcessShows(
address,
apiKey,
library,
ffmpegPath,
ffprobePath,
pathReplacements,
existingShows,
shows);
protected override string MediaServerEtag(EmbyShow show) => show.Etag;
protected override string MediaServerEtag(EmbySeason season) => season.Etag;
protected override string MediaServerEtag(EmbyEpisode episode) => episode.Etag;
var incomingShowIds = shows.Map(s => s.ItemId).ToList();
var showIds = existingShows
.Filter(i => !incomingShowIds.Contains(i.ItemId))
.Map(m => m.ItemId)
.ToList();
List<int> missingShowIds = await _televisionRepository.RemoveMissingShows(library, showIds);
await _searchIndex.RemoveItems(missingShowIds);
await _televisionRepository.DeleteEmptySeasons(library);
List<int> emptyShowIds = await _televisionRepository.DeleteEmptyShows(library);
await _searchIndex.RemoveItems(emptyShowIds);
await _mediator.Publish(new LibraryScanProgress(library.Id, 0));
_searchIndex.Commit();
}
return Unit.Default;
}
private async Task ProcessShows(
string address,
string apiKey,
protected override Task<Either<BaseError, List<EmbySeason>>> GetSeasonLibraryItems(
EmbyLibrary library,
string ffmpegPath,
string ffprobePath,
List<EmbyPathReplacement> pathReplacements,
List<EmbyItemEtag> existingShows,
List<EmbyShow> shows)
{
var sortedShows = shows.OrderBy(s => s.ShowMetadata.Head().Title).ToList();
foreach (EmbyShow incoming in sortedShows)
{
decimal percentCompletion = (decimal)sortedShows.IndexOf(incoming) / shows.Count;
await _mediator.Publish(new LibraryScanProgress(library.Id, percentCompletion));
EmbyConnectionParameters connectionParameters,
EmbyShow show) =>
_embyApiClient.GetSeasonLibraryItems(
connectionParameters.Address,
connectionParameters.ApiKey,
show.ItemId);
Option<EmbyItemEtag> maybeExisting = existingShows.Find(ie => ie.ItemId == incoming.ItemId);
if (maybeExisting.IsNone)
{
incoming.LibraryPathId = library.Paths.Head().Id;
// _logger.LogDebug("INSERT: Item id is new for show {Show}", incoming.ShowMetadata.Head().Title);
if (await _televisionRepository.AddShow(incoming))
{
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { incoming });
}
}
foreach (EmbyItemEtag existing in maybeExisting)
{
if (existing.Etag == incoming.Etag)
{
return;
}
_logger.LogDebug("UPDATE: Etag has changed for show {Show}", incoming.ShowMetadata.Head().Title);
incoming.LibraryPathId = library.Paths.Head().Id;
Option<EmbyShow> updated = await _televisionRepository.Update(incoming);
if (updated.IsSome)
{
await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { updated.ValueUnsafe() });
}
}
List<EmbyItemEtag> existingSeasons =
await _televisionRepository.GetExistingSeasons(library, incoming.ItemId);
Either<BaseError, List<EmbySeason>> maybeSeasons =
await _embyApiClient.GetSeasonLibraryItems(address, apiKey, incoming.ItemId);
foreach (BaseError error in maybeSeasons.LeftToSeq())
{
_logger.LogWarning(
"Error synchronizing emby library {Path}: {Error}",
library.Name,
error.Value);
}
foreach (List<EmbySeason> seasons in maybeSeasons.RightToSeq())
{
await ProcessSeasons(
address,
apiKey,
library,
ffmpegPath,
ffprobePath,
pathReplacements,
incoming,
existingSeasons,
seasons);
var incomingSeasonIds = seasons.Map(s => s.ItemId).ToList();
var seasonIds = existingSeasons
.Filter(i => !incomingSeasonIds.Contains(i.ItemId))
.Map(m => m.ItemId)
.ToList();
await _televisionRepository.RemoveMissingSeasons(library, seasonIds);
}
}
}
private async Task ProcessSeasons(
string address,
string apiKey,
protected override Task<Either<BaseError, List<EmbyEpisode>>> GetEpisodeLibraryItems(
EmbyLibrary library,
string ffmpegPath,
string ffprobePath,
List<EmbyPathReplacement> pathReplacements,
EmbyShow show,
List<EmbyItemEtag> existingSeasons,
List<EmbySeason> seasons)
{
foreach (EmbySeason incoming in seasons)
{
Option<EmbyItemEtag> maybeExisting = existingSeasons.Find(ie => ie.ItemId == incoming.ItemId);
await maybeExisting.Match(
async existing =>
{
if (existing.Etag == incoming.Etag)
{
return;
}
EmbyConnectionParameters connectionParameters,
EmbySeason season) =>
_embyApiClient.GetEpisodeLibraryItems(
connectionParameters.Address,
connectionParameters.ApiKey,
library,
season.ItemId);
_logger.LogDebug(
"UPDATE: Etag has changed for show {Show} season {Season}",
show.ShowMetadata.Head().Title,
incoming.SeasonMetadata.Head().Title);
incoming.ShowId = show.Id;
incoming.LibraryPathId = library.Paths.Head().Id;
foreach (EmbySeason updated in await _televisionRepository.Update(incoming))
{
incoming.Show = show;
foreach (MediaItem toIndex in await _searchRepository.GetItemToIndex(updated.Id))
{
await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { toIndex });
}
}
},
async () =>
{
incoming.LibraryPathId = library.Paths.Head().Id;
_logger.LogDebug(
"INSERT: Item id is new for show {Show} season {Season}",
show.ShowMetadata.Head().Title,
incoming.SeasonMetadata.Head().Title);
if (await _televisionRepository.AddSeason(show, incoming))
{
incoming.Show = show;
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { incoming });
}
});
List<EmbyItemEtag> existingEpisodes =
await _televisionRepository.GetExistingEpisodes(library, incoming.ItemId);
Either<BaseError, List<EmbyEpisode>> maybeEpisodes =
await _embyApiClient.GetEpisodeLibraryItems(address, apiKey, incoming.ItemId);
await maybeEpisodes.Match(
async episodes =>
{
var validEpisodes = new List<EmbyEpisode>();
foreach (EmbyEpisode episode in episodes)
{
string localPath = _pathReplacementService.GetReplacementEmbyPath(
pathReplacements,
episode.MediaVersions.Head().MediaFiles.Head().Path,
false);
if (!_localFileSystem.FileExists(localPath))
{
_logger.LogWarning(
"Skipping emby episode that does not exist at {Path}",
localPath);
}
else
{
validEpisodes.Add(episode);
}
}
await ProcessEpisodes(
show.ShowMetadata.Head().Title,
incoming.SeasonMetadata.Head().Title,
library,
ffmpegPath,
ffprobePath,
pathReplacements,
incoming,
existingEpisodes,
validEpisodes);
var incomingEpisodeIds = episodes.Map(s => s.ItemId).ToList();
var episodeIds = existingEpisodes
.Filter(i => !incomingEpisodeIds.Contains(i.ItemId))
.Map(m => m.ItemId)
.ToList();
List<int> missingEpisodeIds =
await _televisionRepository.RemoveMissingEpisodes(library, episodeIds);
await _searchIndex.RemoveItems(missingEpisodeIds);
_searchIndex.Commit();
},
error =>
{
_logger.LogWarning(
"Error synchronizing emby library {Path}: {Error}",
library.Name,
error.Value);
return Task.CompletedTask;
});
}
}
private async Task ProcessEpisodes(
string showName,
string seasonName,
protected override Task<Option<ShowMetadata>> GetFullMetadata(
EmbyConnectionParameters connectionParameters,
EmbyLibrary library,
string ffmpegPath,
string ffprobePath,
List<EmbyPathReplacement> pathReplacements,
EmbySeason season,
List<EmbyItemEtag> existingEpisodes,
List<EmbyEpisode> episodes)
{
foreach (EmbyEpisode incoming in episodes)
{
EmbyEpisode incomingEpisode = incoming;
var updateStatistics = false;
MediaItemScanResult<EmbyShow> result,
EmbyShow incoming,
bool deepScan) =>
Task.FromResult(Option<ShowMetadata>.None);
Option<EmbyItemEtag> maybeExisting = existingEpisodes.Find(ie => ie.ItemId == incoming.ItemId);
await maybeExisting.Match(
async existing =>
{
try
{
if (existing.Etag == incoming.Etag)
{
return;
}
protected override Task<Option<SeasonMetadata>> GetFullMetadata(
EmbyConnectionParameters connectionParameters,
EmbyLibrary library,
MediaItemScanResult<EmbySeason> result,
EmbySeason incoming,
bool deepScan) =>
Task.FromResult(Option<SeasonMetadata>.None);
_logger.LogDebug(
"UPDATE: Etag has changed for show {Show} season {Season} episode {Episode}",
showName,
seasonName,
incoming.EpisodeMetadata.HeadOrNone().Map(em => em.EpisodeNumber));
protected override Task<Option<EpisodeMetadata>> GetFullMetadata(
EmbyConnectionParameters connectionParameters,
EmbyLibrary library,
MediaItemScanResult<EmbyEpisode> result,
EmbyEpisode incoming,
bool deepScan) =>
Task.FromResult(Option<EpisodeMetadata>.None);
updateStatistics = true;
incoming.SeasonId = season.Id;
incoming.LibraryPathId = library.Paths.Head().Id;
protected override Task<Either<BaseError, MediaItemScanResult<EmbyShow>>> UpdateMetadata(
MediaItemScanResult<EmbyShow> result,
ShowMetadata fullMetadata) =>
Task.FromResult<Either<BaseError, MediaItemScanResult<EmbyShow>>>(result);
Option<EmbyEpisode> maybeUpdated = await _televisionRepository.Update(incoming);
foreach (EmbyEpisode updated in maybeUpdated)
{
await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { updated });
protected override Task<Either<BaseError, MediaItemScanResult<EmbySeason>>> UpdateMetadata(
MediaItemScanResult<EmbySeason> result,
SeasonMetadata fullMetadata) =>
Task.FromResult<Either<BaseError, MediaItemScanResult<EmbySeason>>>(result);
incomingEpisode = updated;
}
}
catch (Exception ex)
{
updateStatistics = false;
_logger.LogError(
ex,
"Error updating episode {Path}",
incoming.MediaVersions.Head().MediaFiles.Head().Path);
}
},
async () =>
{
try
{
updateStatistics = true;
incoming.LibraryPathId = library.Paths.Head().Id;
_logger.LogDebug(
"INSERT: Item id is new for show {Show} season {Season} episode {Episode}",
showName,
seasonName,
incoming.EpisodeMetadata.HeadOrNone().Map(em => em.EpisodeNumber));
if (await _televisionRepository.AddEpisode(season, incoming))
{
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { incoming });
}
}
catch (Exception ex)
{
updateStatistics = false;
_logger.LogError(
ex,
"Error adding episode {Path}",
incoming.MediaVersions.Head().MediaFiles.Head().Path);
}
});
if (updateStatistics)
{
string localPath = _pathReplacementService.GetReplacementEmbyPath(
pathReplacements,
incoming.MediaVersions.Head().MediaFiles.Head().Path,
false);
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", localPath);
Either<BaseError, bool> refreshResult =
await _localStatisticsProvider.RefreshStatistics(
ffmpegPath,
ffprobePath,
incomingEpisode,
localPath);
if (refreshResult.Map(t => t).IfLeft(false))
{
refreshResult = await UpdateSubtitles(incomingEpisode, localPath);
}
foreach (BaseError error in refreshResult.LeftToSeq())
{
_logger.LogWarning(
"Unable to refresh {Attribute} for media item {Path}. Error: {Error}",
"Statistics",
localPath,
error.Value);
}
}
}
}
private async Task<Either<BaseError, bool>> UpdateSubtitles(EmbyEpisode episode, string localPath)
{
try
{
return await _localSubtitlesProvider.UpdateSubtitles(episode, localPath, false);
}
catch (Exception ex)
{
return BaseError.New(ex.ToString());
}
}
protected override Task<Either<BaseError, MediaItemScanResult<EmbyEpisode>>> UpdateMetadata(
MediaItemScanResult<EmbyEpisode> result,
EpisodeMetadata fullMetadata) =>
Task.FromResult<Either<BaseError, MediaItemScanResult<EmbyEpisode>>>(result);
}
+9
View File
@@ -0,0 +1,9 @@
namespace ErsatzTV.Core.Errors;
public class FailedToReadNfo : BaseError
{
public FailedToReadNfo(string message = null) : base(
string.IsNullOrWhiteSpace(message) ? "Failed to read NFO metadata" : $"Failed to read NFO metadata: {message}")
{
}
}
+5 -4
View File
@@ -9,19 +9,20 @@
<ItemGroup>
<PackageReference Include="Bugsnag" Version="3.0.1" />
<PackageReference Include="Destructurama.Attributed" Version="3.0.0" />
<PackageReference Include="Flurl" Version="3.0.4" />
<PackageReference Include="LanguageExt.Core" Version="4.0.4" />
<PackageReference Include="Flurl" Version="3.0.5" />
<PackageReference Include="LanguageExt.Core" Version="4.1.0" />
<PackageReference Include="MediatR" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="6.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="6.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="6.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="6.0.1" />
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.1.46">
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="2.2.0" />
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.2.32">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
<PackageReference Include="Serilog" Version="2.10.0" />
<PackageReference Include="Serilog" Version="2.11.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="4.0.1" />
</ItemGroup>
@@ -18,16 +18,19 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
private readonly IFFmpegStreamSelector _ffmpegStreamSelector;
private readonly ILogger<FFmpegLibraryProcessService> _logger;
private readonly FFmpegPlaybackSettingsCalculator _playbackSettingsCalculator;
private readonly ITempFilePool _tempFilePool;
public FFmpegLibraryProcessService(
FFmpegProcessService ffmpegProcessService,
FFmpegPlaybackSettingsCalculator playbackSettingsCalculator,
IFFmpegStreamSelector ffmpegStreamSelector,
ITempFilePool tempFilePool,
ILogger<FFmpegLibraryProcessService> logger)
{
_ffmpegProcessService = ffmpegProcessService;
_playbackSettingsCalculator = playbackSettingsCalculator;
_ffmpegStreamSelector = ffmpegStreamSelector;
_tempFilePool = tempFilePool;
_logger = logger;
}
@@ -56,7 +59,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
TimeSpan inPoint,
TimeSpan outPoint,
long ptsOffset,
Option<int> targetFramerate)
Option<int> targetFramerate,
bool disableWatermarks)
{
MediaStream videoStream = await _ffmpegStreamSelector.SelectVideoStream(videoVersion);
Option<MediaStream> maybeAudioStream =
@@ -87,8 +91,9 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
hlsRealtime,
targetFramerate);
Option<WatermarkOptions> watermarkOptions =
await _ffmpegProcessService.GetWatermarkOptions(
Option<WatermarkOptions> watermarkOptions = disableWatermarks
? None
: await _ffmpegProcessService.GetWatermarkOptions(
ffprobePath,
channel,
playoutItemWatermark,
@@ -174,14 +179,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
Option<WatermarkInputFile> watermarkInputFile = GetWatermarkInputFile(watermarkOptions, maybeFadePoints);
string videoFormat = playbackSettings.VideoFormat switch
{
FFmpegProfileVideoFormat.Hevc => VideoFormat.Hevc,
FFmpegProfileVideoFormat.H264 => VideoFormat.H264,
FFmpegProfileVideoFormat.Mpeg2Video => VideoFormat.Mpeg2Video,
FFmpegProfileVideoFormat.Copy => VideoFormat.Copy,
_ => throw new ArgumentOutOfRangeException($"unexpected video format {playbackSettings.VideoFormat}")
};
string videoFormat = GetVideoFormat(playbackSettings);
HardwareAccelerationMode hwAccel = playbackSettings.HardwareAcceleration switch
{
@@ -254,14 +252,128 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
return GetCommand(ffmpegPath, videoInputFile, audioInputFile, watermarkInputFile, None, pipeline);
}
public Task<Command> ForError(
public async Task<Command> ForError(
string ffmpegPath,
Channel channel,
Option<TimeSpan> duration,
string errorMessage,
bool hlsRealtime,
long ptsOffset) =>
_ffmpegProcessService.ForError(ffmpegPath, channel, duration, errorMessage, hlsRealtime, ptsOffset);
long ptsOffset)
{
FFmpegPlaybackSettings playbackSettings = _playbackSettingsCalculator.CalculateErrorSettings(
channel.StreamingMode,
channel.FFmpegProfile,
hlsRealtime);
IDisplaySize desiredResolution = channel.FFmpegProfile.Resolution;
var fontSize = (int)Math.Round(channel.FFmpegProfile.Resolution.Height / 20.0);
var margin = (int)Math.Round(channel.FFmpegProfile.Resolution.Height * 0.05);
string subtitleFile = await new SubtitleBuilder(_tempFilePool)
.WithResolution(desiredResolution)
.WithFontName("Roboto")
.WithFontSize(fontSize)
.WithAlignment(2)
.WithMarginV(margin)
.WithPrimaryColor("&HFFFFFF")
.WithFormattedContent(errorMessage.Replace(Environment.NewLine, "\\N"))
.BuildFile();
string audioFormat = playbackSettings.AudioFormat switch
{
FFmpegProfileAudioFormat.Ac3 => AudioFormat.Ac3,
_ => AudioFormat.Aac
};
var audioState = new AudioState(
audioFormat,
playbackSettings.AudioChannels,
playbackSettings.AudioBitrate,
playbackSettings.AudioBufferSize,
playbackSettings.AudioSampleRate,
Option<TimeSpan>.None,
playbackSettings.NormalizeLoudness);
var desiredState = new FrameState(
playbackSettings.RealtimeOutput,
false,
GetVideoFormat(playbackSettings),
new PixelFormatYuv420P(),
new FrameSize(desiredResolution.Width, desiredResolution.Height),
new FrameSize(desiredResolution.Width, desiredResolution.Height),
playbackSettings.FrameRate,
playbackSettings.VideoBitrate,
playbackSettings.VideoBufferSize,
playbackSettings.VideoTrackTimeScale,
playbackSettings.Deinterlace);
OutputFormatKind outputFormat = channel.StreamingMode == StreamingMode.HttpLiveStreamingSegmenter
? OutputFormatKind.Hls
: OutputFormatKind.MpegTs;
Option<string> hlsPlaylistPath = outputFormat == OutputFormatKind.Hls
? Path.Combine(FileSystemLayout.TranscodeFolder, channel.Number, "live.m3u8")
: Option<string>.None;
Option<string> hlsSegmentTemplate = outputFormat == OutputFormatKind.Hls
? Path.Combine(FileSystemLayout.TranscodeFolder, channel.Number, "live%06d.ts")
: Option<string>.None;
string videoPath = Path.Combine(FileSystemLayout.ResourcesCacheFolder, "background.png");
var videoVersion = BackgroundImageMediaVersion.ForPath(videoPath, desiredResolution);
var ffmpegVideoStream = new VideoStream(
0,
VideoFormat.GeneratedImage,
new PixelFormatYuv420P(),
new FrameSize(videoVersion.Width, videoVersion.Height),
None,
true);
var videoInputFile = new VideoInputFile(videoPath, new List<VideoStream> { ffmpegVideoStream });
var ffmpegState = new FFmpegState(
false,
HardwareAccelerationMode.None,
None,
None,
playbackSettings.StreamSeek,
duration,
channel.StreamingMode != StreamingMode.HttpLiveStreamingDirect,
"ErsatzTV",
channel.Name,
None,
outputFormat,
hlsPlaylistPath,
hlsSegmentTemplate,
ptsOffset);
var ffmpegSubtitleStream = new ErsatzTV.FFmpeg.MediaStream(0, "ass", StreamKind.Video);
var audioInputFile = new NullAudioInputFile(audioState);
var subtitleInputFile = new SubtitleInputFile(
subtitleFile,
new List<ErsatzTV.FFmpeg.MediaStream> { ffmpegSubtitleStream },
false);
_logger.LogDebug("FFmpeg desired error state {FrameState}", desiredState);
var pipelineBuilder = new PipelineBuilder(
videoInputFile,
audioInputFile,
None,
subtitleInputFile,
FileSystemLayout.FFmpegReportsFolder,
FileSystemLayout.FontsCacheFolder,
_logger);
FFmpegPipeline pipeline = pipelineBuilder.Build(ffmpegState, desiredState);
return GetCommand(ffmpegPath, videoInputFile, audioInputFile, None, None, pipeline);
}
public Command ConcatChannel(string ffmpegPath, bool saveReports, Channel channel, string scheme, string host)
{
@@ -478,4 +590,14 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
private static Option<string> VaapiDeviceName(HardwareAccelerationMode accelerationMode, string vaapiDevice) =>
accelerationMode == HardwareAccelerationMode.Vaapi ? vaapiDevice : Option<string>.None;
private static string GetVideoFormat(FFmpegPlaybackSettings playbackSettings) =>
playbackSettings.VideoFormat switch
{
FFmpegProfileVideoFormat.Hevc => VideoFormat.Hevc,
FFmpegProfileVideoFormat.H264 => VideoFormat.H264,
FFmpegProfileVideoFormat.Mpeg2Video => VideoFormat.Mpeg2Video,
FFmpegProfileVideoFormat.Copy => VideoFormat.Copy,
_ => throw new ArgumentOutOfRangeException($"unexpected video format {playbackSettings.VideoFormat}")
};
}
@@ -170,14 +170,22 @@ public class FFmpegPlaybackSettingsCalculator
return result;
}
public FFmpegPlaybackSettings CalculateErrorSettings(FFmpegProfile ffmpegProfile) =>
public FFmpegPlaybackSettings CalculateErrorSettings(
StreamingMode streamingMode,
FFmpegProfile ffmpegProfile,
bool hlsRealtime) =>
new()
{
HardwareAcceleration = HardwareAccelerationKind.None,
ThreadCount = ffmpegProfile.ThreadCount,
ThreadCount = 1,
FormatFlags = CommonFormatFlags,
VideoFormat = ffmpegProfile.VideoFormat,
AudioFormat = ffmpegProfile.AudioFormat
AudioFormat = ffmpegProfile.AudioFormat,
RealtimeOutput = streamingMode switch
{
StreamingMode.HttpLiveStreamingSegmenter => hlsRealtime,
_ => true
}
};
private static bool NeedToScale(FFmpegProfile ffmpegProfile, MediaVersion version) =>

Some files were not shown because too many files have changed in this diff Show More