Compare commits
55
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f895ab5304 | ||
|
|
07c54ff45f | ||
|
|
6a29ce2049 | ||
|
|
d19e95fb38 | ||
|
|
d78daf8735 | ||
|
|
4f6522379d | ||
|
|
9e0972fec0 | ||
|
|
6d564233ed | ||
|
|
47252b1243 | ||
|
|
bd5b52922d | ||
|
|
59c793b9be | ||
|
|
3ad1ba01f8 | ||
|
|
ab10f0ed81 | ||
|
|
44dd68fe59 | ||
|
|
6326189444 | ||
|
|
198c693208 | ||
|
|
1431b33a98 | ||
|
|
e81a8e58ea | ||
|
|
daf7114ce2 | ||
|
|
8542bc20b1 | ||
|
|
9decb91bf7 | ||
|
|
fcfd579b37 | ||
|
|
e9be182bed | ||
|
|
610e261cd7 | ||
|
|
f65818c838 | ||
|
|
1651d2895e | ||
|
|
b90c536dcb | ||
|
|
5c98eb3df5 | ||
|
|
bdff5eba75 | ||
|
|
7d112eda05 | ||
|
|
4f16431ca0 | ||
|
|
69b39c6940 | ||
|
|
fe7181ea1d | ||
|
|
88b287a094 | ||
|
|
7953e3ad85 | ||
|
|
8ba6374165 | ||
|
|
973dd4b53d | ||
|
|
6facd745ec | ||
|
|
32c4c4ec8b | ||
|
|
ecb6ed37f0 | ||
|
|
2a8bf57930 | ||
|
|
1ebc4b62e3 | ||
|
|
4ae671b633 | ||
|
|
87aa69f4cc | ||
|
|
404ea49e35 | ||
|
|
4ed40acfbe | ||
|
|
17f540dc99 | ||
|
|
780ebc01ee | ||
|
|
0a0fb71b94 | ||
|
|
53d6ecae8d | ||
|
|
837f311ec0 | ||
|
|
a9a89d04ea | ||
|
|
2e1073eb53 | ||
|
|
7687278b80 | ||
|
|
392aebd46f |
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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>>;
|
||||
+12
-9
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
+19
-7
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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 "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.</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=&f=2"">https://rovimusic.rovicorp.com/image.jpg?c=73pC-Gp0OovlmiQL7Wp5Yd_M69_UI9rrJSVvWL2-yAg=&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 Vision’s 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 & 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 Vision’s 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's Justice League</title>
|
||||
<originaltitle>Zack Snyder'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'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'è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.</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's Justice League</title>
|
||||
<originaltitle>Zack Snyder'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'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);
|
||||
@@ -5,4 +5,5 @@ public enum ChannelSubtitleMode
|
||||
None = 0,
|
||||
Forced = 1,
|
||||
Default = 2,
|
||||
Any = 3}
|
||||
Any = 3
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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}")
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user