Compare commits
36
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27fefa1b38 | ||
|
|
fc3175591e | ||
|
|
3363d2c9d7 | ||
|
|
1d5217fa84 | ||
|
|
904cdb8780 | ||
|
|
85fee64565 | ||
|
|
13cfb9728f | ||
|
|
60b82876ea | ||
|
|
a99249c375 | ||
|
|
36e6ef4c18 | ||
|
|
21e53532c1 | ||
|
|
a864d53327 | ||
|
|
e6446f9983 | ||
|
|
ad40213f90 | ||
|
|
45c6d20fd0 | ||
|
|
5439db89a7 | ||
|
|
a39231bb5a | ||
|
|
4c8584b517 | ||
|
|
ca8bcacbd3 | ||
|
|
f27286d1dd | ||
|
|
23870b75f7 | ||
|
|
7f5a91c643 | ||
|
|
f1f50e883c | ||
|
|
7506f49f5b | ||
|
|
944f1e4307 | ||
|
|
f7de9ac5ea | ||
|
|
1eb51ad2f4 | ||
|
|
c3e0aaf0b7 | ||
|
|
b9912b47df | ||
|
|
55fb2624e7 | ||
|
|
8ced20dc39 | ||
|
|
e718cb0faf | ||
|
|
e218ff9a6d | ||
|
|
c2a49cbaea | ||
|
|
17e74f7314 | ||
|
|
2032bb4777 |
@@ -49,7 +49,7 @@ jobs:
|
||||
tag=$(git describe --tags --abbrev=0)
|
||||
tag2="${tag:1}"
|
||||
short=$(git rev-parse --short HEAD)
|
||||
final="${tag2/prealpha/$short}"
|
||||
final="${tag2/alpha/$short}"
|
||||
echo "GIT_TAG=${final}" >> $GITHUB_ENV
|
||||
|
||||
- name: Set up Docker Buildx Base
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
# For most projects, this workflow file will not need changing; you simply need
|
||||
# to commit it to your repository.
|
||||
#
|
||||
# You may wish to alter this file to override the set of languages analyzed,
|
||||
# or to provide custom queries or build logic.
|
||||
#
|
||||
# ******** NOTE ********
|
||||
# We have attempted to detect the languages in your repository. Please check
|
||||
# the `language` matrix defined below to confirm you have the correct set of
|
||||
# supported CodeQL languages.
|
||||
#
|
||||
name: "CodeQL"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
pull_request:
|
||||
# The branches below must be a subset of the branches above
|
||||
branches: [ main ]
|
||||
schedule:
|
||||
- cron: '30 3 * * 0'
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [ 'csharp' ]
|
||||
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ]
|
||||
# Learn more:
|
||||
# https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v1
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
# By default, queries listed here will override any specified in a config file.
|
||||
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||
# queries: ./path/to/local/query, your-org/your-repo/queries@main
|
||||
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v1
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 https://git.io/JvXDl
|
||||
|
||||
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
|
||||
# and modify them (or add more) to build your code if your project
|
||||
# uses a compiled language
|
||||
|
||||
#- run: |
|
||||
# make bootstrap
|
||||
# make release
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v1
|
||||
@@ -82,7 +82,7 @@ jobs:
|
||||
run: |
|
||||
tag=$(git describe --tags --abbrev=0)
|
||||
echo "GIT_TAG=${tag:1}" >> $GITHUB_ENV
|
||||
echo "DOCKER_TAG=${tag/-prealpha/}" >> $GITHUB_ENV
|
||||
echo "DOCKER_TAG=${tag/-alpha/}" >> $GITHUB_ENV
|
||||
|
||||
- name: Set up Docker Buildx Base
|
||||
uses: docker/setup-buildx-action@v1
|
||||
|
||||
+70
-1
@@ -5,6 +5,70 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.0.62-alpha] - 2021-10-05
|
||||
### Added
|
||||
- Support IMDB ids from Plex libraries, which may improve Trakt matching for some items
|
||||
|
||||
### Fixed
|
||||
- Include Specials/Season 0 `episode-num` entry in XMLTV
|
||||
- Fix some transcoding edge cases with VAAPI and pixel formats `yuv420p10le`, `yuv444p10le` and `yuv444p`
|
||||
- Update Plex movie and episode paths when they are changed within Plex
|
||||
- Always use `libx264` software encoder for error messages
|
||||
|
||||
## [0.0.61-alpha] - 2021-09-30
|
||||
### Fixed
|
||||
- Revert nvenc/cuda filter change from v60
|
||||
|
||||
## [0.0.60-alpha] - 2021-09-25
|
||||
### Added
|
||||
- Add Trakt list support under `Lists` > `Trakt Lists`
|
||||
- Trakt lists can be added by url or by `user/list`
|
||||
- To re-download a Trakt list, simply add it again (no need to delete)
|
||||
- See `Logs` for unmatched item details
|
||||
- Trakt lists can only be scheduled by using Smart Collections
|
||||
- Add seasons to search index
|
||||
- This is needed because Trakt lists can contain seasons
|
||||
- This requires rebuilding the search index and search results may be empty or incomplete until the rebuild is complete
|
||||
|
||||
### Fixed
|
||||
- Fix local television scanner to properly update episode metadata when NFO files have been added/changed
|
||||
- Properly detect ffmpeg nvenc (cuda) support in Hardware Acceleration health check
|
||||
- Fix nvenc/cuda filter for some yuv420p content
|
||||
|
||||
## [0.0.59-alpha] - 2021-09-18
|
||||
### Added
|
||||
- Add `Health Checks` table to home page to identify and surface common misconfigurations
|
||||
- `FFmpeg Version` checks `ffmpeg` and `ffprobe` versions
|
||||
- `FFmpeg Reports` checks whether ffmpeg troubleshooting reports are enabled since they can use a lot of disk space over time
|
||||
- `Hardware Acceleration` checks whether channels that transcode are using acceleration methods that ffmpeg claims to support
|
||||
- `Movie Metadata` checks whether all movies have metadata (fallback metadata counts as metadata)
|
||||
- `Episode Metadata` checks whether all episodes have metadata (fallback metadata counts as metadata)
|
||||
- `Zero Duration` checks whether all movies and episodes have a valid (non-zero) duration
|
||||
- `VAAPI Driver` checks whether a vaapi driver preference is configured when using the vaapi docker image
|
||||
- Add setting to each playout to schedule an automatic daily rebuild
|
||||
- This is useful if the playout uses a smart collection with `released_onthisday`
|
||||
|
||||
### Fixed
|
||||
- Fix docker vaapi support for newer Intel platforms (Gen 8+)
|
||||
- This includes a new setting to force a particular vaapi driver (`iHD` or `i965`), as some Gen 8 or 9 hardware that is supported by both drivers will perform better with one or the other
|
||||
- Fix scanning and indexing local movies and episodes without NFO metadata
|
||||
- Fix displaying seasons for shows with no year (in metadata or in folder name)
|
||||
- Fix "direct play" in MPEG-TS mode (copy audio and video stream when `Transcode` is unchecked)
|
||||
|
||||
## [0.0.58-alpha] - 2021-09-15
|
||||
### Added
|
||||
- Add `released_notinthelast` search field for relative release date queries
|
||||
- Syntax is a number and a unit (days, weeks, months, years) like `1 week` or `2 years`
|
||||
- Add `released_onthisday` search field for historical queries
|
||||
- Syntax is `released_onthisday:1` and will search for items released on this month number and day number in prior years
|
||||
- Add tooltip explaining `Keep Multi-Part Episodes Together`
|
||||
|
||||
### Fixed
|
||||
- Properly display watermark when no other video filters (like scaling or padding) are required
|
||||
- Fix building some playouts in timezones with positive offsets (like UTC+2)
|
||||
- Fix `Shuffle In Order` so all collections/shows start from the earliest episode
|
||||
- You may need to rebuild playouts to see this fixed behavior more quickly
|
||||
|
||||
## [0.0.57-alpha] - 2021-09-10
|
||||
### Added
|
||||
- Add `released_inthelast` search field for relative release date queries
|
||||
@@ -580,7 +644,12 @@ 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.0.57-alpha...HEAD
|
||||
[Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.62-alpha...HEAD
|
||||
[0.0.62-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.61-alpha...v0.0.62-alpha
|
||||
[0.0.61-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.60-alpha...v0.0.61-alpha
|
||||
[0.0.60-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.59-alpha...v0.0.60-alpha
|
||||
[0.0.59-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.58-alpha...v0.0.59-alpha
|
||||
[0.0.58-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.57-alpha...v0.0.58-alpha
|
||||
[0.0.57-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.56-alpha...v0.0.57-alpha
|
||||
[0.0.56-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.55-alpha...v0.0.56-alpha
|
||||
[0.0.55-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.54-alpha...v0.0.55-alpha
|
||||
|
||||
@@ -67,7 +67,7 @@ namespace ErsatzTV.Application.Emby.Commands
|
||||
|
||||
private async Task<Unit> Synchronize(RequestParameters parameters)
|
||||
{
|
||||
var lastScan = new DateTimeOffset(parameters.Library.LastScan ?? DateTime.MinValue, TimeSpan.Zero);
|
||||
var lastScan = new DateTimeOffset(parameters.Library.LastScan ?? SystemTime.MinValueUtc, TimeSpan.Zero);
|
||||
DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(parameters.LibraryRefreshInterval);
|
||||
if (parameters.ForceScan || nextScan < DateTimeOffset.Now)
|
||||
{
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
</PackageReference>
|
||||
<PackageReference Include="MediatR" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="5.0.0" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="16.10.56">
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.0.63">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
|
||||
@@ -37,17 +37,17 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
|
||||
p.Transcode = update.Transcode;
|
||||
p.HardwareAcceleration = update.HardwareAcceleration;
|
||||
p.ResolutionId = update.ResolutionId;
|
||||
p.NormalizeVideo = update.NormalizeVideo;
|
||||
p.NormalizeVideo = update.Transcode && update.NormalizeVideo;
|
||||
p.VideoCodec = update.VideoCodec;
|
||||
p.VideoBitrate = update.VideoBitrate;
|
||||
p.VideoBufferSize = update.VideoBufferSize;
|
||||
p.AudioCodec = update.AudioCodec;
|
||||
p.AudioBitrate = update.AudioBitrate;
|
||||
p.AudioBufferSize = update.AudioBufferSize;
|
||||
p.NormalizeLoudness = update.NormalizeLoudness;
|
||||
p.NormalizeLoudness = update.Transcode && update.NormalizeLoudness;
|
||||
p.AudioChannels = update.AudioChannels;
|
||||
p.AudioSampleRate = update.AudioSampleRate;
|
||||
p.NormalizeAudio = update.NormalizeAudio;
|
||||
p.NormalizeAudio = update.Transcode && update.NormalizeAudio;
|
||||
await dbContext.SaveChangesAsync();
|
||||
return new UpdateFFmpegProfileResult(p.Id);
|
||||
}
|
||||
|
||||
@@ -115,6 +115,10 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
|
||||
await _configElementRepository.Delete(ConfigElementKey.FFmpegGlobalWatermarkId);
|
||||
}
|
||||
|
||||
await _configElementRepository.Upsert(
|
||||
ConfigElementKey.FFmpegVaapiDriver,
|
||||
(int)request.Settings.VaapiDriver);
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
namespace ErsatzTV.Application.FFmpegProfiles
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
|
||||
namespace ErsatzTV.Application.FFmpegProfiles
|
||||
{
|
||||
public class FFmpegSettingsViewModel
|
||||
{
|
||||
@@ -8,5 +10,6 @@
|
||||
public string PreferredLanguageCode { get; set; }
|
||||
public bool SaveReports { get; set; }
|
||||
public int? GlobalWatermarkId { get; set; }
|
||||
public VaapiDriver VaapiDriver { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
@@ -28,6 +29,8 @@ namespace ErsatzTV.Application.FFmpegProfiles.Queries
|
||||
await _configElementRepository.GetValue<string>(ConfigElementKey.FFmpegPreferredLanguageCode);
|
||||
Option<int> watermark =
|
||||
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegGlobalWatermarkId);
|
||||
Option<int> vaapiDriver =
|
||||
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegVaapiDriver);
|
||||
|
||||
var result = new FFmpegSettingsViewModel
|
||||
{
|
||||
@@ -36,6 +39,7 @@ namespace ErsatzTV.Application.FFmpegProfiles.Queries
|
||||
DefaultFFmpegProfileId = await defaultFFmpegProfileId.IfNoneAsync(0),
|
||||
SaveReports = await saveReports.IfNoneAsync(false),
|
||||
PreferredLanguageCode = await preferredLanguageCode.IfNoneAsync("eng"),
|
||||
VaapiDriver = (VaapiDriver)await vaapiDriver.IfNoneAsync(0)
|
||||
};
|
||||
|
||||
foreach (int watermarkId in watermark)
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
using System.Collections.Generic;
|
||||
using ErsatzTV.Core.Health;
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.Health.Queries
|
||||
{
|
||||
public record GetAllHealthCheckResults : IRequest<List<HealthCheckResult>>;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Health;
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.Health.Queries
|
||||
{
|
||||
public class GetAllHealthCheckResultsHandler : IRequestHandler<GetAllHealthCheckResults, List<HealthCheckResult>>
|
||||
{
|
||||
private readonly IHealthCheckService _healthCheckService;
|
||||
|
||||
public GetAllHealthCheckResultsHandler(IHealthCheckService healthCheckService) =>
|
||||
_healthCheckService = healthCheckService;
|
||||
|
||||
public async Task<List<HealthCheckResult>> Handle(
|
||||
GetAllHealthCheckResults request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
List<HealthCheckResult> results = await _healthCheckService.PerformHealthChecks();
|
||||
return results.Filter(r => r.Status != HealthCheckStatus.NotApplicable).ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -67,7 +67,7 @@ namespace ErsatzTV.Application.Jellyfin.Commands
|
||||
|
||||
private async Task<Unit> Synchronize(RequestParameters parameters)
|
||||
{
|
||||
var lastScan = new DateTimeOffset(parameters.Library.LastScan ?? DateTime.MinValue, TimeSpan.Zero);
|
||||
var lastScan = new DateTimeOffset(parameters.Library.LastScan ?? SystemTime.MinValueUtc, TimeSpan.Zero);
|
||||
DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(parameters.LibraryRefreshInterval);
|
||||
if (parameters.ForceScan || nextScan < DateTimeOffset.Now)
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Emby;
|
||||
using ErsatzTV.Core.Jellyfin;
|
||||
@@ -20,7 +21,7 @@ namespace ErsatzTV.Application.MediaCards
|
||||
showMetadata.Year?.ToString(),
|
||||
showMetadata.SortTitle,
|
||||
GetPoster(showMetadata, maybeJellyfin, maybeEmby));
|
||||
|
||||
|
||||
internal static TelevisionSeasonCardViewModel ProjectToViewModel(
|
||||
Season season,
|
||||
Option<JellyfinMediaSource> maybeJellyfin,
|
||||
@@ -36,6 +37,26 @@ namespace ErsatzTV.Application.MediaCards
|
||||
.IfNone(string.Empty),
|
||||
season.SeasonNumber == 0 ? "S" : season.SeasonNumber.ToString());
|
||||
|
||||
internal static TelevisionSeasonCardViewModel ProjectToViewModel(
|
||||
SeasonMetadata seasonMetadata,
|
||||
Option<JellyfinMediaSource> maybeJellyfin,
|
||||
Option<EmbyMediaSource> maybeEmby)
|
||||
{
|
||||
string showTitle = seasonMetadata.Season.Show.ShowMetadata.HeadOrNone().Match(
|
||||
m => m.Title ?? string.Empty,
|
||||
() => string.Empty);
|
||||
|
||||
return new TelevisionSeasonCardViewModel(
|
||||
showTitle,
|
||||
seasonMetadata.SeasonId,
|
||||
seasonMetadata.Season.SeasonNumber,
|
||||
showTitle,
|
||||
GetSeasonName(seasonMetadata.Season.SeasonNumber),
|
||||
$"{showTitle}_{seasonMetadata.Season.SeasonNumber:0000}",
|
||||
GetPoster(seasonMetadata, maybeJellyfin, maybeEmby),
|
||||
seasonMetadata.Season.SeasonNumber == 0 ? "S" : seasonMetadata.Season.SeasonNumber.ToString());
|
||||
}
|
||||
|
||||
internal static TelevisionEpisodeCardViewModel ProjectToViewModel(
|
||||
EpisodeMetadata episodeMetadata,
|
||||
Option<JellyfinMediaSource> maybeJellyfin,
|
||||
@@ -43,7 +64,7 @@ namespace ErsatzTV.Application.MediaCards
|
||||
bool isSearchResult) =>
|
||||
new(
|
||||
episodeMetadata.EpisodeId,
|
||||
episodeMetadata.ReleaseDate ?? DateTime.MinValue,
|
||||
episodeMetadata.ReleaseDate ?? SystemTime.MinValueUtc,
|
||||
episodeMetadata.Episode.Season.Show.ShowMetadata.HeadOrNone().Match(
|
||||
m => m.Title ?? string.Empty,
|
||||
() => string.Empty),
|
||||
|
||||
@@ -7,6 +7,7 @@ using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using static ErsatzTV.Application.MediaCards.Mapper;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCards.Queries
|
||||
{
|
||||
@@ -41,7 +42,7 @@ namespace ErsatzTV.Application.MediaCards.Queries
|
||||
.GetPagedSeasons(request.TelevisionShowId, request.PageNumber, request.PageSize)
|
||||
.Map(list => list.Map(s => ProjectToViewModel(s, maybeJellyfin, maybeEmby)).ToList());
|
||||
|
||||
return new TelevisionSeasonCardResultsViewModel(count, results);
|
||||
return new TelevisionSeasonCardResultsViewModel(count, results, None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
using System.Collections.Generic;
|
||||
using ErsatzTV.Core.Search;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCards
|
||||
{
|
||||
public record TelevisionSeasonCardResultsViewModel(int Count, List<TelevisionSeasonCardViewModel> Cards);
|
||||
public record TelevisionSeasonCardResultsViewModel(
|
||||
int Count,
|
||||
List<TelevisionSeasonCardViewModel> Cards,
|
||||
Option<SearchPageMap> PageMap);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands
|
||||
int CollectionId,
|
||||
List<int> MovieIds,
|
||||
List<int> ShowIds,
|
||||
List<int> SeasonIds,
|
||||
List<int> EpisodeIds,
|
||||
List<int> ArtistIds,
|
||||
List<int> MusicVideoIds) : MediatR.IRequest<Either<BaseError, Unit>>;
|
||||
|
||||
@@ -52,6 +52,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands
|
||||
{
|
||||
var allItems = request.MovieIds
|
||||
.Append(request.ShowIds)
|
||||
.Append(request.SeasonIds)
|
||||
.Append(request.EpisodeIds)
|
||||
.Append(request.ArtistIds)
|
||||
.Append(request.MusicVideoIds)
|
||||
@@ -77,12 +78,15 @@ namespace ErsatzTV.Application.MediaCollections.Commands
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private async Task<Validation<BaseError, Collection>> Validate(TvContext dbContext, AddItemsToCollection request) =>
|
||||
private async Task<Validation<BaseError, Collection>> Validate(
|
||||
TvContext dbContext,
|
||||
AddItemsToCollection request) =>
|
||||
(await CollectionMustExist(dbContext, request),
|
||||
await ValidateMovies(request),
|
||||
await ValidateShows(request),
|
||||
await ValidateSeasons(request),
|
||||
await ValidateEpisodes(request))
|
||||
.Apply((collection, _, _, _) => collection);
|
||||
.Apply((collection, _, _, _, _) => collection);
|
||||
|
||||
private static Task<Validation<BaseError, Collection>> CollectionMustExist(
|
||||
TvContext dbContext,
|
||||
@@ -106,6 +110,13 @@ namespace ErsatzTV.Application.MediaCollections.Commands
|
||||
.MapT(_ => Unit.Default)
|
||||
.Map(v => v.ToValidation<BaseError>("Show does not exist"));
|
||||
|
||||
private Task<Validation<BaseError, Unit>> ValidateSeasons(AddItemsToCollection request) =>
|
||||
_televisionRepository.AllSeasonsExist(request.SeasonIds)
|
||||
.Map(Optional)
|
||||
.Filter(v => v == true)
|
||||
.MapT(_ => Unit.Default)
|
||||
.Map(v => v.ToValidation<BaseError>("Season does not exist"));
|
||||
|
||||
private Task<Validation<BaseError, Unit>> ValidateEpisodes(AddItemsToCollection request) =>
|
||||
_televisionRepository.AllEpisodesExist(request.EpisodeIds)
|
||||
.Map(Optional)
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
using ErsatzTV.Core;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Unit = LanguageExt.Unit;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections.Commands
|
||||
{
|
||||
public record AddTraktList(string TraktListUrl) : IRequest<Either<BaseError, Unit>>, IBackgroundServiceRequest;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Interfaces.Locking;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Interfaces.Trakt;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Unit = LanguageExt.Unit;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections.Commands
|
||||
{
|
||||
public class AddTraktListHandler : TraktCommandBase, IRequestHandler<AddTraktList, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly IEntityLocker _entityLocker;
|
||||
|
||||
public AddTraktListHandler(
|
||||
ITraktApiClient traktApiClient,
|
||||
ISearchRepository searchRepository,
|
||||
ISearchIndex searchIndex,
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ILogger<AddTraktListHandler> logger,
|
||||
IEntityLocker entityLocker)
|
||||
: base(traktApiClient, searchRepository, searchIndex, logger)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_entityLocker = entityLocker;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Unit>> Handle(AddTraktList request, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
Validation<BaseError, Parameters> validation = ValidateUrl(request);
|
||||
return await validation.Match(
|
||||
DoAdd,
|
||||
error => Task.FromResult<Either<BaseError, Unit>>(error.Join()));
|
||||
}
|
||||
finally
|
||||
{
|
||||
_entityLocker.UnlockTrakt();
|
||||
}
|
||||
}
|
||||
|
||||
private static Validation<BaseError, Parameters> ValidateUrl(AddTraktList request)
|
||||
{
|
||||
const string PATTERN = @"(?:https:\/\/trakt\.tv\/users\/)?([\w\-_]+)\/(?:lists\/)?([\w\-_]+)";
|
||||
Match match = Regex.Match(request.TraktListUrl, PATTERN);
|
||||
if (match.Success)
|
||||
{
|
||||
string user = match.Groups[1].Value;
|
||||
string list = match.Groups[2].Value;
|
||||
return new Parameters(user, list);
|
||||
}
|
||||
|
||||
return BaseError.New("Invalid Trakt list url");
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, Unit>> DoAdd(Parameters parameters)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
|
||||
return await TraktApiClient.GetUserList(parameters.User, parameters.List)
|
||||
.BindT(list => SaveList(dbContext, list))
|
||||
.BindT(list => SaveListItems(dbContext, list))
|
||||
.BindT(list => MatchListItems(dbContext, list))
|
||||
.MapT(_ => Unit.Default);
|
||||
|
||||
// match list items (and update in search index)
|
||||
}
|
||||
|
||||
private record Parameters(string User, string List);
|
||||
}
|
||||
}
|
||||
@@ -56,7 +56,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands
|
||||
name => new MultiCollection
|
||||
{
|
||||
Name = name,
|
||||
MultiCollectionItems = request.Items.Map(
|
||||
MultiCollectionItems = request.Items.Bind(
|
||||
i =>
|
||||
{
|
||||
if (i.CollectionId.HasValue)
|
||||
@@ -70,12 +70,10 @@ namespace ErsatzTV.Application.MediaCollections.Commands
|
||||
});
|
||||
}
|
||||
|
||||
return None;
|
||||
return Option<MultiCollectionItem>.None;
|
||||
})
|
||||
.Sequence()
|
||||
.Flatten()
|
||||
.ToList(),
|
||||
MultiCollectionSmartItems = request.Items.Map(
|
||||
MultiCollectionSmartItems = request.Items.Bind(
|
||||
i =>
|
||||
{
|
||||
if (i.SmartCollectionId.HasValue)
|
||||
@@ -89,10 +87,8 @@ namespace ErsatzTV.Application.MediaCollections.Commands
|
||||
});
|
||||
}
|
||||
|
||||
return None;
|
||||
return Option<MultiCollectionSmartItem>.None;
|
||||
})
|
||||
.Sequence()
|
||||
.Flatten()
|
||||
.ToList()
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
using ErsatzTV.Core;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections.Commands
|
||||
{
|
||||
public record DeleteTraktList(int TraktListId) : IRequest<Either<BaseError, LanguageExt.Unit>>,
|
||||
IBackgroundServiceRequest;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Locking;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Interfaces.Trakt;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using LanguageExt;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections.Commands
|
||||
{
|
||||
public class DeleteTraktListHandler : TraktCommandBase, MediatR.IRequestHandler<DeleteTraktList, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly ISearchRepository _searchRepository;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly IEntityLocker _entityLocker;
|
||||
|
||||
public DeleteTraktListHandler(
|
||||
ITraktApiClient traktApiClient,
|
||||
ISearchRepository searchRepository,
|
||||
ISearchIndex searchIndex,
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ILogger<DeleteTraktListHandler> logger,
|
||||
IEntityLocker entityLocker)
|
||||
: base(traktApiClient, searchRepository, searchIndex, logger)
|
||||
{
|
||||
_searchRepository = searchRepository;
|
||||
_searchIndex = searchIndex;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_entityLocker = entityLocker;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Unit>> Handle(
|
||||
DeleteTraktList request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
|
||||
Validation<BaseError, TraktList> validation = await TraktListMustExist(dbContext, request.TraktListId);
|
||||
return await validation.Apply(c => DoDeletion(dbContext, c));
|
||||
}
|
||||
finally
|
||||
{
|
||||
_entityLocker.UnlockTrakt();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Unit> DoDeletion(TvContext dbContext, TraktList traktList)
|
||||
{
|
||||
var mediaItemIds = traktList.Items.Bind(i => Optional(i.MediaItemId)).ToList();
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_searchIndex.Commit();
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using ErsatzTV.Core;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Unit = LanguageExt.Unit;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections.Commands
|
||||
{
|
||||
public record MatchTraktListItems(int TraktListId, bool Unlock = true) : IRequest<Either<BaseError, Unit>>,
|
||||
IBackgroundServiceRequest;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Locking;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Interfaces.Trakt;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Unit = LanguageExt.Unit;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections.Commands
|
||||
{
|
||||
public class MatchTraktListItemsHandler : TraktCommandBase,
|
||||
IRequestHandler<MatchTraktListItems, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly IEntityLocker _entityLocker;
|
||||
|
||||
public MatchTraktListItemsHandler(
|
||||
ITraktApiClient traktApiClient,
|
||||
ISearchRepository searchRepository,
|
||||
ISearchIndex searchIndex,
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ILogger<MatchTraktListItemsHandler> logger,
|
||||
IEntityLocker entityLocker) : base(traktApiClient, searchRepository, searchIndex, logger)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_entityLocker = entityLocker;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Unit>> Handle(
|
||||
MatchTraktListItems request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
|
||||
Validation<BaseError, TraktList> validation = await TraktListMustExist(dbContext, request.TraktListId);
|
||||
return await validation.Match(
|
||||
async l => await MatchListItems(dbContext, l).MapT(_ => Unit.Default),
|
||||
error => Task.FromResult<Either<BaseError, Unit>>(error.Join()));
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (request.Unlock)
|
||||
{
|
||||
_entityLocker.UnlockTrakt();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Interfaces.Trakt;
|
||||
using ErsatzTV.Core.Trakt;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using LanguageExt;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections.Commands
|
||||
{
|
||||
public abstract class TraktCommandBase
|
||||
{
|
||||
private readonly ISearchRepository _searchRepository;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
protected TraktCommandBase(
|
||||
ITraktApiClient traktApiClient,
|
||||
ISearchRepository searchRepository,
|
||||
ISearchIndex searchIndex,
|
||||
ILogger logger)
|
||||
{
|
||||
_searchRepository = searchRepository;
|
||||
_searchIndex = searchIndex;
|
||||
_logger = logger;
|
||||
TraktApiClient = traktApiClient;
|
||||
}
|
||||
|
||||
protected ITraktApiClient TraktApiClient { get; }
|
||||
|
||||
protected static Task<Validation<BaseError, TraktList>>
|
||||
TraktListMustExist(TvContext dbContext, int traktListId) =>
|
||||
dbContext.TraktLists
|
||||
.Include(l => l.Items)
|
||||
.ThenInclude(i => i.Guids)
|
||||
.SelectOneAsync(c => c.Id, c => c.Id == traktListId)
|
||||
.Map(o => o.ToValidation<BaseError>($"TraktList {traktListId} does not exist."));
|
||||
|
||||
protected async Task<Either<BaseError, TraktList>> SaveList(TvContext dbContext, TraktList list)
|
||||
{
|
||||
Option<TraktList> maybeExisting = await dbContext.TraktLists
|
||||
.Include(l => l.Items)
|
||||
.ThenInclude(i => i.Guids)
|
||||
.SelectOneAsync(tl => tl.Id, tl => tl.User == list.User && tl.List == list.List);
|
||||
|
||||
return await maybeExisting.Match(
|
||||
async existing =>
|
||||
{
|
||||
existing.Name = list.Name;
|
||||
existing.Description = list.Description;
|
||||
existing.ItemCount = list.ItemCount;
|
||||
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
return existing;
|
||||
},
|
||||
async () =>
|
||||
{
|
||||
await dbContext.TraktLists.AddAsync(list);
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
return list;
|
||||
});
|
||||
}
|
||||
|
||||
protected async Task<Either<BaseError, TraktList>> SaveListItems(TvContext dbContext, TraktList list)
|
||||
{
|
||||
Either<BaseError, List<TraktListItemWithGuids>> maybeItems =
|
||||
await TraktApiClient.GetUserListItems(list.User, list.List);
|
||||
|
||||
return await maybeItems.Match<Task<Either<BaseError, TraktList>>>(
|
||||
async items =>
|
||||
{
|
||||
var toAdd = items.Filter(i => list.Items.All(i2 => i2.TraktId != i.TraktId)).ToList();
|
||||
var toRemove = list.Items.Filter(i => items.All(i2 => i2.TraktId != i.TraktId)).ToList();
|
||||
var toUpdate = list.Items.Filter(i => !toRemove.Contains(i)).ToList();
|
||||
|
||||
list.Items.RemoveAll(toRemove.Contains);
|
||||
list.Items.AddRange(toAdd.Map(a => ProjectItem(list, a)));
|
||||
|
||||
foreach (TraktListItem existing in toUpdate)
|
||||
{
|
||||
Option<TraktListItem> maybeIncoming = list.Items.Find(i => i.TraktId == existing.TraktId);
|
||||
foreach (TraktListItem incoming in maybeIncoming)
|
||||
{
|
||||
existing.Kind = incoming.Kind;
|
||||
existing.Rank = incoming.Rank;
|
||||
existing.Title = incoming.Title;
|
||||
existing.Year = incoming.Year;
|
||||
existing.Season = incoming.Season;
|
||||
existing.Episode = incoming.Episode;
|
||||
existing.Guids.Clear();
|
||||
existing.Guids.AddRange(incoming.Guids);
|
||||
existing.MediaItemId = null;
|
||||
existing.MediaItem = null;
|
||||
}
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
return list;
|
||||
},
|
||||
error => Task.FromResult(Left<BaseError, TraktList>(error)));
|
||||
}
|
||||
|
||||
protected async Task<Either<BaseError, TraktList>> MatchListItems(TvContext dbContext, TraktList list)
|
||||
{
|
||||
try
|
||||
{
|
||||
var ids = new System.Collections.Generic.HashSet<int>();
|
||||
|
||||
foreach (TraktListItem item in list.Items
|
||||
.OrderBy(i => i.Title).ThenBy(i => i.Year).ThenBy(i => i.Season).ThenBy(i => i.Episode))
|
||||
{
|
||||
switch (item.Kind)
|
||||
{
|
||||
case TraktListItemKind.Movie:
|
||||
Option<int> maybeMovieId = await IdentifyMovie(dbContext, item);
|
||||
foreach (int movieId in maybeMovieId)
|
||||
{
|
||||
ids.Add(movieId);
|
||||
item.MediaItemId = movieId;
|
||||
}
|
||||
|
||||
break;
|
||||
case TraktListItemKind.Show:
|
||||
Option<int> maybeShowId = await IdentifyShow(dbContext, item);
|
||||
foreach (int showId in maybeShowId)
|
||||
{
|
||||
ids.Add(showId);
|
||||
item.MediaItemId = showId;
|
||||
}
|
||||
|
||||
break;
|
||||
case TraktListItemKind.Season:
|
||||
Option<int> maybeSeasonId = await IdentifySeason(dbContext, item);
|
||||
foreach (int seasonId in maybeSeasonId)
|
||||
{
|
||||
ids.Add(seasonId);
|
||||
item.MediaItemId = seasonId;
|
||||
}
|
||||
|
||||
break;
|
||||
default:
|
||||
Option<int> maybeEpisodeId = await IdentifyEpisode(dbContext, item);
|
||||
foreach (int episodeId in maybeEpisodeId)
|
||||
{
|
||||
ids.Add(episodeId);
|
||||
item.MediaItemId = episodeId;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
foreach (int mediaItemId in ids)
|
||||
{
|
||||
Option<MediaItem> maybeItem = await _searchRepository.GetItemToIndex(mediaItemId);
|
||||
foreach (MediaItem item in maybeItem)
|
||||
{
|
||||
await _searchIndex.UpdateItems(_searchRepository, new[] { item }.ToList());
|
||||
}
|
||||
}
|
||||
|
||||
_searchIndex.Commit();
|
||||
|
||||
return list;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error matching trakt list items");
|
||||
return BaseError.New(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static TraktListItem ProjectItem(TraktList list, TraktListItemWithGuids item)
|
||||
{
|
||||
var result = new TraktListItem
|
||||
{
|
||||
TraktList = list,
|
||||
Kind = item.Kind,
|
||||
TraktId = item.TraktId,
|
||||
Rank = item.Rank,
|
||||
Title = item.Title,
|
||||
Year = item.Year,
|
||||
Season = item.Season,
|
||||
Episode = item.Episode,
|
||||
};
|
||||
|
||||
result.Guids = item.Guids.Map(g => new TraktListItemGuid { Guid = g, TraktListItem = result }).ToList();
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task<Option<int>> IdentifyMovie(TvContext dbContext, TraktListItem item)
|
||||
{
|
||||
var guids = item.Guids.Map(g => g.Guid).ToList();
|
||||
|
||||
Option<int> maybeMovieByGuid = await dbContext.MovieMetadata
|
||||
.Filter(mm => mm.Guids.Any(g => guids.Contains(g.Guid)))
|
||||
.FirstOrDefaultAsync()
|
||||
.Map(Optional)
|
||||
.MapT(mm => mm.MovieId);
|
||||
|
||||
foreach (int movieId in maybeMovieByGuid)
|
||||
{
|
||||
_logger.LogDebug("Located trakt movie {Title} by id", item.DisplayTitle);
|
||||
return movieId;
|
||||
}
|
||||
|
||||
Option<int> maybeMovieByTitleYear = await dbContext.MovieMetadata
|
||||
.Filter(mm => mm.Title == item.Title && mm.Year == item.Year)
|
||||
.FirstOrDefaultAsync()
|
||||
.Map(Optional)
|
||||
.MapT(mm => mm.MovieId);
|
||||
|
||||
foreach (int movieId in maybeMovieByTitleYear)
|
||||
{
|
||||
_logger.LogDebug("Located trakt movie {Title} by title/year", item.DisplayTitle);
|
||||
return movieId;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Unable to locate trakt movie {Title}", item.DisplayTitle);
|
||||
|
||||
return None;
|
||||
}
|
||||
|
||||
private async Task<Option<int>> IdentifyShow(TvContext dbContext, TraktListItem item)
|
||||
{
|
||||
var guids = item.Guids.Map(g => g.Guid).ToList();
|
||||
|
||||
Option<int> maybeShowByGuid = await dbContext.ShowMetadata
|
||||
.Filter(sm => sm.Guids.Any(g => guids.Contains(g.Guid)))
|
||||
.FirstOrDefaultAsync()
|
||||
.Map(Optional)
|
||||
.MapT(sm => sm.ShowId);
|
||||
|
||||
foreach (int showId in maybeShowByGuid)
|
||||
{
|
||||
_logger.LogDebug("Located trakt show {Title} by id", item.DisplayTitle);
|
||||
return showId;
|
||||
}
|
||||
|
||||
Option<int> maybeShowByTitleYear = await dbContext.ShowMetadata
|
||||
.Filter(sm => sm.Title == item.Title && sm.Year == item.Year)
|
||||
.FirstOrDefaultAsync()
|
||||
.Map(Optional)
|
||||
.MapT(sm => sm.ShowId);
|
||||
|
||||
foreach (int showId in maybeShowByTitleYear)
|
||||
{
|
||||
_logger.LogDebug("Located trakt show {Title} by title/year", item.Title);
|
||||
return showId;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Unable to locate trakt show {Title}", item.DisplayTitle);
|
||||
|
||||
return None;
|
||||
}
|
||||
|
||||
private async Task<Option<int>> IdentifySeason(TvContext dbContext, TraktListItem item)
|
||||
{
|
||||
var guids = item.Guids.Map(g => g.Guid).ToList();
|
||||
|
||||
Option<int> maybeSeasonByGuid = await dbContext.SeasonMetadata
|
||||
.Filter(sm => sm.Guids.Any(g => guids.Contains(g.Guid)))
|
||||
.FirstOrDefaultAsync()
|
||||
.Map(Optional)
|
||||
.MapT(sm => sm.SeasonId);
|
||||
|
||||
foreach (int seasonId in maybeSeasonByGuid)
|
||||
{
|
||||
_logger.LogDebug("Located trakt season {Title} by id", item.DisplayTitle);
|
||||
return seasonId;
|
||||
}
|
||||
|
||||
Option<int> maybeSeasonByTitleYear = await dbContext.SeasonMetadata
|
||||
.Filter(sm => sm.Season.Show.ShowMetadata.Any(s => s.Title == item.Title && s.Year == item.Year))
|
||||
.Filter(sm => sm.Season.SeasonNumber == item.Season)
|
||||
.FirstOrDefaultAsync()
|
||||
.Map(Optional)
|
||||
.MapT(sm => sm.SeasonId);
|
||||
|
||||
foreach (int seasonId in maybeSeasonByTitleYear)
|
||||
{
|
||||
_logger.LogDebug("Located trakt season {Title} by title/year/season", item.DisplayTitle);
|
||||
return seasonId;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Unable to locate trakt season {Title}", item.DisplayTitle);
|
||||
|
||||
return None;
|
||||
}
|
||||
|
||||
private async Task<Option<int>> IdentifyEpisode(TvContext dbContext, TraktListItem item)
|
||||
{
|
||||
var guids = item.Guids.Map(g => g.Guid).ToList();
|
||||
|
||||
Option<int> maybeEpisodeByGuid = await dbContext.EpisodeMetadata
|
||||
.Filter(em => em.Guids.Any(g => guids.Contains(g.Guid)))
|
||||
.FirstOrDefaultAsync()
|
||||
.Map(Optional)
|
||||
.MapT(sm => sm.EpisodeId);
|
||||
|
||||
foreach (int episodeId in maybeEpisodeByGuid)
|
||||
{
|
||||
_logger.LogDebug("Located trakt episode {Title} by id", item.DisplayTitle);
|
||||
return episodeId;
|
||||
}
|
||||
|
||||
Option<int> maybeEpisodeByTitleYear = await dbContext.EpisodeMetadata
|
||||
.Filter(sm => sm.Episode.Season.Show.ShowMetadata.Any(s => s.Title == item.Title && s.Year == item.Year))
|
||||
.Filter(em => em.Episode.Season.SeasonNumber == item.Season)
|
||||
.Filter(sm => sm.Episode.EpisodeMetadata.Any(e => e.EpisodeNumber == item.Episode))
|
||||
.FirstOrDefaultAsync()
|
||||
.Map(Optional)
|
||||
.MapT(sm => sm.EpisodeId);
|
||||
|
||||
foreach (int episodeId in maybeEpisodeByTitleYear)
|
||||
{
|
||||
_logger.LogDebug("Located trakt episode {Title} by title/year/season/episode", item.DisplayTitle);
|
||||
return episodeId;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Unable to locate trakt episode {Title}", item.DisplayTitle);
|
||||
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,15 @@ namespace ErsatzTV.Application.MediaCollections
|
||||
internal static SmartCollectionViewModel ProjectToViewModel(SmartCollection collection) =>
|
||||
new(collection.Id, collection.Name, collection.Query);
|
||||
|
||||
internal static TraktListViewModel ProjectToViewModel(TraktList traktList) =>
|
||||
new(
|
||||
traktList.Id,
|
||||
traktList.TraktId,
|
||||
$"{traktList.User}/{traktList.List}",
|
||||
traktList.Name,
|
||||
traktList.ItemCount,
|
||||
traktList.Items.Count(i => i.MediaItemId.HasValue));
|
||||
|
||||
private static MultiCollectionItemViewModel ProjectToViewModel(MultiCollectionItem multiCollectionItem) =>
|
||||
new(
|
||||
multiCollectionItem.MultiCollectionId,
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections
|
||||
{
|
||||
public record PagedTraktListsViewModel(int TotalCount, List<TraktListViewModel> Page);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections.Queries
|
||||
{
|
||||
public record GetPagedTraktLists(int PageNum, int PageSize) : IRequest<PagedTraktListsViewModel>;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Data;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Dapper;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using static ErsatzTV.Application.MediaCollections.Mapper;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections.Queries
|
||||
{
|
||||
public class GetPagedTraktListsHandler : IRequestHandler<GetPagedTraktLists, PagedTraktListsViewModel>
|
||||
{
|
||||
private readonly IDbConnection _dbConnection;
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
|
||||
public GetPagedTraktListsHandler(IDbContextFactory<TvContext> dbContextFactory, IDbConnection dbConnection)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_dbConnection = dbConnection;
|
||||
}
|
||||
|
||||
public async Task<PagedTraktListsViewModel> Handle(
|
||||
GetPagedTraktLists request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
int count = await _dbConnection.QuerySingleAsync<int>(@"SELECT COUNT (*) FROM TraktList");
|
||||
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
List<TraktListViewModel> page = await dbContext.TraktLists.FromSqlRaw(
|
||||
@"SELECT * FROM TraktList
|
||||
ORDER BY Name
|
||||
COLLATE NOCASE
|
||||
LIMIT {0} OFFSET {1}",
|
||||
request.PageSize,
|
||||
request.PageNum * request.PageSize)
|
||||
.Include(l => l.Items)
|
||||
.ToListAsync(cancellationToken)
|
||||
.Map(list => list.Map(ProjectToViewModel).ToList());
|
||||
|
||||
return new PagedTraktListsViewModel(count, page);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
namespace ErsatzTV.Application.MediaCollections
|
||||
{
|
||||
public record TraktListViewModel(int Id, int TraktId, string Slug, string Name, int ItemCount, int MatchCount);
|
||||
}
|
||||
@@ -78,7 +78,7 @@ namespace ErsatzTV.Application.MediaSources.Commands
|
||||
decimal progressMin = (decimal) i / localLibrary.Paths.Count;
|
||||
decimal progressMax = (decimal) (i + 1) / localLibrary.Paths.Count;
|
||||
|
||||
var lastScan = new DateTimeOffset(libraryPath.LastScan ?? DateTime.MinValue, TimeSpan.Zero);
|
||||
var lastScan = new DateTimeOffset(libraryPath.LastScan ?? SystemTime.MinValueUtc, TimeSpan.Zero);
|
||||
DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(libraryRefreshInterval);
|
||||
if (forceScan || nextScan < DateTimeOffset.Now)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
using System;
|
||||
using ErsatzTV.Core;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.Playouts.Commands
|
||||
{
|
||||
public record UpdatePlayout
|
||||
(int PlayoutId, Option<TimeSpan> DailyRebuildTime) : IRequest<Either<BaseError, PlayoutNameViewModel>>;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Application.Playouts.Commands
|
||||
{
|
||||
public class UpdatePlayoutHandler : IRequestHandler<UpdatePlayout, Either<BaseError, PlayoutNameViewModel>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
|
||||
public UpdatePlayoutHandler(IDbContextFactory<TvContext> dbContextFactory) =>
|
||||
_dbContextFactory = dbContextFactory;
|
||||
|
||||
public async Task<Either<BaseError, PlayoutNameViewModel>> Handle(
|
||||
UpdatePlayout request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
Validation<BaseError, Playout> validation = await Validate(dbContext, request);
|
||||
return await validation.Apply(playout => ApplyUpdateRequest(dbContext, request, playout));
|
||||
}
|
||||
|
||||
private static async Task<PlayoutNameViewModel> ApplyUpdateRequest(
|
||||
TvContext dbContext,
|
||||
UpdatePlayout request,
|
||||
Playout playout)
|
||||
{
|
||||
playout.DailyRebuildTime = null;
|
||||
|
||||
foreach (TimeSpan dailyRebuildTime in request.DailyRebuildTime)
|
||||
{
|
||||
playout.DailyRebuildTime = dailyRebuildTime;
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
return new PlayoutNameViewModel(
|
||||
playout.Id,
|
||||
playout.Channel.Name,
|
||||
playout.Channel.Number,
|
||||
playout.ProgramSchedule.Name,
|
||||
Optional(playout.DailyRebuildTime));
|
||||
}
|
||||
|
||||
private static Task<Validation<BaseError, Playout>> Validate(TvContext dbContext, UpdatePlayout request) =>
|
||||
PlayoutMustExist(dbContext, request);
|
||||
|
||||
private static Task<Validation<BaseError, Playout>> PlayoutMustExist(
|
||||
TvContext dbContext,
|
||||
UpdatePlayout updatePlayout) =>
|
||||
dbContext.Playouts
|
||||
.Include(p => p.Channel)
|
||||
.Include(p => p.ProgramSchedule)
|
||||
.SelectOneAsync(p => p.Id, p => p.Id == updatePlayout.PlayoutId)
|
||||
.Map(o => o.ToValidation<BaseError>("Playout does not exist."));
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
namespace ErsatzTV.Application.Playouts
|
||||
using System;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Application.Playouts
|
||||
{
|
||||
public record PlayoutNameViewModel(
|
||||
int PlayoutId,
|
||||
string ChannelName,
|
||||
string ChannelNumber,
|
||||
string ScheduleName);
|
||||
string ScheduleName,
|
||||
Option<TimeSpan> DailyRebuildTime);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ using System.Threading.Tasks;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Application.Playouts.Queries
|
||||
{
|
||||
@@ -21,7 +22,13 @@ namespace ErsatzTV.Application.Playouts.Queries
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
return await dbContext.Playouts
|
||||
.Filter(p => p.Channel != null && p.ProgramSchedule != null)
|
||||
.Map(p => new PlayoutNameViewModel(p.Id, p.Channel.Name, p.Channel.Number, p.ProgramSchedule.Name))
|
||||
.Map(
|
||||
p => new PlayoutNameViewModel(
|
||||
p.Id,
|
||||
p.Channel.Name,
|
||||
p.Channel.Number,
|
||||
p.ProgramSchedule.Name,
|
||||
Optional(p.DailyRebuildTime)))
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ namespace ErsatzTV.Application.Plex.Commands
|
||||
|
||||
private async Task<Unit> Synchronize(RequestParameters parameters)
|
||||
{
|
||||
var lastScan = new DateTimeOffset(parameters.Library.LastScan ?? DateTime.MinValue, TimeSpan.Zero);
|
||||
var lastScan = new DateTimeOffset(parameters.Library.LastScan ?? SystemTime.MinValueUtc, TimeSpan.Zero);
|
||||
DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(parameters.LibraryRefreshInterval);
|
||||
if (parameters.ForceScan || nextScan < DateTimeOffset.Now)
|
||||
{
|
||||
|
||||
@@ -21,6 +21,7 @@ namespace ErsatzTV.Application.Search.Queries
|
||||
new(
|
||||
await GetIds("movie", request.Query),
|
||||
await GetIds("show", request.Query),
|
||||
await GetIds("season", request.Query),
|
||||
await GetIds("episode", request.Query),
|
||||
await GetIds("artist", request.Query),
|
||||
await GetIds("music_video", request.Query));
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
using ErsatzTV.Application.MediaCards;
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.Search.Queries
|
||||
{
|
||||
public record QuerySearchIndexSeasons
|
||||
(string Query, int PageNumber, int PageSize) : IRequest<TelevisionSeasonCardResultsViewModel>;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Application.MediaCards;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Search;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using static ErsatzTV.Application.MediaCards.Mapper;
|
||||
|
||||
namespace ErsatzTV.Application.Search.Queries
|
||||
{
|
||||
public class
|
||||
QuerySearchIndexSeasonsHandler : IRequestHandler<QuerySearchIndexSeasons, TelevisionSeasonCardResultsViewModel>
|
||||
{
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
private readonly ITelevisionRepository _televisionRepository;
|
||||
|
||||
public QuerySearchIndexSeasonsHandler(
|
||||
ISearchIndex searchIndex,
|
||||
ITelevisionRepository televisionRepository,
|
||||
IMediaSourceRepository mediaSourceRepository)
|
||||
{
|
||||
_searchIndex = searchIndex;
|
||||
_televisionRepository = televisionRepository;
|
||||
_mediaSourceRepository = mediaSourceRepository;
|
||||
}
|
||||
|
||||
public async Task<TelevisionSeasonCardResultsViewModel> Handle(
|
||||
QuerySearchIndexSeasons request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
SearchResult searchResult = await _searchIndex.Search(
|
||||
request.Query,
|
||||
(request.PageNumber - 1) * request.PageSize,
|
||||
request.PageSize);
|
||||
|
||||
Option<JellyfinMediaSource> maybeJellyfin = await _mediaSourceRepository.GetAllJellyfin()
|
||||
.Map(list => list.HeadOrNone());
|
||||
|
||||
Option<EmbyMediaSource> maybeEmby = await _mediaSourceRepository.GetAllEmby()
|
||||
.Map(list => list.HeadOrNone());
|
||||
|
||||
List<TelevisionSeasonCardViewModel> items = await _televisionRepository
|
||||
.GetSeasonsForCards(searchResult.Items.Map(i => i.Id).ToList())
|
||||
.Map(list => list.Map(s => ProjectToViewModel(s, maybeJellyfin, maybeEmby)).ToList());
|
||||
|
||||
return new TelevisionSeasonCardResultsViewModel(searchResult.TotalCount, items, searchResult.PageMap);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ namespace ErsatzTV.Application.Search
|
||||
public record SearchResultAllItemsViewModel(
|
||||
List<int> MovieIds,
|
||||
List<int> ShowIds,
|
||||
List<int> SeasonIds,
|
||||
List<int> EpisodeIds,
|
||||
List<int> ArtistIds,
|
||||
List<int> MusicVideoIds);
|
||||
|
||||
+6
-1
@@ -99,6 +99,10 @@ namespace ErsatzTV.Application.Streaming.Queries
|
||||
watermarkId => dbContext.ChannelWatermarks
|
||||
.SelectOneAsync(w => w.Id, w => w.Id == watermarkId));
|
||||
|
||||
Option<VaapiDriver> maybeVaapiDriver = await dbContext.ConfigElements
|
||||
.GetValue<int>(ConfigElementKey.FFmpegVaapiDriver)
|
||||
.MapT(i => (VaapiDriver)i);
|
||||
|
||||
return Right<BaseError, Process>(
|
||||
await _ffmpegProcessService.ForPlayoutItem(
|
||||
ffmpegPath,
|
||||
@@ -108,7 +112,8 @@ namespace ErsatzTV.Application.Streaming.Queries
|
||||
playoutItemWithPath.Path,
|
||||
playoutItemWithPath.PlayoutItem.StartOffset,
|
||||
now,
|
||||
maybeGlobalWatermark));
|
||||
maybeGlobalWatermark,
|
||||
maybeVaapiDriver));
|
||||
},
|
||||
async error =>
|
||||
{
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<PackageReference Include="LanguageExt.Core" Version="3.4.15" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="5.0.2" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.11.0" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="16.10.56">
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.0.63">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
|
||||
@@ -4,6 +4,7 @@ using ErsatzTV.Core.FFmpeg;
|
||||
using FluentAssertions;
|
||||
using LanguageExt;
|
||||
using NUnit.Framework;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
{
|
||||
@@ -113,6 +114,161 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
[TestCase(
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
ChannelWatermarkLocation.BottomLeft,
|
||||
false,
|
||||
100,
|
||||
"[0:0][1:v]overlay=x=134:y=H-h-54[v]",
|
||||
"0:1",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
ChannelWatermarkLocation.BottomRight,
|
||||
false,
|
||||
100,
|
||||
"[0:0][1:v]overlay=x=W-w-134:y=H-h-54[v]",
|
||||
"0:1",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
ChannelWatermarkLocation.TopLeft,
|
||||
false,
|
||||
100,
|
||||
"[0:0][1:v]overlay=x=134:y=54[v]",
|
||||
"0:1",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
ChannelWatermarkLocation.TopRight,
|
||||
false,
|
||||
100,
|
||||
"[0:0][1:v]overlay=x=W-w-134:y=54[v]",
|
||||
"0:1",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
ChannelWatermarkLocation.TopLeft,
|
||||
false,
|
||||
100,
|
||||
"[0:0][1:v]overlay=x=134:y=54:enable='lt(mod(mod(time(0),60*60),10*60),15)'[v]",
|
||||
"0:1",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
ChannelWatermarkLocation.TopLeft,
|
||||
true,
|
||||
100,
|
||||
"[1:v]scale=384:-1[wmp];[0:0][wmp]overlay=x=134:y=54[v]",
|
||||
"0:1",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
ChannelWatermarkLocation.TopLeft,
|
||||
false,
|
||||
90,
|
||||
"[1:v]format=yuva420p|yuva444p|yuva422p|rgba|abgr|bgra|gbrap|ya8,colorchannelmixer=aa=0.90[wmp];[0:0][wmp]overlay=x=134:y=54[v]",
|
||||
"0:1",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
ChannelWatermarkLocation.TopLeft,
|
||||
false,
|
||||
100,
|
||||
"[0:0]yadif=1[vt];[vt][1:v]overlay=x=134:y=54[v]",
|
||||
"0:1",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
ChannelWatermarkLocation.TopLeft,
|
||||
true,
|
||||
100,
|
||||
"[0:0]yadif=1[vt];[1:v]scale=384:-1[wmp];[vt][wmp]overlay=x=134:y=54[v]",
|
||||
"0:1",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
ChannelWatermarkLocation.TopLeft,
|
||||
false,
|
||||
100,
|
||||
"[0:1]apad=whole_dur=3300000ms[a];[0:0]yadif=1[vt];[vt][1:v]overlay=x=134:y=54[v]",
|
||||
"[a]",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
ChannelWatermarkLocation.TopLeft,
|
||||
false,
|
||||
100,
|
||||
"[0:1]apad=whole_dur=3300000ms[a];[0:0][1:v]overlay=x=134:y=54[v]",
|
||||
"[a]",
|
||||
"[v]")]
|
||||
public void Should_Return_Watermark(
|
||||
bool alignAudio,
|
||||
bool deinterlace,
|
||||
bool intermittent,
|
||||
ChannelWatermarkLocation location,
|
||||
bool scaled,
|
||||
int opacity,
|
||||
string expectedVideoFilter,
|
||||
string expectedAudioLabel,
|
||||
string expectedVideoLabel)
|
||||
{
|
||||
FFmpegComplexFilterBuilder builder = new FFmpegComplexFilterBuilder()
|
||||
.WithWatermark(
|
||||
Some(
|
||||
new ChannelWatermark
|
||||
{
|
||||
Mode = intermittent
|
||||
? ChannelWatermarkMode.Intermittent
|
||||
: ChannelWatermarkMode.Permanent,
|
||||
DurationSeconds = intermittent ? 15 : 0,
|
||||
FrequencyMinutes = intermittent ? 10 : 0,
|
||||
Location = location,
|
||||
Size = scaled ? ChannelWatermarkSize.Scaled : ChannelWatermarkSize.ActualSize,
|
||||
WidthPercent = scaled ? 20 : 0,
|
||||
Opacity = opacity,
|
||||
HorizontalMarginPercent = 7,
|
||||
VerticalMarginPercent = 5
|
||||
}),
|
||||
new Resolution { Width = 1920, Height = 1080 })
|
||||
.WithDeinterlace(deinterlace)
|
||||
.WithAlignedAudio(alignAudio ? Some(TimeSpan.FromMinutes(55)) : None);
|
||||
|
||||
Option<FFmpegComplexFilter> result = builder.Build(0, 1);
|
||||
|
||||
result.IsSome.Should().BeTrue();
|
||||
result.IfSome(
|
||||
filter =>
|
||||
{
|
||||
filter.ComplexFilter.Should().Be(expectedVideoFilter);
|
||||
filter.AudioLabel.Should().Be(expectedAudioLabel);
|
||||
filter.VideoLabel.Should().Be(expectedVideoLabel);
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
[TestCase(true, false, false, "[0:0]deinterlace_qsv[v]", "[v]")]
|
||||
[TestCase(
|
||||
@@ -281,7 +437,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
"[0:0]deinterlace_vaapi,scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,hwupload[v]",
|
||||
"[0:0]deinterlace_vaapi,scale_vaapi=format=nv12:w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,hwupload[v]",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
"h264",
|
||||
@@ -295,14 +451,14 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
"[0:0]deinterlace_vaapi,scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
||||
"[0:0]deinterlace_vaapi,scale_vaapi=format=nv12:w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
"h264",
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
"[0:0]scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,hwupload[v]",
|
||||
"[0:0]scale_vaapi=format=nv12:w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,hwupload[v]",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
"h264",
|
||||
@@ -316,7 +472,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
"[0:0]scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
||||
"[0:0]scale_vaapi=format=nv12:w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
||||
"[v]")]
|
||||
[TestCase("mpeg4", true, false, false, "[0:0]hwupload,deinterlace_vaapi[v]", "[v]")]
|
||||
[TestCase(
|
||||
@@ -324,7 +480,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
"[0:0]hwupload,deinterlace_vaapi,scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,hwupload[v]",
|
||||
"[0:0]hwupload,deinterlace_vaapi,scale_vaapi=format=nv12:w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,hwupload[v]",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
"mpeg4",
|
||||
@@ -338,14 +494,14 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
"[0:0]hwupload,deinterlace_vaapi,scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
||||
"[0:0]hwupload,deinterlace_vaapi,scale_vaapi=format=nv12:w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
"mpeg4",
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
"[0:0]hwupload,scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,hwupload[v]",
|
||||
"[0:0]hwupload,scale_vaapi=format=nv12:w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,hwupload[v]",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
"mpeg4",
|
||||
@@ -359,7 +515,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
"[0:0]hwupload,scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
||||
"[0:0]hwupload,scale_vaapi=format=nv12:w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
||||
"[v]")]
|
||||
public void Should_Return_VAAPI_Video_Filter(
|
||||
string codec,
|
||||
|
||||
@@ -258,6 +258,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
{
|
||||
FFmpegProfile ffmpegProfile = TestProfile() with
|
||||
{
|
||||
Transcode = true,
|
||||
NormalizeVideo = true,
|
||||
Resolution = new Resolution { Width = 1920, Height = 1080 }
|
||||
};
|
||||
@@ -283,6 +284,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
{
|
||||
FFmpegProfile ffmpegProfile = TestProfile() with
|
||||
{
|
||||
Transcode = true,
|
||||
NormalizeVideo = true,
|
||||
Resolution = new Resolution { Width = 1280, Height = 720 }
|
||||
};
|
||||
@@ -309,6 +311,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
{
|
||||
FFmpegProfile ffmpegProfile = TestProfile() with
|
||||
{
|
||||
Transcode = true,
|
||||
NormalizeVideo = true,
|
||||
Resolution = new Resolution { Width = 1920, Height = 1080 }
|
||||
};
|
||||
@@ -334,6 +337,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
{
|
||||
FFmpegProfile ffmpegProfile = TestProfile() with
|
||||
{
|
||||
Transcode = true,
|
||||
NormalizeVideo = false,
|
||||
Resolution = new Resolution { Width = 1920, Height = 1080 }
|
||||
};
|
||||
@@ -359,6 +363,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
{
|
||||
var ffmpegProfile = new FFmpegProfile
|
||||
{
|
||||
Transcode = true,
|
||||
NormalizeVideo = true,
|
||||
Resolution = new Resolution { Width = 1920, Height = 1080 },
|
||||
VideoCodec = "testCodec"
|
||||
@@ -387,6 +392,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
{
|
||||
var ffmpegProfile = new FFmpegProfile
|
||||
{
|
||||
Transcode = true,
|
||||
NormalizeVideo = true,
|
||||
Resolution = new Resolution { Width = 1920, Height = 1080 },
|
||||
VideoCodec = "testCodec"
|
||||
@@ -416,6 +422,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
{
|
||||
var ffmpegProfile = new FFmpegProfile
|
||||
{
|
||||
Transcode = true,
|
||||
NormalizeVideo = true,
|
||||
Resolution = new Resolution { Width = 1920, Height = 1080 },
|
||||
VideoCodec = "testCodec"
|
||||
@@ -444,6 +451,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
{
|
||||
var ffmpegProfile = new FFmpegProfile
|
||||
{
|
||||
Transcode = true,
|
||||
NormalizeVideo = true,
|
||||
Resolution = new Resolution { Width = 1920, Height = 1080 },
|
||||
VideoCodec = "libx264"
|
||||
@@ -473,6 +481,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
{
|
||||
var ffmpegProfile = new FFmpegProfile
|
||||
{
|
||||
Transcode = true,
|
||||
NormalizeVideo = false,
|
||||
Resolution = new Resolution { Width = 1920, Height = 1080 },
|
||||
VideoCodec = "libx264"
|
||||
@@ -495,12 +504,47 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
actual.PadToDesiredResolution.Should().BeFalse();
|
||||
actual.VideoCodec.Should().Be("copy");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void
|
||||
Should_SetCopyVideoCodec_AndCopyAudioCodec_When_NotTranscoding_ForTransportStream()
|
||||
{
|
||||
var ffmpegProfile = new FFmpegProfile
|
||||
{
|
||||
Transcode = false,
|
||||
NormalizeVideo = true,
|
||||
NormalizeAudio = true,
|
||||
NormalizeLoudness = true,
|
||||
Resolution = new Resolution { Width = 1920, Height = 1080 },
|
||||
VideoCodec = "libx264"
|
||||
};
|
||||
|
||||
// not anamorphic
|
||||
var version = new MediaVersion
|
||||
{ Width = 1920, Height = 1080, SampleAspectRatio = "1:1" };
|
||||
|
||||
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
|
||||
StreamingMode.TransportStream,
|
||||
ffmpegProfile,
|
||||
version,
|
||||
new MediaStream { Codec = "mpeg2video" },
|
||||
new MediaStream(),
|
||||
DateTimeOffset.Now,
|
||||
DateTimeOffset.Now);
|
||||
|
||||
actual.ScaledSize.IsNone.Should().BeTrue();
|
||||
actual.PadToDesiredResolution.Should().BeFalse();
|
||||
actual.VideoCodec.Should().Be("copy");
|
||||
actual.NormalizeLoudness.Should().BeFalse();
|
||||
actual.AudioCodec.Should().Be("copy");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_SetVideoBitrate_When_ContentIsPadded_ForTransportStream()
|
||||
{
|
||||
var ffmpegProfile = new FFmpegProfile
|
||||
{
|
||||
Transcode = true,
|
||||
NormalizeVideo = true,
|
||||
Resolution = new Resolution { Width = 1920, Height = 1080 },
|
||||
VideoBitrate = 2525
|
||||
@@ -528,6 +572,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
{
|
||||
var ffmpegProfile = new FFmpegProfile
|
||||
{
|
||||
Transcode = true,
|
||||
NormalizeVideo = true,
|
||||
Resolution = new Resolution { Width = 1920, Height = 1080 },
|
||||
VideoBitrate = 2525
|
||||
@@ -556,6 +601,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
{
|
||||
var ffmpegProfile = new FFmpegProfile
|
||||
{
|
||||
Transcode = true,
|
||||
NormalizeVideo = true,
|
||||
Resolution = new Resolution { Width = 1920, Height = 1080 },
|
||||
VideoBufferSize = 2525
|
||||
@@ -584,6 +630,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
{
|
||||
var ffmpegProfile = new FFmpegProfile
|
||||
{
|
||||
Transcode = true,
|
||||
NormalizeVideo = true,
|
||||
Resolution = new Resolution { Width = 1920, Height = 1080 },
|
||||
VideoBufferSize = 2525
|
||||
@@ -612,6 +659,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
{
|
||||
FFmpegProfile ffmpegProfile = TestProfile() with
|
||||
{
|
||||
Transcode = true,
|
||||
NormalizeAudio = true,
|
||||
AudioCodec = "aac"
|
||||
};
|
||||
@@ -658,6 +706,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
{
|
||||
FFmpegProfile ffmpegProfile = TestProfile() with
|
||||
{
|
||||
Transcode = true,
|
||||
NormalizeAudio = true,
|
||||
AudioCodec = "aac"
|
||||
};
|
||||
@@ -681,6 +730,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
{
|
||||
FFmpegProfile ffmpegProfile = TestProfile() with
|
||||
{
|
||||
Transcode = true,
|
||||
NormalizeAudio = true,
|
||||
AudioCodec = "aac"
|
||||
};
|
||||
@@ -704,6 +754,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
{
|
||||
FFmpegProfile ffmpegProfile = TestProfile() with
|
||||
{
|
||||
Transcode = true,
|
||||
NormalizeAudio = true,
|
||||
AudioBitrate = 2424,
|
||||
AudioCodec = "ac3"
|
||||
@@ -728,6 +779,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
{
|
||||
FFmpegProfile ffmpegProfile = TestProfile() with
|
||||
{
|
||||
Transcode = true,
|
||||
NormalizeAudio = true,
|
||||
AudioBufferSize = 2424,
|
||||
AudioCodec = "ac3"
|
||||
@@ -752,6 +804,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
{
|
||||
FFmpegProfile ffmpegProfile = TestProfile() with
|
||||
{
|
||||
Transcode = true,
|
||||
NormalizeAudio = true,
|
||||
AudioCodec = "ac3",
|
||||
AudioChannels = 6
|
||||
@@ -776,6 +829,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
{
|
||||
FFmpegProfile ffmpegProfile = TestProfile() with
|
||||
{
|
||||
Transcode = true,
|
||||
NormalizeAudio = true,
|
||||
AudioCodec = "ac3",
|
||||
AudioSampleRate = 48
|
||||
@@ -800,6 +854,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
{
|
||||
FFmpegProfile ffmpegProfile = TestProfile() with
|
||||
{
|
||||
Transcode = true,
|
||||
NormalizeAudio = true,
|
||||
AudioChannels = 6
|
||||
};
|
||||
@@ -823,6 +878,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
{
|
||||
FFmpegProfile ffmpegProfile = TestProfile() with
|
||||
{
|
||||
Transcode = true,
|
||||
NormalizeAudio = true,
|
||||
AudioSampleRate = 48
|
||||
};
|
||||
@@ -846,6 +902,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
{
|
||||
FFmpegProfile ffmpegProfile = TestProfile() with
|
||||
{
|
||||
Transcode = true,
|
||||
NormalizeAudio = true,
|
||||
AudioSampleRate = 48,
|
||||
AudioCodec = "ac3"
|
||||
@@ -870,6 +927,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
{
|
||||
FFmpegProfile ffmpegProfile = TestProfile() with
|
||||
{
|
||||
Transcode = true,
|
||||
NormalizeAudio = true,
|
||||
NormalizeLoudness = true
|
||||
};
|
||||
@@ -893,6 +951,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
{
|
||||
FFmpegProfile ffmpegProfile = TestProfile() with
|
||||
{
|
||||
Transcode = true,
|
||||
NormalizeAudio = false,
|
||||
NormalizeLoudness = true
|
||||
};
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Images;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
using FluentAssertions;
|
||||
using LanguageExt;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using NUnit.Framework;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
{
|
||||
[TestFixture]
|
||||
[Explicit]
|
||||
public class TranscodingTests
|
||||
{
|
||||
[Test]
|
||||
[Explicit]
|
||||
public void DeleteTestVideos()
|
||||
{
|
||||
foreach (string file in Directory.GetFiles(TestContext.CurrentContext.TestDirectory, "*.mkv"))
|
||||
{
|
||||
File.Delete(file);
|
||||
}
|
||||
|
||||
Assert.Pass();
|
||||
}
|
||||
|
||||
private class TestData
|
||||
{
|
||||
public static string[] InputCodecs =
|
||||
{
|
||||
"h264",
|
||||
"mpeg2video",
|
||||
"hevc",
|
||||
"mpeg4"
|
||||
};
|
||||
|
||||
public static string[] InputPixelFormats =
|
||||
{
|
||||
"yuv420p",
|
||||
"yuv420p10le",
|
||||
"yuvj420p",
|
||||
"yuv444p",
|
||||
"yuv444p10le"
|
||||
};
|
||||
|
||||
public static Resolution[] Resolutions =
|
||||
{
|
||||
new() { Width = 1920, Height = 1080 },
|
||||
new() { Width = 1280, Height = 720 }
|
||||
};
|
||||
|
||||
public static string[] SoftwareCodecs =
|
||||
{
|
||||
"libx264",
|
||||
"libx265"
|
||||
};
|
||||
|
||||
public static HardwareAccelerationKind[] NoAcceleration =
|
||||
{
|
||||
HardwareAccelerationKind.None
|
||||
};
|
||||
|
||||
public static string[] NvidiaCodecs =
|
||||
{
|
||||
"h264_nvenc",
|
||||
"hevc_nvenc"
|
||||
};
|
||||
|
||||
public static HardwareAccelerationKind[] NvidiaAcceleration =
|
||||
{
|
||||
HardwareAccelerationKind.Nvenc
|
||||
};
|
||||
|
||||
public static string[] VaapiCodecs =
|
||||
{
|
||||
"h264_vaapi",
|
||||
"hevc_vaapi"
|
||||
};
|
||||
|
||||
public static HardwareAccelerationKind[] VaapiAcceleration =
|
||||
{
|
||||
HardwareAccelerationKind.Vaapi
|
||||
};
|
||||
}
|
||||
|
||||
[Test, Combinatorial]
|
||||
public async Task Transcode(
|
||||
[ValueSource(typeof(TestData), nameof(TestData.InputCodecs))]
|
||||
string inputCodec,
|
||||
[ValueSource(typeof(TestData), nameof(TestData.InputPixelFormats))]
|
||||
string inputPixelFormat,
|
||||
[ValueSource(typeof(TestData), nameof(TestData.Resolutions))]
|
||||
Resolution profileResolution,
|
||||
// [ValueSource(typeof(TestData), nameof(TestData.SoftwareCodecs))] string profileCodec,
|
||||
// [ValueSource(typeof(TestData), nameof(TestData.NoAcceleration))] HardwareAccelerationKind profileAcceleration)
|
||||
[ValueSource(typeof(TestData), nameof(TestData.NvidiaCodecs))] string profileCodec,
|
||||
[ValueSource(typeof(TestData), nameof(TestData.NvidiaAcceleration))] HardwareAccelerationKind profileAcceleration)
|
||||
// [ValueSource(typeof(TestData), nameof(TestData.VaapiCodecs))] string profileCodec,
|
||||
// [ValueSource(typeof(TestData), nameof(TestData.VaapiAcceleration))] HardwareAccelerationKind profileAcceleration)
|
||||
{
|
||||
string name = GetStringSha256Hash(
|
||||
$"{inputCodec}_{inputPixelFormat}_{profileResolution}_{profileCodec}_{profileAcceleration}");
|
||||
|
||||
string file = Path.Combine(TestContext.CurrentContext.TestDirectory, $"{name}.mkv");
|
||||
if (!File.Exists(file))
|
||||
{
|
||||
var args =
|
||||
$"-y -f lavfi -i anullsrc=channel_layout=stereo:sample_rate=44100 -f lavfi -i testsrc=duration=1:size=1920x1080:rate=30 -c:a aac -c:v {inputCodec} -shortest -pix_fmt {inputPixelFormat} -strict -2 {file}";
|
||||
var p1 = new Process
|
||||
{
|
||||
StartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "ffmpeg",
|
||||
Arguments = args
|
||||
}
|
||||
};
|
||||
|
||||
p1.Start();
|
||||
await p1.WaitForExitAsync();
|
||||
p1.ExitCode.Should().Be(0);
|
||||
}
|
||||
|
||||
var service = new FFmpegProcessService(
|
||||
new FFmpegPlaybackSettingsCalculator(),
|
||||
new FakeStreamSelector(),
|
||||
new Mock<IImageCache>().Object);
|
||||
|
||||
MediaVersion v = new MediaVersion();
|
||||
|
||||
var metadataRepository = new Mock<IMetadataRepository>();
|
||||
metadataRepository
|
||||
.Setup(r => r.UpdateLocalStatistics(It.IsAny<int>(), It.IsAny<MediaVersion>(), It.IsAny<bool>()))
|
||||
.Callback<int, MediaVersion, bool>((_, version, _) => v = version);
|
||||
|
||||
var localStatisticsProvider = new LocalStatisticsProvider(
|
||||
metadataRepository.Object,
|
||||
new LocalFileSystem(),
|
||||
new Mock<ILogger<LocalStatisticsProvider>>().Object);
|
||||
|
||||
await localStatisticsProvider.RefreshStatistics(
|
||||
"ffprobe",
|
||||
new Movie
|
||||
{
|
||||
MediaVersions = new List<MediaVersion>
|
||||
{
|
||||
new()
|
||||
{
|
||||
MediaFiles = new List<MediaFile>
|
||||
{
|
||||
new() { Path = file }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
DateTimeOffset now = DateTimeOffset.Now;
|
||||
|
||||
Process process = await service.ForPlayoutItem(
|
||||
"ffmpeg",
|
||||
false,
|
||||
new Channel(Guid.NewGuid())
|
||||
{
|
||||
FFmpegProfile = FFmpegProfile.New("test", profileResolution) with
|
||||
{
|
||||
HardwareAcceleration = profileAcceleration,
|
||||
VideoCodec = profileCodec
|
||||
},
|
||||
StreamingMode = StreamingMode.TransportStream
|
||||
},
|
||||
v,
|
||||
file,
|
||||
now,
|
||||
now,
|
||||
None,
|
||||
None);
|
||||
|
||||
process.StartInfo.RedirectStandardError = true;
|
||||
|
||||
process.Start().Should().BeTrue();
|
||||
|
||||
await process.StandardOutput.ReadToEndAsync();
|
||||
string error = await process.StandardError.ReadToEndAsync();
|
||||
await process.WaitForExitAsync();
|
||||
|
||||
string[] unsupportedMessages =
|
||||
{
|
||||
"No support for codec",
|
||||
"No usable",
|
||||
"Provided device doesn't support"
|
||||
};
|
||||
|
||||
if (profileAcceleration != HardwareAccelerationKind.None && unsupportedMessages.Any(error.Contains))
|
||||
{
|
||||
IEnumerable<string> quotedArgs = process.StartInfo.ArgumentList.Map(a => $"\'{a}\'");
|
||||
process.ExitCode.Should().Be(1, $"Error message with successful exit code? {string.Join(" ", quotedArgs)}");
|
||||
Assert.Warn("Unsupported on this hardware");
|
||||
}
|
||||
else if (error.Contains("Impossible to convert between"))
|
||||
{
|
||||
IEnumerable<string> quotedArgs = process.StartInfo.ArgumentList.Map(a => $"\'{a}\'");
|
||||
Assert.Fail($"Transcode failure: ffmpeg {string.Join(" ", quotedArgs)}");
|
||||
}
|
||||
else
|
||||
{
|
||||
process.ExitCode.Should().Be(0, error);
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetStringSha256Hash(string text)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
using var sha = new System.Security.Cryptography.SHA256Managed();
|
||||
byte[] textData = System.Text.Encoding.UTF8.GetBytes(text);
|
||||
byte[] hash = sha.ComputeHash(textData);
|
||||
return BitConverter.ToString(hash).Replace("-", string.Empty);
|
||||
}
|
||||
|
||||
private class FakeStreamSelector : IFFmpegStreamSelector
|
||||
{
|
||||
public Task<MediaStream> SelectVideoStream(Channel channel, MediaVersion version) =>
|
||||
version.Streams.First(s => s.MediaStreamKind == MediaStreamKind.Video).AsTask();
|
||||
|
||||
public Task<Option<MediaStream>> SelectAudioStream(Channel channel, MediaVersion version) =>
|
||||
Optional(version.Streams.First(s => s.MediaStreamKind == MediaStreamKind.Audio)).AsTask();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,6 @@ namespace ErsatzTV.Core.Tests.Fakes
|
||||
{
|
||||
public record FakeFileEntry(string Path)
|
||||
{
|
||||
public DateTime LastWriteTime { get; set; } = DateTime.MinValue;
|
||||
public DateTime LastWriteTime { get; set; } = SystemTime.MinValueUtc;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ namespace ErsatzTV.Core.Tests.Fakes
|
||||
public DateTime GetLastWriteTime(string path) =>
|
||||
Optional(_files.SingleOrDefault(f => f.Path == path))
|
||||
.Map(f => f.LastWriteTime)
|
||||
.IfNone(DateTime.MinValue);
|
||||
.IfNone(SystemTime.MinValueUtc);
|
||||
|
||||
public bool IsLibraryPathAccessible(LibraryPath libraryPath) =>
|
||||
_files.Any(f => f.Path.StartsWith(libraryPath.Path + Path.DirectorySeparatorChar));
|
||||
|
||||
@@ -11,6 +11,8 @@ namespace ErsatzTV.Core.Tests.Fakes
|
||||
public class FakeTelevisionRepository : ITelevisionRepository
|
||||
{
|
||||
public Task<bool> AllShowsExist(List<int> showIds) => throw new NotSupportedException();
|
||||
public Task<bool> AllSeasonsExist(List<int> seasonIds) => throw new NotSupportedException();
|
||||
|
||||
public Task<bool> AllEpisodesExist(List<int> episodeIds) => throw new NotSupportedException();
|
||||
|
||||
public Task<List<Show>> GetAllShows() => throw new NotSupportedException();
|
||||
@@ -18,6 +20,8 @@ namespace ErsatzTV.Core.Tests.Fakes
|
||||
public Task<Option<Show>> GetShow(int showId) => throw new NotSupportedException();
|
||||
|
||||
public Task<List<ShowMetadata>> GetShowsForCards(List<int> ids) => throw new NotSupportedException();
|
||||
public Task<List<SeasonMetadata>> GetSeasonsForCards(List<int> ids) => throw new NotSupportedException();
|
||||
|
||||
public Task<List<EpisodeMetadata>> GetEpisodesForCards(List<int> ids) => throw new NotSupportedException();
|
||||
|
||||
public Task<List<Episode>> GetShowItems(int showId) => throw new NotSupportedException();
|
||||
@@ -33,8 +37,6 @@ namespace ErsatzTV.Core.Tests.Fakes
|
||||
|
||||
public Task<List<Episode>> GetSeasonItems(int seasonId) => throw new NotSupportedException();
|
||||
|
||||
public Task<Option<Episode>> GetEpisode(int episodeId) => throw new NotSupportedException();
|
||||
|
||||
public Task<int> GetEpisodeCount(int seasonId) => throw new NotSupportedException();
|
||||
|
||||
public Task<List<EpisodeMetadata>> GetPagedEpisodes(int seasonId, int pageNumber, int pageSize) =>
|
||||
@@ -95,5 +97,6 @@ namespace ErsatzTV.Core.Tests.Fakes
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
public class TraktList
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int TraktId { get; set; }
|
||||
public string User { get; set; }
|
||||
public string List { get; set; }
|
||||
public string Name { get; set; }
|
||||
public string Description { get; set; }
|
||||
public int ItemCount { get; set; }
|
||||
public List<TraktListItem> Items { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
public class TraktListItem
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int TraktListId { get; set; }
|
||||
public TraktList TraktList { get; set; }
|
||||
|
||||
public TraktListItemKind Kind { get; set; }
|
||||
public int TraktId { get; set; }
|
||||
public int Rank { get; set; }
|
||||
public string Title { get; set; }
|
||||
public int? Year { get; set; }
|
||||
public int? Season { get; set; }
|
||||
public int? Episode { get; set; }
|
||||
public List<TraktListItemGuid> Guids { get; set; }
|
||||
|
||||
public int? MediaItemId { get; set; }
|
||||
public MediaItem MediaItem { get; set; }
|
||||
|
||||
public string DisplayTitle => Kind switch
|
||||
{
|
||||
TraktListItemKind.Movie => $"{Title} ({Year})",
|
||||
TraktListItemKind.Show => $"{Title} ({Year})",
|
||||
TraktListItemKind.Season => $"{Title} ({Year}) S{Season:00}",
|
||||
_ => $"{Title} ({Year}) S{Season:00}E{Episode:00}"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
public class TraktListItemGuid
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Guid { get; set; }
|
||||
public int TraktListItemId { get; set; }
|
||||
public TraktListItem TraktListItem { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
public enum TraktListItemKind
|
||||
{
|
||||
Movie,
|
||||
Show,
|
||||
Season,
|
||||
Episode
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@
|
||||
public static ConfigElementKey FFmpegSaveReports => new("ffmpeg.save_reports");
|
||||
public static ConfigElementKey FFmpegPreferredLanguageCode => new("ffmpeg.preferred_language_code");
|
||||
public static ConfigElementKey FFmpegGlobalWatermarkId => new("ffmpeg.global_watermark_id");
|
||||
public static ConfigElementKey FFmpegVaapiDriver => new("ffmpeg.vaapi_driver");
|
||||
public static ConfigElementKey SearchIndexVersion => new("search_index.version");
|
||||
public static ConfigElementKey HDHRTunerCount => new("hdhr.tuner_count");
|
||||
public static ConfigElementKey ChannelsPageSize => new("pages.channels.page_size");
|
||||
@@ -24,6 +25,7 @@
|
||||
public static ConfigElementKey PlayoutsPageSize => new("pages.playouts.page_size");
|
||||
public static ConfigElementKey PlayoutsDetailPageSize => new("pages.playouts.detail_page_size");
|
||||
public static ConfigElementKey LogsPageSize => new("pages.logs.page_size");
|
||||
public static ConfigElementKey TraktListsPageSize => new("pages.trakt.lists_page_size");
|
||||
public static ConfigElementKey LibraryRefreshInterval => new("scanner.library_refresh_interval");
|
||||
public static ConfigElementKey PlayoutDaysToBuild => new("playout.days_to_build");
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ using System.Diagnostics;
|
||||
|
||||
namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
[DebuggerDisplay("{EpisodeMetadata[0].Title}")]
|
||||
[DebuggerDisplay("{EpisodeMetadata[0].Title ?? \"[unknown episode]\"}")]
|
||||
public class Episode : MediaItem
|
||||
{
|
||||
public int SeasonId { get; set; }
|
||||
|
||||
@@ -9,5 +9,6 @@ namespace ErsatzTV.Core.Domain
|
||||
public LibraryPath LibraryPath { get; set; }
|
||||
public List<Collection> Collections { get; set; }
|
||||
public List<CollectionItem> CollectionItems { get; set; }
|
||||
public List<TraktListItem> TraktListItems { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Collections.Generic;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
@@ -13,5 +14,6 @@ namespace ErsatzTV.Core.Domain
|
||||
public List<PlayoutItem> Items { get; set; }
|
||||
public PlayoutAnchor Anchor { get; set; }
|
||||
public List<PlayoutProgramScheduleAnchor> ProgramScheduleAnchors { get; set; }
|
||||
public TimeSpan? DailyRebuildTime { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,5 +8,7 @@ namespace ErsatzTV.Core.Domain
|
||||
public string Name { get; set; }
|
||||
public int Height { get; set; }
|
||||
public int Width { get; set; }
|
||||
|
||||
public override string ToString() => $"{Width}x{Height}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,7 +227,11 @@ namespace ErsatzTV.Core.Emby
|
||||
incoming.ShowId = show.Id;
|
||||
incoming.LibraryPathId = library.Paths.Head().Id;
|
||||
|
||||
await _televisionRepository.Update(incoming);
|
||||
foreach (EmbySeason updated in await _televisionRepository.Update(incoming))
|
||||
{
|
||||
incoming.Show = show;
|
||||
await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { updated });
|
||||
}
|
||||
},
|
||||
async () =>
|
||||
{
|
||||
@@ -238,7 +242,11 @@ namespace ErsatzTV.Core.Emby
|
||||
show.ShowMetadata.Head().Title,
|
||||
incoming.SeasonMetadata.Head().Title);
|
||||
|
||||
await _televisionRepository.AddSeason(show, incoming);
|
||||
if (await _televisionRepository.AddSeason(show, incoming))
|
||||
{
|
||||
incoming.Show = show;
|
||||
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { incoming });
|
||||
}
|
||||
});
|
||||
|
||||
List<EmbyItemEtag> existingEpisodes =
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<PackageReference Include="MediatR" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="5.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="5.0.0" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="16.10.56">
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.0.63">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
|
||||
@@ -129,6 +129,20 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
}
|
||||
}
|
||||
|
||||
string[] h264hevc = { "h264", "hevc" };
|
||||
|
||||
if (acceleration == HardwareAccelerationKind.Vaapi && (_pixelFormat ?? string.Empty).EndsWith("p10le") &&
|
||||
h264hevc.Contains(_inputCodec)
|
||||
&& (_pixelFormat != "yuv420p10le" || _inputCodec != "hevc"))
|
||||
{
|
||||
videoFilterQueue.Add("format=p010le,format=nv12|vaapi,hwupload");
|
||||
}
|
||||
|
||||
if (acceleration == HardwareAccelerationKind.Vaapi && _pixelFormat == "yuv444p" && h264hevc.Contains(_inputCodec))
|
||||
{
|
||||
videoFilterQueue.Add("format=nv12|vaapi,hwupload");
|
||||
}
|
||||
|
||||
_scaleToSize.IfSome(
|
||||
size =>
|
||||
{
|
||||
@@ -138,7 +152,7 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
HardwareAccelerationKind.Nvenc when _pixelFormat == "yuv420p10le" =>
|
||||
$"hwdownload,format=p010le,format=nv12,hwupload,scale_npp={size.Width}:{size.Height}",
|
||||
HardwareAccelerationKind.Nvenc => $"scale_npp={size.Width}:{size.Height}",
|
||||
HardwareAccelerationKind.Vaapi => $"scale_vaapi=w={size.Width}:h={size.Height}",
|
||||
HardwareAccelerationKind.Vaapi => $"scale_vaapi=format=nv12:w={size.Width}:h={size.Height}",
|
||||
_ => $"scale={size.Width}:{size.Height}:flags=fast_bilinear"
|
||||
};
|
||||
|
||||
@@ -233,28 +247,39 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
complexFilter.Append(audioLabel);
|
||||
}
|
||||
|
||||
if (videoFilterQueue.Any())
|
||||
if (videoFilterQueue.Any() || !string.IsNullOrWhiteSpace(watermarkOverlay))
|
||||
{
|
||||
if (hasAudioFilters)
|
||||
{
|
||||
complexFilter.Append(';');
|
||||
}
|
||||
|
||||
complexFilter.Append($"[{videoLabel}]");
|
||||
var filters = string.Join(",", videoFilterQueue);
|
||||
complexFilter.Append(filters);
|
||||
if (videoFilterQueue.Any())
|
||||
{
|
||||
complexFilter.Append($"[{videoLabel}]");
|
||||
var filters = string.Join(",", videoFilterQueue);
|
||||
complexFilter.Append(filters);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(watermarkOverlay))
|
||||
{
|
||||
complexFilter.Append("[vt]");
|
||||
if (videoFilterQueue.Any())
|
||||
{
|
||||
complexFilter.Append("[vt];");
|
||||
}
|
||||
|
||||
var watermarkLabel = "[1:v]";
|
||||
if (!string.IsNullOrWhiteSpace(watermarkPreprocess))
|
||||
{
|
||||
complexFilter.Append($";{watermarkLabel}{watermarkPreprocess}[wmp]");
|
||||
complexFilter.Append($"{watermarkLabel}{watermarkPreprocess}[wmp];");
|
||||
watermarkLabel = "[wmp]";
|
||||
}
|
||||
|
||||
complexFilter.Append($";[vt]{watermarkLabel}{watermarkOverlay}");
|
||||
complexFilter.Append(
|
||||
videoFilterQueue.Any()
|
||||
? $"[vt]{watermarkLabel}{watermarkOverlay}"
|
||||
: $"[{videoLabel}]{watermarkLabel}{watermarkOverlay}");
|
||||
|
||||
if (usesSoftwareFilters && acceleration != HardwareAccelerationKind.None)
|
||||
{
|
||||
complexFilter.Append(",hwupload");
|
||||
|
||||
@@ -85,12 +85,12 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
}
|
||||
|
||||
IDisplaySize sizeAfterScaling = result.ScaledSize.IfNone(version);
|
||||
if (ffmpegProfile.NormalizeVideo && !sizeAfterScaling.IsSameSizeAs(ffmpegProfile.Resolution))
|
||||
if (ffmpegProfile.Transcode && ffmpegProfile.NormalizeVideo && !sizeAfterScaling.IsSameSizeAs(ffmpegProfile.Resolution))
|
||||
{
|
||||
result.PadToDesiredResolution = true;
|
||||
}
|
||||
|
||||
if (ffmpegProfile.NormalizeVideo)
|
||||
if (ffmpegProfile.Transcode && ffmpegProfile.NormalizeVideo)
|
||||
{
|
||||
result.VideoTrackTimeScale = 90000;
|
||||
}
|
||||
@@ -107,7 +107,7 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
result.VideoCodec = "copy";
|
||||
}
|
||||
|
||||
if (ffmpegProfile.NormalizeAudio)
|
||||
if (ffmpegProfile.Transcode && ffmpegProfile.NormalizeAudio)
|
||||
{
|
||||
result.AudioCodec = ffmpegProfile.AudioCodec;
|
||||
result.AudioBitrate = ffmpegProfile.AudioBitrate;
|
||||
@@ -147,12 +147,12 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
{
|
||||
ThreadCount = ffmpegProfile.ThreadCount,
|
||||
FormatFlags = CommonFormatFlags,
|
||||
VideoCodec = ffmpegProfile.VideoCodec,
|
||||
AudioCodec = ffmpegProfile.AudioCodec
|
||||
VideoCodec = "libx264",
|
||||
AudioCodec = ffmpegProfile.AudioCodec,
|
||||
};
|
||||
|
||||
private static bool NeedToScale(FFmpegProfile ffmpegProfile, MediaVersion version) =>
|
||||
ffmpegProfile.NormalizeVideo &&
|
||||
ffmpegProfile.Transcode && ffmpegProfile.NormalizeVideo &&
|
||||
IsIncorrectSize(ffmpegProfile.Resolution, version) ||
|
||||
IsTooLarge(ffmpegProfile.Resolution, version) ||
|
||||
IsOddSize(version);
|
||||
@@ -170,7 +170,7 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
version.Height % 2 == 1 || version.Width % 2 == 1;
|
||||
|
||||
private static bool NeedToNormalizeVideoCodec(FFmpegProfile ffmpegProfile, MediaStream videoStream) =>
|
||||
ffmpegProfile.NormalizeVideo && ffmpegProfile.VideoCodec != videoStream.Codec;
|
||||
ffmpegProfile.Transcode && ffmpegProfile.NormalizeVideo && ffmpegProfile.VideoCodec != videoStream.Codec;
|
||||
|
||||
private static IDisplaySize CalculateScaledSize(FFmpegProfile ffmpegProfile, MediaVersion version)
|
||||
{
|
||||
|
||||
@@ -43,6 +43,8 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
private readonly bool _saveReports;
|
||||
private FFmpegComplexFilterBuilder _complexFilterBuilder = new();
|
||||
private bool _isConcat;
|
||||
private VaapiDriver _vaapiDriver;
|
||||
private HardwareAccelerationKind _hwAccel;
|
||||
|
||||
public FFmpegProcessBuilder(string ffmpegPath, bool saveReports)
|
||||
{
|
||||
@@ -50,6 +52,16 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
_saveReports = saveReports;
|
||||
}
|
||||
|
||||
public FFmpegProcessBuilder WithVaapiDriver(Option<VaapiDriver> maybeVaapiDriver)
|
||||
{
|
||||
foreach (VaapiDriver vaapiDriver in maybeVaapiDriver)
|
||||
{
|
||||
_vaapiDriver = vaapiDriver;
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegProcessBuilder WithThreads(int threads)
|
||||
{
|
||||
_arguments.Add("-threads");
|
||||
@@ -59,6 +71,8 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
|
||||
public FFmpegProcessBuilder WithHardwareAcceleration(HardwareAccelerationKind hwAccel)
|
||||
{
|
||||
_hwAccel = hwAccel;
|
||||
|
||||
switch (hwAccel)
|
||||
{
|
||||
case HardwareAccelerationKind.Qsv:
|
||||
@@ -433,6 +447,19 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
StandardOutputEncoding = Encoding.UTF8
|
||||
};
|
||||
|
||||
if (_hwAccel == HardwareAccelerationKind.Vaapi)
|
||||
{
|
||||
switch (_vaapiDriver)
|
||||
{
|
||||
case VaapiDriver.i965:
|
||||
startInfo.EnvironmentVariables.Add("LIBVA_DRIVER_NAME", "i965");
|
||||
break;
|
||||
case VaapiDriver.iHD:
|
||||
startInfo.EnvironmentVariables.Add("LIBVA_DRIVER_NAME", "iHD");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (_saveReports)
|
||||
{
|
||||
string fileName = _isConcat
|
||||
|
||||
@@ -34,7 +34,8 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
string path,
|
||||
DateTimeOffset start,
|
||||
DateTimeOffset now,
|
||||
Option<ChannelWatermark> globalWatermark)
|
||||
Option<ChannelWatermark> globalWatermark,
|
||||
Option<VaapiDriver> maybeVaapiDriver)
|
||||
{
|
||||
MediaStream videoStream = await _ffmpegStreamSelector.SelectVideoStream(channel, version);
|
||||
Option<MediaStream> maybeAudioStream = await _ffmpegStreamSelector.SelectAudioStream(channel, version);
|
||||
@@ -58,6 +59,7 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
FFmpegProcessBuilder builder = new FFmpegProcessBuilder(ffmpegPath, saveReports)
|
||||
.WithThreads(playbackSettings.ThreadCount)
|
||||
.WithHardwareAcceleration(playbackSettings.HardwareAcceleration)
|
||||
.WithVaapiDriver(maybeVaapiDriver)
|
||||
.WithQuiet()
|
||||
.WithFormatFlags(playbackSettings.FormatFlags)
|
||||
.WithRealtimeOutput(playbackSettings.RealtimeOutput)
|
||||
@@ -160,7 +162,8 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
|
||||
private WatermarkOptions GetWatermarkOptions(Channel channel, Option<ChannelWatermark> globalWatermark)
|
||||
{
|
||||
if (channel.StreamingMode != StreamingMode.HttpLiveStreamingDirect)
|
||||
if (channel.StreamingMode != StreamingMode.HttpLiveStreamingDirect && channel.FFmpegProfile.Transcode &&
|
||||
channel.FFmpegProfile.NormalizeVideo)
|
||||
{
|
||||
// check for channel watermark
|
||||
if (channel.Watermark != null)
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace ErsatzTV.Core.FFmpeg
|
||||
{
|
||||
[SuppressMessage("ReSharper", "InconsistentNaming")]
|
||||
public enum VaapiDriver
|
||||
{
|
||||
Default = 0,
|
||||
iHD = 1,
|
||||
i965 = 2
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace ErsatzTV.Core.Health.Checks
|
||||
{
|
||||
public interface IEpisodeMetadataHealthCheck : IHealthCheck
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace ErsatzTV.Core.Health.Checks
|
||||
{
|
||||
public interface IFFmpegReportsHealthCheck : IHealthCheck
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace ErsatzTV.Core.Health.Checks
|
||||
{
|
||||
public interface IFFmpegVersionHealthCheck : IHealthCheck
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace ErsatzTV.Core.Health.Checks
|
||||
{
|
||||
public interface IHardwareAccelerationHealthCheck : IHealthCheck
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace ErsatzTV.Core.Health.Checks
|
||||
{
|
||||
public interface IMovieMetadataHealthCheck : IHealthCheck
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace ErsatzTV.Core.Health.Checks
|
||||
{
|
||||
public interface IVaapiDriverHealthCheck : IHealthCheck
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace ErsatzTV.Core.Health.Checks
|
||||
{
|
||||
public interface IZeroDurationHealthCheck : IHealthCheck
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
namespace ErsatzTV.Core.Health
|
||||
{
|
||||
public record HealthCheckResult(string Title, HealthCheckStatus Status, string Message);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace ErsatzTV.Core.Health
|
||||
{
|
||||
public enum HealthCheckStatus
|
||||
{
|
||||
Pass = 0,
|
||||
Fail = 1,
|
||||
Warning = 2,
|
||||
Info = 3,
|
||||
NotApplicable = 4
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace ErsatzTV.Core.Health
|
||||
{
|
||||
public interface IHealthCheck
|
||||
{
|
||||
Task<HealthCheckResult> Check();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace ErsatzTV.Core.Health
|
||||
{
|
||||
public interface IHealthCheckService
|
||||
{
|
||||
Task<List<HealthCheckResult>> PerformHealthChecks();
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ namespace ErsatzTV.Core.Interfaces.Locking
|
||||
event EventHandler OnLibraryChanged;
|
||||
event EventHandler OnPlexChanged;
|
||||
event EventHandler<Type> OnRemoteMediaSourceChanged;
|
||||
event EventHandler OnTraktChanged;
|
||||
bool LockLibrary(int libraryId);
|
||||
bool UnlockLibrary(int libraryId);
|
||||
bool IsLibraryLocked(int libraryId);
|
||||
@@ -16,5 +17,8 @@ namespace ErsatzTV.Core.Interfaces.Locking
|
||||
bool IsRemoteMediaSourceLocked<TMediaSource>();
|
||||
bool LockRemoteMediaSource<TMediaSource>();
|
||||
bool UnlockRemoteMediaSource<TMediaSource>();
|
||||
bool IsTraktLocked();
|
||||
bool LockTrakt();
|
||||
bool UnlockTrakt();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ namespace ErsatzTV.Core.Interfaces.Repositories
|
||||
Task<bool> AddShow(EmbyShow show);
|
||||
Task<Option<EmbyShow>> Update(EmbyShow show);
|
||||
Task<bool> AddSeason(EmbyShow show, EmbySeason season);
|
||||
Task<Unit> Update(EmbySeason season);
|
||||
Task<Option<EmbySeason>> Update(EmbySeason season);
|
||||
Task<bool> AddEpisode(EmbySeason season, EmbyEpisode episode);
|
||||
Task<Option<EmbyEpisode>> Update(EmbyEpisode episode);
|
||||
Task<List<int>> RemoveMissingShows(EmbyLibrary library, List<string> showIds);
|
||||
|
||||
@@ -14,7 +14,7 @@ namespace ErsatzTV.Core.Interfaces.Repositories
|
||||
Task<bool> AddShow(JellyfinShow show);
|
||||
Task<Option<JellyfinShow>> Update(JellyfinShow show);
|
||||
Task<bool> AddSeason(JellyfinShow show, JellyfinSeason season);
|
||||
Task<Unit> Update(JellyfinSeason season);
|
||||
Task<Option<JellyfinSeason>> Update(JellyfinSeason season);
|
||||
Task<bool> AddEpisode(JellyfinSeason season, JellyfinEpisode episode);
|
||||
Task<Option<JellyfinEpisode>> Update(JellyfinEpisode episode);
|
||||
Task<List<int>> RemoveMissingShows(JellyfinLibrary library, List<string> showIds);
|
||||
|
||||
@@ -33,5 +33,6 @@ namespace ErsatzTV.Core.Interfaces.Repositories
|
||||
Task<Option<EmbyMovie>> UpdateEmby(EmbyMovie movie);
|
||||
Task<bool> AddDirector(MovieMetadata metadata, Director director);
|
||||
Task<bool> AddWriter(MovieMetadata metadata, Writer writer);
|
||||
Task<Unit> UpdatePath(int mediaFileId, string path);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ namespace ErsatzTV.Core.Interfaces.Repositories
|
||||
Task<List<int>> GetItemIdsToIndex();
|
||||
Task<Option<MediaItem>> GetItemToIndex(int id);
|
||||
Task<List<string>> GetLanguagesForShow(Show show);
|
||||
Task<List<string>> GetLanguagesForSeason(Season season);
|
||||
Task<List<string>> GetLanguagesForArtist(Artist artist);
|
||||
Task<List<string>> GetAllLanguageCodes(List<string> mediaCodes);
|
||||
}
|
||||
|
||||
@@ -9,10 +9,12 @@ namespace ErsatzTV.Core.Interfaces.Repositories
|
||||
public interface ITelevisionRepository
|
||||
{
|
||||
Task<bool> AllShowsExist(List<int> showIds);
|
||||
Task<bool> AllSeasonsExist(List<int> seasonIds);
|
||||
Task<bool> AllEpisodesExist(List<int> episodeIds);
|
||||
Task<List<Show>> GetAllShows();
|
||||
Task<Option<Show>> GetShow(int showId);
|
||||
Task<List<ShowMetadata>> GetShowsForCards(List<int> ids);
|
||||
Task<List<SeasonMetadata>> GetSeasonsForCards(List<int> ids);
|
||||
Task<List<EpisodeMetadata>> GetEpisodesForCards(List<int> ids);
|
||||
Task<List<Episode>> GetShowItems(int showId);
|
||||
Task<List<Season>> GetAllSeasons();
|
||||
@@ -49,5 +51,6 @@ namespace ErsatzTV.Core.Interfaces.Repositories
|
||||
Task<Unit> RemoveMetadata(Episode episode, EpisodeMetadata metadata);
|
||||
Task<bool> AddDirector(EpisodeMetadata metadata, Director director);
|
||||
Task<bool> AddWriter(EpisodeMetadata metadata, Writer writer);
|
||||
Task<Unit> UpdatePath(int mediaFileId, string path);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Trakt;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Trakt
|
||||
{
|
||||
public interface ITraktApiClient
|
||||
{
|
||||
Task<Either<BaseError, TraktList>> GetUserList(string user, string list);
|
||||
Task<Either<BaseError, List<TraktListItemWithGuids>>> GetUserListItems(string user, string list);
|
||||
}
|
||||
}
|
||||
@@ -200,10 +200,10 @@ namespace ErsatzTV.Core.Iptv
|
||||
|
||||
if (!isSameCustomShow)
|
||||
{
|
||||
int s = Optional(episode.Season?.SeasonNumber).IfNone(0);
|
||||
int s = Optional(episode.Season?.SeasonNumber).IfNone(-1);
|
||||
// TODO: multi-episode?
|
||||
int e = episode.EpisodeMetadata.Head().EpisodeNumber;
|
||||
if (s > 0 && e > 0)
|
||||
if (s >= 0 && e > 0)
|
||||
{
|
||||
xml.WriteStartElement("episode-num");
|
||||
xml.WriteAttributeString("system", "onscreen");
|
||||
|
||||
@@ -227,7 +227,11 @@ namespace ErsatzTV.Core.Jellyfin
|
||||
incoming.ShowId = show.Id;
|
||||
incoming.LibraryPathId = library.Paths.Head().Id;
|
||||
|
||||
await _televisionRepository.Update(incoming);
|
||||
foreach (JellyfinSeason updated in await _televisionRepository.Update(incoming))
|
||||
{
|
||||
incoming.Show = show;
|
||||
await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { updated });
|
||||
}
|
||||
},
|
||||
async () =>
|
||||
{
|
||||
@@ -238,7 +242,11 @@ namespace ErsatzTV.Core.Jellyfin
|
||||
show.ShowMetadata.Head().Title,
|
||||
incoming.SeasonMetadata.Head().Title);
|
||||
|
||||
await _televisionRepository.AddSeason(show, incoming);
|
||||
if (await _televisionRepository.AddSeason(show, incoming))
|
||||
{
|
||||
incoming.Show = show;
|
||||
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { incoming });
|
||||
}
|
||||
});
|
||||
|
||||
List<JellyfinItemEtag> existingEpisodes =
|
||||
|
||||
@@ -15,7 +15,14 @@ namespace ErsatzTV.Core.Metadata
|
||||
{
|
||||
string fileName = Path.GetFileName(showFolder);
|
||||
var metadata = new ShowMetadata
|
||||
{ MetadataKind = MetadataKind.Fallback, Title = fileName ?? showFolder };
|
||||
{
|
||||
MetadataKind = MetadataKind.Fallback,
|
||||
Title = fileName ?? showFolder,
|
||||
Genres = new List<Genre>(),
|
||||
Tags = new List<Tag>(),
|
||||
Studios = new List<Studio>(),
|
||||
Actors = new List<Actor>()
|
||||
};
|
||||
return GetTelevisionShowMetadata(fileName, metadata);
|
||||
}
|
||||
|
||||
@@ -33,7 +40,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
var baseMetadata = new EpisodeMetadata
|
||||
{
|
||||
MetadataKind = MetadataKind.Fallback,
|
||||
Title = fileName ?? path,
|
||||
Title = Path.GetFileNameWithoutExtension(path) ?? path,
|
||||
DateAdded = DateTime.UtcNow,
|
||||
EpisodeNumber = 0,
|
||||
Actors = new List<Actor>(),
|
||||
@@ -54,7 +61,17 @@ namespace ErsatzTV.Core.Metadata
|
||||
{
|
||||
string path = movie.MediaVersions.Head().MediaFiles.Head().Path;
|
||||
string fileName = Path.GetFileName(path);
|
||||
var metadata = new MovieMetadata { MetadataKind = MetadataKind.Fallback, Title = fileName ?? path };
|
||||
var metadata = new MovieMetadata
|
||||
{
|
||||
MetadataKind = MetadataKind.Fallback,
|
||||
Title = Path.GetFileNameWithoutExtension(path) ?? path,
|
||||
Genres = new List<Genre>(),
|
||||
Tags = new List<Tag>(),
|
||||
Studios = new List<Studio>(),
|
||||
Actors = new List<Actor>(),
|
||||
Directors = new List<Director>(),
|
||||
Writers = new List<Writer>()
|
||||
};
|
||||
|
||||
return fileName != null ? GetMovieMetadata(fileName, metadata) : metadata;
|
||||
}
|
||||
@@ -128,6 +145,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
EpisodeNumber = episodeNumber,
|
||||
DateAdded = baseMetadata.DateAdded,
|
||||
DateUpdated = baseMetadata.DateAdded,
|
||||
Title = baseMetadata.Title,
|
||||
Actors = new List<Actor>(),
|
||||
Artwork = new List<Artwork>(),
|
||||
Directors = new List<Director>(),
|
||||
@@ -164,12 +182,6 @@ namespace ErsatzTV.Core.Metadata
|
||||
metadata.Title = match.Groups[1].Value.Trim();
|
||||
metadata.Year = int.Parse(match.Groups[2].Value);
|
||||
metadata.ReleaseDate = new DateTime(int.Parse(match.Groups[2].Value), 1, 1);
|
||||
metadata.Genres = new List<Genre>();
|
||||
metadata.Tags = new List<Tag>();
|
||||
metadata.Studios = new List<Studio>();
|
||||
metadata.Actors = new List<Actor>();
|
||||
metadata.Directors = new List<Director>();
|
||||
metadata.Writers = new List<Writer>();
|
||||
metadata.DateUpdated = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
@@ -214,10 +226,6 @@ namespace ErsatzTV.Core.Metadata
|
||||
metadata.Title = match.Groups[1].Value;
|
||||
metadata.Year = int.Parse(match.Groups[2].Value);
|
||||
metadata.ReleaseDate = new DateTime(int.Parse(match.Groups[2].Value), 1, 1);
|
||||
metadata.Genres = new List<Genre>();
|
||||
metadata.Tags = new List<Tag>();
|
||||
metadata.Studios = new List<Studio>();
|
||||
metadata.Actors = new List<Actor>();
|
||||
metadata.DateUpdated = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
}
|
||||
|
||||
public DateTime GetLastWriteTime(string path) =>
|
||||
Try(File.GetLastWriteTimeUtc(path)).IfFail(() => DateTime.MinValue);
|
||||
Try(File.GetLastWriteTimeUtc(path)).IfFail(() => SystemTime.MinValueUtc);
|
||||
|
||||
public bool IsLibraryPathAccessible(LibraryPath libraryPath) =>
|
||||
Directory.Exists(libraryPath.Path);
|
||||
|
||||
@@ -206,19 +206,19 @@ namespace ErsatzTV.Core.Metadata
|
||||
updated = await _metadataRepository.Add(metadata) || updated;
|
||||
}
|
||||
|
||||
foreach (EpisodeMetadata metadata in toUpdate)
|
||||
foreach (EpisodeMetadata existing in toUpdate)
|
||||
{
|
||||
Option<EpisodeMetadata> maybeExisting =
|
||||
episode.EpisodeMetadata.Find(em => em.EpisodeNumber == metadata.EpisodeNumber);
|
||||
updated = await maybeExisting.Match(
|
||||
async existing =>
|
||||
Option<EpisodeMetadata> maybeIncoming =
|
||||
episodeMetadata.Find(em => em.EpisodeNumber == existing.EpisodeNumber);
|
||||
updated = await maybeIncoming.Match(
|
||||
async metadata =>
|
||||
{
|
||||
existing.Outline = metadata.Outline;
|
||||
existing.Plot = metadata.Plot;
|
||||
existing.Tagline = metadata.Tagline;
|
||||
existing.Title = metadata.Title;
|
||||
|
||||
if (existing.DateAdded == DateTime.MinValue)
|
||||
if (existing.DateAdded == SystemTime.MinValueUtc)
|
||||
{
|
||||
existing.DateAdded = metadata.DateAdded;
|
||||
}
|
||||
@@ -318,7 +318,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
existing.Tagline = metadata.Tagline;
|
||||
existing.Title = metadata.Title;
|
||||
|
||||
if (existing.DateAdded == DateTime.MinValue)
|
||||
if (existing.DateAdded == SystemTime.MinValueUtc)
|
||||
{
|
||||
existing.DateAdded = metadata.DateAdded;
|
||||
}
|
||||
@@ -423,7 +423,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
existing.Tagline = metadata.Tagline;
|
||||
existing.Title = metadata.Title;
|
||||
|
||||
if (existing.DateAdded == DateTime.MinValue)
|
||||
if (existing.DateAdded == SystemTime.MinValueUtc)
|
||||
{
|
||||
existing.DateAdded = metadata.DateAdded;
|
||||
}
|
||||
@@ -486,7 +486,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
existing.Disambiguation = metadata.Disambiguation;
|
||||
existing.Biography = metadata.Biography;
|
||||
|
||||
if (existing.DateAdded == DateTime.MinValue)
|
||||
if (existing.DateAdded == SystemTime.MinValueUtc)
|
||||
{
|
||||
existing.DateAdded = metadata.DateAdded;
|
||||
}
|
||||
@@ -581,7 +581,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
existing.Plot = metadata.Plot;
|
||||
existing.Album = metadata.Album;
|
||||
|
||||
if (existing.DateAdded == DateTime.MinValue)
|
||||
if (existing.DateAdded == SystemTime.MinValueUtc)
|
||||
{
|
||||
existing.DateAdded = metadata.DateAdded;
|
||||
}
|
||||
|
||||
@@ -184,6 +184,9 @@ namespace ErsatzTV.Core.Metadata
|
||||
{
|
||||
await ScanEpisodes(libraryPath, ffprobePath, season, seasonFolder);
|
||||
await _libraryRepository.SetEtag(libraryPath, knownFolder, seasonFolder, etag);
|
||||
|
||||
season.Show = show;
|
||||
await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { season });
|
||||
},
|
||||
error =>
|
||||
{
|
||||
@@ -322,7 +325,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
async () =>
|
||||
{
|
||||
bool shouldUpdate = Optional(episode.EpisodeMetadata).Flatten().HeadOrNone().Match(
|
||||
m => m.DateUpdated == DateTime.MinValue,
|
||||
m => m.DateUpdated == SystemTime.MinValueUtc,
|
||||
true);
|
||||
|
||||
if (shouldUpdate)
|
||||
|
||||
@@ -156,6 +156,24 @@ namespace ErsatzTV.Core.Plex
|
||||
MediaVersion existingVersion = existing.MediaVersions.Head();
|
||||
MediaVersion incomingVersion = incoming.MediaVersions.Head();
|
||||
|
||||
foreach (MediaFile incomingFile in incomingVersion.MediaFiles.HeadOrNone())
|
||||
{
|
||||
foreach (MediaFile existingFile in existingVersion.MediaFiles.HeadOrNone())
|
||||
{
|
||||
if (incomingFile.Path != existingFile.Path)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Plex movie has moved from {OldPath} to {NewPath}",
|
||||
existingFile.Path,
|
||||
incomingFile.Path);
|
||||
|
||||
existingFile.Path = incomingFile.Path;
|
||||
|
||||
await _movieRepository.UpdatePath(existingFile.Id, incomingFile.Path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (incomingVersion.DateUpdated > existingVersion.DateUpdated || !existingVersion.Streams.Any())
|
||||
{
|
||||
string localPath = _plexPathReplacementService.GetReplacementPlexPath(
|
||||
|
||||
@@ -314,13 +314,19 @@ namespace ErsatzTV.Core.Plex
|
||||
.BindT(existing => UpdateMetadataAndArtwork(existing, incoming));
|
||||
|
||||
await maybeSeason.Match(
|
||||
async season => await ScanEpisodes(
|
||||
library,
|
||||
pathReplacements,
|
||||
season,
|
||||
connection,
|
||||
token,
|
||||
ffprobePath),
|
||||
async season =>
|
||||
{
|
||||
await ScanEpisodes(
|
||||
library,
|
||||
pathReplacements,
|
||||
season,
|
||||
connection,
|
||||
token,
|
||||
ffprobePath);
|
||||
|
||||
season.Show = show;
|
||||
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { season });
|
||||
},
|
||||
error =>
|
||||
{
|
||||
_logger.LogWarning(
|
||||
@@ -508,6 +514,24 @@ namespace ErsatzTV.Core.Plex
|
||||
{
|
||||
MediaVersion existingVersion = existing.MediaVersions.Head();
|
||||
MediaVersion incomingVersion = incoming.MediaVersions.Head();
|
||||
|
||||
foreach (MediaFile incomingFile in incomingVersion.MediaFiles.HeadOrNone())
|
||||
{
|
||||
foreach (MediaFile existingFile in existingVersion.MediaFiles.HeadOrNone())
|
||||
{
|
||||
if (incomingFile.Path != existingFile.Path)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Plex episode has moved from {OldPath} to {NewPath}",
|
||||
existingFile.Path,
|
||||
incomingFile.Path);
|
||||
|
||||
existingFile.Path = incomingFile.Path;
|
||||
|
||||
await _televisionRepository.UpdatePath(existingFile.Id, incomingFile.Path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (incomingVersion.DateUpdated > existingVersion.DateUpdated || !existingVersion.Streams.Any())
|
||||
{
|
||||
|
||||
@@ -103,9 +103,9 @@ namespace ErsatzTV.Core.Scheduling
|
||||
|
||||
var collectionMediaItems = Map.createRange(tuples);
|
||||
|
||||
// using IDisposable scope = _logger.BeginScope(new { PlayoutId = playout.Id });
|
||||
_logger.LogDebug(
|
||||
$"{(rebuild ? "Rebuilding" : "Building")} playout {{PlayoutId}} for channel {{ChannelNumber}} - {{ChannelName}}",
|
||||
"{Action} playout {PlayoutId} for channel {ChannelNumber} - {ChannelName}",
|
||||
rebuild ? "Rebuilding" : "Building",
|
||||
playout.Id,
|
||||
playout.Channel.Number,
|
||||
playout.Channel.Name);
|
||||
@@ -374,9 +374,9 @@ namespace ErsatzTV.Core.Scheduling
|
||||
}
|
||||
|
||||
bool willNotFinishInTime =
|
||||
currentTime <= durationFinish.IfNone(DateTime.MinValue) &&
|
||||
currentTime <= durationFinish.IfNone(SystemTime.MinValueUtc) &&
|
||||
currentTime + peekVersion.Duration >
|
||||
durationFinish.IfNone(DateTime.MinValue);
|
||||
durationFinish.IfNone(SystemTime.MinValueUtc);
|
||||
if (willNotFinishInTime)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
|
||||
@@ -147,13 +147,7 @@ namespace ErsatzTV.Core.Scheduling
|
||||
ordered.AddRange(larger);
|
||||
}
|
||||
|
||||
int offset = random.Next(ordered.Count);
|
||||
result.Add(
|
||||
new OrderedCollection
|
||||
{
|
||||
Index = 0,
|
||||
Items = ordered.Skip(offset).Concat(ordered.Take(offset)).ToList()
|
||||
});
|
||||
result.Add(new OrderedCollection { Index = 0, Items = ordered });
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
using System;
|
||||
|
||||
namespace ErsatzTV.Core
|
||||
{
|
||||
public static class SystemTime
|
||||
{
|
||||
public static DateTime MinValueUtc = new(0, DateTimeKind.Utc);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace ErsatzTV.Core.Trakt
|
||||
{
|
||||
public class TraktConfiguration
|
||||
{
|
||||
public string ClientId { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using System.Collections.Generic;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Core.Trakt
|
||||
{
|
||||
public record TraktListItemWithGuids(
|
||||
int TraktId,
|
||||
int Rank,
|
||||
string DisplayTitle,
|
||||
string Title,
|
||||
int? Year,
|
||||
int Season,
|
||||
int Episode,
|
||||
TraktListItemKind Kind,
|
||||
List<string> Guids);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Data.Configurations
|
||||
{
|
||||
public class TraktListConfiguration : IEntityTypeConfiguration<TraktList>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<TraktList> builder)
|
||||
{
|
||||
builder.ToTable("TraktList");
|
||||
|
||||
builder.HasMany(l => l.Items)
|
||||
.WithOne(i => i.TraktList)
|
||||
.HasForeignKey(i => i.TraktListId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Data.Configurations
|
||||
{
|
||||
public class TraktListItemConfiguration : IEntityTypeConfiguration<TraktListItem>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<TraktListItem> builder)
|
||||
{
|
||||
builder.ToTable("TraktListItem");
|
||||
|
||||
builder.HasOne(i => i.MediaItem)
|
||||
.WithMany(mi => mi.TraktListItems)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
builder.HasMany(i => i.Guids)
|
||||
.WithOne(g => g.TraktListItem)
|
||||
.HasForeignKey(g => g.TraktListItemId);
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Data.Configurations
|
||||
{
|
||||
public class TraktListItemGuidConfiguration : IEntityTypeConfiguration<TraktListItemGuid>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<TraktListItemGuid> builder) => builder.ToTable("TraktListItemGuid");
|
||||
}
|
||||
}
|
||||
@@ -90,6 +90,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
.ThenInclude(mm => mm.Artwork)
|
||||
.Include(m => m.ShowMetadata)
|
||||
.ThenInclude(mm => mm.Guids)
|
||||
.Include(m => m.TraktListItems)
|
||||
.ThenInclude(tli => tli.TraktList)
|
||||
.Filter(m => m.ItemId == show.ItemId)
|
||||
.OrderBy(m => m.ItemId)
|
||||
.SingleOrDefaultAsync();
|
||||
@@ -267,7 +269,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Unit> Update(EmbySeason season)
|
||||
public async Task<Option<EmbySeason>> Update(EmbySeason season)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
Option<EmbySeason> maybeExisting = await dbContext.EmbySeasons
|
||||
@@ -376,7 +378,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
return Unit.Default;
|
||||
return maybeExisting;
|
||||
}
|
||||
|
||||
public async Task<bool> AddEpisode(EmbySeason season, EmbyEpisode episode)
|
||||
@@ -432,6 +434,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
.Include(m => m.EpisodeMetadata)
|
||||
.ThenInclude(mm => mm.Writers)
|
||||
.Include(m => m.Season)
|
||||
.Include(m => m.TraktListItems)
|
||||
.ThenInclude(tli => tli.TraktList)
|
||||
.Filter(m => m.ItemId == episode.ItemId)
|
||||
.OrderBy(m => m.ItemId)
|
||||
.SingleOrDefaultAsync();
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user