Compare commits

...
Author SHA1 Message Date
Jason DoveandGitHub 60b82876ea mudblazor updates (#390) 2021-09-30 17:40:26 -05:00
Jason DoveandGitHub a99249c375 revert nvenc changes (#389) 2021-09-30 17:35:44 -05:00
Jason Dove 36e6ef4c18 update changelog for release 60 2021-09-25 15:12:47 -05:00
Jason DoveandGitHub 21e53532c1 trakt season bug fixes (#386) 2021-09-25 14:55:42 -05:00
Jason DoveandGitHub a864d53327 add seasons to search index (#385)
* update trakt list items when re-adding existing list

* add seasons to search index
2021-09-25 14:01:35 -05:00
Jason DoveandGitHub e6446f9983 better trakt lists (#384)
* better trakt list support

* update dependencies

* revert unneeded brackets
2021-09-25 09:12:25 -05:00
Jason DoveandGitHub ad40213f90 fix synchronizing trakt lists that contain unreleased movies (#382) 2021-09-21 21:14:27 -05:00
Jason DoveandGitHub 45c6d20fd0 sync trakt list to collection (#381)
* sync trakt list to collection

* move trakt client id
2021-09-20 18:46:03 -05:00
Jason DoveandGitHub 5439db89a7 nvidia fixes (#380)
* nvidia fixes

* fix tests
2021-09-19 21:39:36 -05:00
Jason DoveandGitHub a39231bb5a fix local episode metadata update (#379) 2021-09-19 20:57:12 -05:00
Jason Dove 4c8584b517 try to fix develop versioning 2021-09-18 18:08:19 -05:00
Jason Dove ca8bcacbd3 update changelog for release 59 [no ci] 2021-09-18 14:35:01 -05:00
Jason DoveandGitHub f27286d1dd properly disable transcoding when unchecked in mpeg-ts mode (#378)
* properly disable transcoding in MPEG-TS mode

* update changelog
2021-09-18 14:21:49 -05:00
Jason Dove 23870b75f7 update changelog [no ci] 2021-09-18 14:02:49 -05:00
Jason Dove 7f5a91c643 include libva-x11-2 in vaapi docker image 2021-09-18 13:25:01 -05:00
Jason DoveandGitHub f1f50e883c add vaapi driver setting and health check (#377)
* add vaapi driver option

* add vaapi driver setting and health check
2021-09-18 13:00:36 -05:00
Jason Dove 7506f49f5b remove codeql [no docker] 2021-09-18 11:30:16 -05:00
Jason DoveandGitHub 944f1e4307 add scheduled playout rebuild (#376)
* configure scheduled playout rebuild

* implement scheduled playout rebuild

* remove variable
2021-09-18 11:23:58 -05:00
Jason DoveandGitHub f7de9ac5ea include intel-media-va-driver-non-free in vaapi image (#375)
* include intel-media-va-driver-non-free in vaapi image

* tweak changelog
2021-09-17 21:13:27 -05:00
Jason DoveandGitHub 1eb51ad2f4 add some health checks to home page (#374) 2021-09-17 17:59:59 -05:00
Jason DoveandGitHub c3e0aaf0b7 missing metadata fixes (#373) 2021-09-17 08:53:41 -05:00
150 changed files with 13330 additions and 217 deletions
+1 -1
View File
@@ -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
-71
View File
@@ -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
+1 -1
View File
@@ -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
+45 -2
View File
@@ -5,6 +5,46 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased]
## [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
@@ -594,8 +634,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Initial release to facilitate testing outside of Docker.
[Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.58-alpha...HEAD
[0.0.58-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.57-alpha...v0.0.57-alpha
[Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.61-alpha...HEAD
[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
@@ -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();
}
}
}
+21 -1
View File
@@ -21,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,
@@ -37,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,
@@ -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);
}
@@ -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);
}
}
@@ -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);
@@ -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>
@@ -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
};
@@ -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) =>
@@ -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
}
}
+2
View File
@@ -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");
}
+1 -1
View File
@@ -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; }
}
}
+3 -1
View File
@@ -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; }
}
}
@@ -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 =
+1 -1
View File
@@ -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>
@@ -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;
@@ -152,7 +152,7 @@ namespace ErsatzTV.Core.FFmpeg
};
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
+5 -2
View File
@@ -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)
+12
View File
@@ -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);
}
+11
View File
@@ -0,0 +1,11 @@
namespace ErsatzTV.Core.Health
{
public enum HealthCheckStatus
{
Pass = 0,
Fail = 1,
Warning = 2,
Info = 3,
NotApplicable = 4
}
}
+9
View File
@@ -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);
@@ -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();
@@ -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);
}
}
@@ -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;
}
}
@@ -206,12 +206,12 @@ 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;
@@ -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 =>
{
@@ -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(
+2 -2
View File
@@ -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);
@@ -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);
}
}
}
@@ -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();
@@ -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();
@@ -284,7 +286,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
}
}
public async Task<Unit> Update(JellyfinSeason season)
public async Task<Option<JellyfinSeason>> Update(JellyfinSeason season)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
Option<JellyfinSeason> maybeExisting = await dbContext.JellyfinSeasons
@@ -372,11 +374,12 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
{
metadata.Artwork.Remove(artworkToRemove);
}
await dbContext.SaveChangesAsync();
await dbContext.Entry(existing.LibraryPath).Reference(lp => lp.Library).LoadAsync();
}
await dbContext.SaveChangesAsync();
return Unit.Default;
return maybeExisting;
}
public async Task<bool> AddEpisode(JellyfinSeason season, JellyfinEpisode episode)
@@ -432,6 +435,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();
@@ -114,6 +114,12 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
result.AddRange(await GetShowItemsFromShowId(dbContext, showId));
}
foreach (int seasonId in searchResults.Items.Filter(i => i.Type == SearchIndex.SeasonType)
.Map(i => i.Id))
{
result.AddRange(await GetSeasonItemsFromSeasonId(dbContext, seasonId));
}
foreach (int artistId in searchResults.Items.Filter(i => i.Type == SearchIndex.ArtistType)
.Map(i => i.Id))
{
@@ -365,6 +365,14 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
WHERE l.Id IN @ids)",
new { ids = libraryIds });
List<int> seasonIds = await _dbConnection.QueryAsync<int>(
@"SELECT m.Id FROM MediaItem m
INNER JOIN PlexSeason ps ON ps.Id = m.Id
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId
INNER JOIN Library l ON l.Id = lp.LibraryId
WHERE l.Id IN @ids",
new { ids = libraryIds }).Map(result => result.ToList());
await _dbConnection.ExecuteAsync(
@"DELETE FROM MediaItem WHERE Id IN
(SELECT m.Id FROM MediaItem m
@@ -391,7 +399,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
WHERE l.Id IN @ids)",
new { ids = libraryIds });
return movieIds.Append(showIds).Append(episodeIds).ToList();
return movieIds.Append(showIds).Append(seasonIds).Append(episodeIds).ToList();
}
public Task EnablePlexLibrarySync(IEnumerable<int> libraryIds) =>
@@ -530,6 +538,14 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
WHERE l.Id IN @ids)",
new { ids = libraryIds });
List<int> seasonIds = await _dbConnection.QueryAsync<int>(
@"SELECT m.Id FROM MediaItem m
INNER JOIN JellyfinSeason js ON js.Id = m.Id
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId
INNER JOIN Library l ON l.Id = lp.LibraryId
WHERE l.Id IN @ids",
new { ids = libraryIds }).Map(result => result.ToList());
await _dbConnection.ExecuteAsync(
@"DELETE FROM MediaItem WHERE Id IN
(SELECT m.Id FROM MediaItem m
@@ -556,7 +572,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
WHERE l.Id IN @ids)",
new { ids = libraryIds });
return movieIds.Append(showIds).Append(episodeIds).ToList();
return movieIds.Append(showIds).Append(seasonIds).Append(episodeIds).ToList();
}
public Task<Option<JellyfinLibrary>> GetJellyfinLibrary(int jellyfinLibraryId)
@@ -928,6 +944,14 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
WHERE l.Id IN @ids)",
new { ids = libraryIds });
List<int> seasonIds = await _dbConnection.QueryAsync<int>(
@"SELECT m.Id FROM MediaItem m
INNER JOIN EmbySeason es ON es.Id = m.Id
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId
INNER JOIN Library l ON l.Id = lp.LibraryId
WHERE l.Id IN @ids",
new { ids = libraryIds }).Map(result => result.ToList());
await _dbConnection.ExecuteAsync(
@"DELETE FROM MediaItem WHERE Id IN
(SELECT m.Id FROM MediaItem m
@@ -954,7 +978,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
WHERE l.Id IN @ids)",
new { ids = libraryIds });
return movieIds.Append(showIds).Append(episodeIds).ToList();
return movieIds.Append(showIds).Append(seasonIds).Append(episodeIds).ToList();
}
}
}
@@ -89,6 +89,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(mv => mv.MediaFiles)
.Include(i => i.MediaVersions)
.ThenInclude(mv => mv.Streams)
.Include(i => i.TraktListItems)
.ThenInclude(tli => tli.TraktList)
.OrderBy(i => i.MediaVersions.First().MediaFiles.First().Path)
.SingleOrDefaultAsync(i => i.MediaVersions.First().MediaFiles.First().Path == path);
@@ -129,6 +131,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(mv => mv.Streams)
.Include(i => i.LibraryPath)
.ThenInclude(lp => lp.Library)
.Include(i => i.TraktListItems)
.ThenInclude(tli => tli.TraktList)
.OrderBy(i => i.Key)
.SingleOrDefaultAsync(i => i.Key == item.Key);
@@ -315,6 +319,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(mm => mm.Writers)
.Include(m => m.MovieMetadata)
.ThenInclude(mm => mm.Guids)
.Include(m => m.TraktListItems)
.ThenInclude(tli => tli.TraktList)
.Filter(m => m.ItemId == movie.ItemId)
.OrderBy(m => m.ItemId)
.SingleOrDefaultAsync();
@@ -570,6 +576,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Include(m => m.MovieMetadata)
.ThenInclude(mm => mm.Guids)
.Filter(m => m.ItemId == movie.ItemId)
.Include(m => m.TraktListItems)
.ThenInclude(tli => tli.TraktList)
.OrderBy(m => m.ItemId)
.SingleOrDefaultAsync();
@@ -786,7 +794,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
},
Streams = new List<MediaStream>()
}
}
},
TraktListItems = new List<TraktListItem>()
};
await dbContext.Movies.AddAsync(movie);
await dbContext.SaveChangesAsync();
@@ -47,6 +47,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(mv => mv.MediaFiles)
.Include(mv => mv.MediaVersions)
.ThenInclude(mv => mv.Streams)
.Include(mv => mv.TraktListItems)
.ThenInclude(tli => tli.TraktList)
.OrderBy(i => i.MediaVersions.First().MediaFiles.First().Path)
.SingleOrDefaultAsync(i => i.MediaVersions.First().MediaFiles.First().Path == path);
@@ -188,7 +190,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
},
Streams = new List<MediaStream>()
}
}
},
TraktListItems = new List<TraktListItem>()
};
await dbContext.MusicVideos.AddAsync(musicVideo);
@@ -65,6 +65,16 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Include(mi => (mi as Episode).MediaVersions)
.ThenInclude(em => em.Streams)
.Include(mi => (mi as Episode).Season)
.Include(mi => (mi as Season).SeasonMetadata)
.ThenInclude(sm => sm.Genres)
.Include(mi => (mi as Season).SeasonMetadata)
.ThenInclude(sm => sm.Tags)
.Include(mi => (mi as Season).SeasonMetadata)
.ThenInclude(sm => sm.Studios)
.Include(mi => (mi as Season).SeasonMetadata)
.ThenInclude(sm => sm.Actors)
.Include(mi => (mi as Season).Show)
.ThenInclude(sm => sm.ShowMetadata)
.Include(mi => (mi as Show).ShowMetadata)
.ThenInclude(mm => mm.Genres)
.Include(mi => (mi as Show).ShowMetadata)
@@ -87,6 +97,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(mm => mm.Styles)
.Include(mi => (mi as Artist).ArtistMetadata)
.ThenInclude(mm => mm.Moods)
.Include(mi => mi.TraktListItems)
.ThenInclude(tli => tli.TraktList)
.OrderBy(mi => mi.Id)
.SingleOrDefaultAsync(mi => mi.Id == id)
.Map(Optional);
@@ -102,6 +114,15 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
WHERE MediaStreamKind = 2 AND S.ShowId = @ShowId",
new { ShowId = show.Id }).Map(result => result.ToList());
public Task<List<string>> GetLanguagesForSeason(Season season) =>
_dbConnection.QueryAsync<string>(
@"SELECT DISTINCT Language
FROM MediaStream
INNER JOIN MediaVersion MV ON MediaStream.MediaVersionId = MV.Id
INNER JOIN Episode E ON MV.EpisodeId = E.Id
WHERE MediaStreamKind = 2 AND E.SeasonId = @SeasonId",
new { SeasonId = season.Id }).Map(result => result.ToList());
public Task<List<string>> GetLanguagesForArtist(Artist artist) =>
_dbConnection.QueryAsync<string>(
@"SELECT DISTINCT Language
@@ -31,6 +31,12 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
new { ShowIds = showIds })
.Map(c => c == showIds.Count);
public Task<bool> AllSeasonsExist(List<int> seasonIds) =>
_dbConnection.QuerySingleAsync<int>(
"SELECT COUNT(*) FROM Season WHERE Id in @SeasonIds",
new { SeasonIds = seasonIds })
.Map(c => c == seasonIds.Count);
public Task<bool> AllEpisodesExist(List<int> episodeIds) =>
_dbConnection.QuerySingleAsync<int>(
"SELECT COUNT(*) FROM Episode WHERE Id in @EpisodeIds",
@@ -80,6 +86,23 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ToListAsync();
}
public async Task<List<SeasonMetadata>> GetSeasonsForCards(List<int> ids)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
return await dbContext.SeasonMetadata
.AsNoTracking()
.Filter(s => ids.Contains(s.SeasonId))
.Include(s => s.Season.Show)
.ThenInclude(s => s.ShowMetadata)
.Include(sm => sm.Artwork)
.ToListAsync()
.Map(
list => list
.OrderBy(s => s.Season.Show.ShowMetadata.HeadOrNone().Match(sm => sm.SortTitle, () => string.Empty))
.ThenBy(s => s.Season.SeasonNumber)
.ToList());
}
public async Task<List<EpisodeMetadata>> GetEpisodesForCards(List<int> ids)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
@@ -144,7 +167,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
@"SELECT m1.ShowId
FROM ShowMetadata m1
LEFT OUTER JOIN ShowMetadata m2 ON m2.ShowId = @ShowId
WHERE m1.Title = m2.Title AND m1.Year = m2.Year",
WHERE m1.Title = m2.Title AND m1.Year is m2.Year",
new { ShowId = televisionShowId })
.Map(results => results.ToList());
@@ -221,6 +244,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(sm => sm.Guids)
.Include(s => s.LibraryPath)
.ThenInclude(lp => lp.Library)
.Include(s => s.TraktListItems)
.ThenInclude(tli => tli.TraktList)
.OrderBy(s => s.Id)
.SingleOrDefaultAsync(s => s.Id == id)
.Map(Optional);
@@ -247,7 +272,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
{
LibraryPathId = libraryPathId,
ShowMetadata = new List<ShowMetadata> { metadata },
Seasons = new List<Season>()
Seasons = new List<Season>(),
TraktListItems = new List<TraktListItem>()
};
await dbContext.Shows.AddAsync(show);
@@ -271,6 +297,10 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(sm => sm.Artwork)
.Include(s => s.SeasonMetadata)
.ThenInclude(sm => sm.Guids)
.Include(s => s.LibraryPath)
.ThenInclude(lp => lp.Library)
.Include(s => s.TraktListItems)
.ThenInclude(tli => tli.TraktList)
.OrderBy(s => s.ShowId)
.ThenBy(s => s.SeasonNumber)
.SingleOrDefaultAsync(s => s.ShowId == show.Id && s.SeasonNumber == seasonNumber);
@@ -311,6 +341,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Include(i => i.LibraryPath)
.ThenInclude(lp => lp.Library)
.Include(i => i.Season)
.Include(i => i.TraktListItems)
.ThenInclude(tli => tli.TraktList)
.OrderBy(i => i.MediaVersions.First().MediaFiles.First().Path)
.SingleOrDefaultAsync(i => i.MediaVersions.First().MediaFiles.First().Path == path);
@@ -414,6 +446,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(sm => sm.Guids)
.Include(i => i.LibraryPath)
.ThenInclude(lp => lp.Library)
.Include(i => i.TraktListItems)
.ThenInclude(tli => tli.TraktList)
.OrderBy(i => i.Key)
.SingleOrDefaultAsync(i => i.Key == item.Key);
@@ -432,6 +466,10 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(sm => sm.Artwork)
.Include(i => i.SeasonMetadata)
.ThenInclude(sm => sm.Guids)
.Include(s => s.LibraryPath)
.ThenInclude(l => l.Library)
.Include(s => s.TraktListItems)
.ThenInclude(tli => tli.TraktList)
.OrderBy(i => i.Key)
.SingleOrDefaultAsync(i => i.Key == item.Key);
@@ -469,6 +507,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Include(i => i.LibraryPath)
.ThenInclude(lp => lp.Library)
.Include(e => e.Season)
.Include(e => e.TraktListItems)
.ThenInclude(tli => tli.TraktList)
.OrderBy(i => i.Key)
.SingleOrDefaultAsync(i => i.Key == item.Key);
@@ -669,10 +709,15 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
DateAdded = DateTime.UtcNow,
Guids = new List<MetadataGuid>()
}
}
},
TraktListItems = new List<TraktListItem>()
};
await dbContext.Seasons.AddAsync(season);
await dbContext.SaveChangesAsync();
await dbContext.Entry(season).Reference(s => s.LibraryPath).LoadAsync();
await dbContext.Entry(season.LibraryPath).Reference(lp => lp.Library).LoadAsync();
return season;
}
catch (Exception ex)
@@ -724,7 +769,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
},
Streams = new List<MediaStream>()
}
}
},
TraktListItems = new List<TraktListItem>()
};
await dbContext.Episodes.AddAsync(episode);
await dbContext.SaveChangesAsync();
@@ -772,6 +818,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
await dbContext.PlexSeasons.AddAsync(item);
await dbContext.SaveChangesAsync();
await dbContext.Entry(item).Reference(i => i.LibraryPath).LoadAsync();
await dbContext.Entry(item.LibraryPath).Reference(lp => lp.Library).LoadAsync();
return item;
}
catch (Exception ex)
@@ -42,6 +42,7 @@ namespace ErsatzTV.Infrastructure.Data
public DbSet<Show> Shows { get; set; }
public DbSet<ShowMetadata> ShowMetadata { get; set; }
public DbSet<Season> Seasons { get; set; }
public DbSet<SeasonMetadata> SeasonMetadata { get; set; }
public DbSet<Episode> Episodes { get; set; }
public DbSet<EpisodeMetadata> EpisodeMetadata { get; set; }
public DbSet<PlexMovie> PlexMovies { get; set; }
@@ -68,6 +69,7 @@ namespace ErsatzTV.Infrastructure.Data
public DbSet<FFmpegProfile> FFmpegProfiles { get; set; }
public DbSet<Resolution> Resolutions { get; set; }
public DbSet<LanguageCode> LanguageCodes { get; set; }
public DbSet<TraktList> TraktLists { get; set; }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) =>
optionsBuilder.UseLoggerFactory(_loggerFactory);
@@ -211,7 +211,8 @@ namespace ErsatzTV.Infrastructure.Emby
ItemId = item.Id,
Etag = item.Etag,
MediaVersions = new List<MediaVersion> { version },
MovieMetadata = new List<MovieMetadata> { metadata }
MovieMetadata = new List<MovieMetadata> { metadata },
TraktListItems = new List<TraktListItem>()
};
return movie;
@@ -335,7 +336,8 @@ namespace ErsatzTV.Infrastructure.Emby
{
ItemId = item.Id,
Etag = item.Etag,
ShowMetadata = new List<ShowMetadata> { metadata }
ShowMetadata = new List<ShowMetadata> { metadata },
TraktListItems = new List<TraktListItem>()
};
return show;
@@ -461,7 +463,8 @@ namespace ErsatzTV.Infrastructure.Emby
{
ItemId = item.Id,
Etag = item.Etag,
SeasonMetadata = new List<SeasonMetadata> { metadata }
SeasonMetadata = new List<SeasonMetadata> { metadata },
TraktListItems = new List<TraktListItem>()
};
if (item.IndexNumber.HasValue)
@@ -509,7 +512,8 @@ namespace ErsatzTV.Infrastructure.Emby
ItemId = item.Id,
Etag = item.Etag,
MediaVersions = new List<MediaVersion> { version },
EpisodeMetadata = new List<EpisodeMetadata> { metadata }
EpisodeMetadata = new List<EpisodeMetadata> { metadata },
TraktListItems = new List<TraktListItem>()
};
return episode;
@@ -22,12 +22,13 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="5.0.10" />
<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>
<PackageReference Include="Refit" Version="6.0.94" />
<PackageReference Include="SixLabors.ImageSharp" Version="1.0.3" />
<PackageReference Include="Refit.Newtonsoft.Json" Version="6.0.94" />
<PackageReference Include="SixLabors.ImageSharp" Version="1.0.4" />
</ItemGroup>
<ItemGroup>
@@ -0,0 +1,55 @@
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading.Tasks;
using ErsatzTV.Core.Health;
using LanguageExt;
using Lucene.Net.Util;
namespace ErsatzTV.Infrastructure.Health.Checks
{
public abstract class BaseHealthCheck
{
protected abstract string Title { get; }
protected HealthCheckResult Result(HealthCheckStatus status, string message) =>
new(Title, status, message);
protected HealthCheckResult NotApplicableResult() =>
new(Title, HealthCheckStatus.NotApplicable, string.Empty);
protected HealthCheckResult OkResult() =>
new(Title, HealthCheckStatus.Pass, string.Empty);
protected HealthCheckResult FailResult(string message) =>
new(Title, HealthCheckStatus.Fail, message);
protected HealthCheckResult WarningResult(string message) =>
new(Title, HealthCheckStatus.Warning, message);
protected HealthCheckResult InfoResult(string message) =>
new(Title, HealthCheckStatus.Info, message);
protected static async Task<string> GetProcessOutput(string path, IEnumerable<string> arguments)
{
var startInfo = new ProcessStartInfo
{
FileName = path,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false
};
startInfo.ArgumentList.AddRange(arguments);
var process = new Process
{
StartInfo = startInfo
};
process.Start();
string result = await process.StandardOutput.ReadToEndAsync();
await process.WaitForExitAsync();
return result;
}
}
}
@@ -0,0 +1,51 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Health;
using ErsatzTV.Core.Health.Checks;
using ErsatzTV.Infrastructure.Data;
using LanguageExt;
using Microsoft.EntityFrameworkCore;
using static LanguageExt.Prelude;
namespace ErsatzTV.Infrastructure.Health.Checks
{
public class EpisodeMetadataHealthCheck : BaseHealthCheck, IEpisodeMetadataHealthCheck
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public EpisodeMetadataHealthCheck(IDbContextFactory<TvContext> dbContextFactory) =>
_dbContextFactory = dbContextFactory;
protected override string Title => "Episode Metadata";
public async Task<HealthCheckResult> Check()
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
List<Episode> episodes = await dbContext.Episodes
.Filter(e => e.EpisodeMetadata.Count == 0)
.Include(e => e.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.ToListAsync();
if (episodes.Any())
{
var paths = episodes.SelectMany(e => e.MediaVersions.Map(mv => mv.MediaFiles))
.Flatten()
.Bind(f => Optional<string>(Path.GetDirectoryName(f.Path)))
.Distinct()
.Take(5)
.ToList();
var folders = string.Join(", ", paths);
return WarningResult($"There are {episodes.Count} episodes with missing metadata, including in the following folders: {folders}");
}
return OkResult();
}
}
}
@@ -0,0 +1,37 @@
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Health;
using ErsatzTV.Core.Health.Checks;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
namespace ErsatzTV.Infrastructure.Health.Checks
{
public class FFmpegReportsHealthCheck : BaseHealthCheck, IFFmpegReportsHealthCheck
{
private readonly IConfigElementRepository _configElementRepository;
public FFmpegReportsHealthCheck(IConfigElementRepository configElementRepository) =>
_configElementRepository = configElementRepository;
public async Task<HealthCheckResult> Check()
{
Option<bool> saveReports =
await _configElementRepository.GetValue<bool>(ConfigElementKey.FFmpegSaveReports);
foreach (bool value in saveReports)
{
if (value)
{
return Result(
HealthCheckStatus.Warning,
"FFmpeg troubleshooting reports are enabled and may use a lot of disk space");
}
}
return OkResult();
}
protected override string Title => "FFmpeg Reports";
}
}
@@ -0,0 +1,110 @@
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Health;
using ErsatzTV.Core.Health.Checks;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using static LanguageExt.Prelude;
namespace ErsatzTV.Infrastructure.Health.Checks
{
public class FFmpegVersionHealthCheck : BaseHealthCheck, IFFmpegVersionHealthCheck
{
private readonly IConfigElementRepository _configElementRepository;
public FFmpegVersionHealthCheck(IConfigElementRepository configElementRepository)
{
_configElementRepository = configElementRepository;
}
public async Task<HealthCheckResult> Check()
{
Option<ConfigElement> maybeFFmpegPath = await _configElementRepository.Get(ConfigElementKey.FFmpegPath);
if (maybeFFmpegPath.IsNone)
{
return FailResult("Unable to locate ffmpeg");
}
Option<ConfigElement> maybeFFprobePath = await _configElementRepository.Get(ConfigElementKey.FFprobePath);
if (maybeFFprobePath.IsNone)
{
return FailResult("Unable to locate ffprobe");
}
foreach (ConfigElement ffmpegPath in maybeFFmpegPath)
{
Option<string> maybeVersion = await GetVersion(ffmpegPath.Value);
if (maybeVersion.IsNone)
{
return WarningResult("Unable to determine ffmpeg version");
}
foreach (string version in maybeVersion)
{
foreach (HealthCheckResult result in ValidateVersion(version, "ffmpeg"))
{
return result;
}
}
}
foreach (ConfigElement ffprobePath in maybeFFprobePath)
{
Option<string> maybeVersion = await GetVersion(ffprobePath.Value);
if (maybeVersion.IsNone)
{
return WarningResult("Unable to determine ffprobe version");
}
foreach (string version in maybeVersion)
{
foreach (HealthCheckResult result in ValidateVersion(version, "ffprobe"))
{
return result;
}
}
}
return new HealthCheckResult("FFmpeg Version", HealthCheckStatus.Pass, string.Empty);
}
private Option<HealthCheckResult> ValidateVersion(string version, string app)
{
if (version.StartsWith("3."))
{
return FailResult($"{app} version {version} is too old; please install 4.3!");
}
if (version.StartsWith("4.4"))
{
return FailResult($"{app} version 4.4 is known to have issues; please install 4.3!");
}
if (!version.StartsWith("4.3"))
{
return WarningResult($"{app} version {version} is unexpected and may have problems; please install 4.3!");
}
return None;
}
private static async Task<Option<string>> GetVersion(string path)
{
Option<string> maybeLine = await GetProcessOutput(path, new[] { "-version" })
.Map(s => s.Split("\n").HeadOrNone().Map(h => h.Trim()));
foreach (string line in maybeLine)
{
const string PATTERN = @"version\s+([^\s]+)";
Match match = Regex.Match(line, PATTERN);
if (match.Success)
{
return match.Groups[1].Value;
}
}
return None;
}
protected override string Title => "FFmpeg Version";
}
}
@@ -0,0 +1,130 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Health;
using ErsatzTV.Core.Health.Checks;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Data;
using LanguageExt;
using LanguageExt.UnsafeValueAccess;
using Microsoft.EntityFrameworkCore;
using static LanguageExt.Prelude;
namespace ErsatzTV.Infrastructure.Health.Checks
{
public class HardwareAccelerationHealthCheck : BaseHealthCheck, IHardwareAccelerationHealthCheck
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly IConfigElementRepository _configElementRepository;
public HardwareAccelerationHealthCheck(
IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository)
{
_dbContextFactory = dbContextFactory;
_configElementRepository = configElementRepository;
}
public async Task<HealthCheckResult> Check()
{
Option<ConfigElement> maybeFFmpegPath = await _configElementRepository.Get(ConfigElementKey.FFmpegPath);
if (maybeFFmpegPath.IsNone)
{
return FailResult("Unable to locate ffmpeg");
}
string version = Assembly.GetEntryAssembly()?.GetCustomAttribute<AssemblyInformationalVersionAttribute>()
?.InformationalVersion ?? "unknown";
var accelerationKinds = new List<HardwareAccelerationKind>();
if (version.Contains("docker", StringComparison.OrdinalIgnoreCase))
{
if (version.Contains("nvidia", StringComparison.OrdinalIgnoreCase))
{
accelerationKinds.Add(HardwareAccelerationKind.Nvenc);
}
else if (version.Contains("vaapi", StringComparison.OrdinalIgnoreCase))
{
accelerationKinds.Add(HardwareAccelerationKind.Vaapi);
}
}
if (!accelerationKinds.Any())
{
accelerationKinds.AddRange(await GetSupportedAccelerationKinds(maybeFFmpegPath.ValueUnsafe().Value));
}
if (!accelerationKinds.Any())
{
return InfoResult("No compatible hardware acceleration kinds are supported by ffmpeg");
}
Option<HealthCheckResult> maybeResult = await VerifyProfilesUseAcceleration(accelerationKinds);
foreach (HealthCheckResult result in maybeResult)
{
return result;
}
return OkResult();
}
private async Task<Option<HealthCheckResult>> VerifyProfilesUseAcceleration(
IEnumerable<HardwareAccelerationKind> accelerationKinds)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
List<Channel> badChannels = await dbContext.Channels
.Filter(c => c.StreamingMode != StreamingMode.HttpLiveStreamingDirect)
.Filter(c => !accelerationKinds.Contains(c.FFmpegProfile.HardwareAcceleration))
.ToListAsync();
if (badChannels.Any())
{
var accel = string.Join(", ", accelerationKinds);
var channels = string.Join(", ", badChannels.Map(c => $"{c.Number} - {c.Name}"));
return WarningResult(
$"The following channels are transcoding without hardware acceleration ({accel}): {channels}");
}
return None;
}
private static async Task<List<HardwareAccelerationKind>> GetSupportedAccelerationKinds(string ffmpegPath)
{
var result = new System.Collections.Generic.HashSet<HardwareAccelerationKind>();
string output = await GetProcessOutput(ffmpegPath, new[] { "-v", "quiet", "-hwaccels" });
foreach (string method in output.Split("\n").Map(s => s.Trim()).Skip(1))
{
switch (method)
{
case "vaapi":
result.Add(HardwareAccelerationKind.Vaapi);
break;
case "nvenc":
result.Add(HardwareAccelerationKind.Nvenc);
break;
case "cuda":
result.Add(HardwareAccelerationKind.Nvenc);
break;
case "qsv":
// qsv is only supported on windows
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
result.Add(HardwareAccelerationKind.Qsv);
}
break;
}
}
return result.ToList();
}
protected override string Title => "Hardware Acceleration";
}
}
@@ -0,0 +1,51 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Health;
using ErsatzTV.Core.Health.Checks;
using ErsatzTV.Infrastructure.Data;
using LanguageExt;
using Microsoft.EntityFrameworkCore;
using static LanguageExt.Prelude;
namespace ErsatzTV.Infrastructure.Health.Checks
{
public class MovieMetadataHealthCheck : BaseHealthCheck, IMovieMetadataHealthCheck
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public MovieMetadataHealthCheck(IDbContextFactory<TvContext> dbContextFactory) =>
_dbContextFactory = dbContextFactory;
protected override string Title => "Movie Metadata";
public async Task<HealthCheckResult> Check()
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
List<Movie> movies = await dbContext.Movies
.Filter(e => e.MovieMetadata.Count == 0)
.Include(e => e.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.ToListAsync();
if (movies.Any())
{
var paths = movies.SelectMany(e => e.MediaVersions.Map(mv => mv.MediaFiles))
.Flatten()
.Bind(f => Optional<string>(Path.GetDirectoryName(f.Path)))
.Distinct()
.Take(5)
.ToList();
var folders = string.Join(", ", paths);
return WarningResult($"There are {movies.Count} movies with missing metadata, including in the following folders: {folders}");
}
return OkResult();
}
}
}

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