Compare commits

...
Author SHA1 Message Date
Jason Dove 67a6f554d0 rename v0.0.63-alpha v0.1.0-alpha [no ci] 2021-10-08 18:44:49 -05:00
Jason Dove 609df217ae update changelog for release 63 [no ci] 2021-10-08 18:39:03 -05:00
Jason DoveandGitHub d3086264c7 unraid doc fixes (#405) 2021-10-08 18:31:22 -05:00
Jason DoveandGitHub 8cd9b23787 fix transcode folder preparation (#404) 2021-10-08 15:29:19 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
dc5c9e42ff Bump Serilog.Settings.Configuration from 3.2.0 to 3.3.0 (#403)
Bumps [Serilog.Settings.Configuration](https://github.com/serilog/serilog-settings-configuration) from 3.2.0 to 3.3.0.
- [Release notes](https://github.com/serilog/serilog-settings-configuration/releases)
- [Changelog](https://github.com/serilog/serilog-settings-configuration/blob/dev/CHANGES.md)
- [Commits](https://github.com/serilog/serilog-settings-configuration/commits)

---
updated-dependencies:
- dependency-name: Serilog.Settings.Configuration
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-10-08 09:09:57 -05:00
Jason DoveandGitHub 2dd267e4db fix xmltv generation with missing episode metadata (#402) 2021-10-07 22:31:46 -05:00
Jason DoveandGitHub b069a21473 allow hls segmenter to run before framerate is known (#401) 2021-10-07 22:07:54 -05:00
Jason DoveandGitHub 6c8813ce22 add hls segmenter streaming mode (#400)
* hls segmenter wip

* log message

* close unused transcode sessions after 2 minutes

* use frame rate for 2s keyframes in hls segmenter

* add frame rate to media version

* fix segmenter framerate calculation

* automatically restart hls segmenter with next scheduled item

* cleanup

* update changelog

* decrease segmenter start delay
2021-10-07 21:42:29 -05:00
Jason DoveandGitHub b5de5e2b7f fix statistic updates (#399) 2021-10-07 19:56:41 -05:00
Jason DoveandGitHub 4b7da4e468 speed up builds by using base images (#398) 2021-10-06 06:06:13 -05:00
Jason DoveandGitHub ae8e795228 vaapi downsample 10bit hevc 8bit h264 (#397)
* vaapi downsample 10bit hevc to 8bit h264

* update changelog
2021-10-05 22:16:57 -05:00
Jason DoveandGitHub 334781485d use latest intel driver in vaapi docker images (#396)
* compile latest iHD driver

* cleanup to reduce image size

* update changelog
2021-10-05 20:24:26 -05:00
Jason Dove 27fefa1b38 update changelog for release 62 [no ci] 2021-10-05 15:28:45 -05:00
Jason DoveandGitHub fc3175591e use libx264 for all errors (#395) 2021-10-05 15:13:35 -05:00
Jason DoveandGitHub 3363d2c9d7 update plex paths when they are changed (#394) 2021-10-05 06:27:05 -05:00
Jason DoveandGitHub 1d5217fa84 support imdb ids from plex (#393) 2021-10-05 06:01:53 -05:00
Jason DoveandGitHub 904cdb8780 vaapi improvements (#392)
* add transcoding tests

* dont use full paths for transcoding tests

* add error details

* use 1-second videos for transcoding tests

* vaapi fixes

* include format in scale_vaapi

* more vaapi fixes

* unsupported errors

* fix unsupported checks

* maybe not failure?

* fix formatting

* ignore nvdec warnings

* update changelog

* fix tests
2021-10-02 13:42:44 -05:00
Jason Dove 85fee64565 update changelog [no ci] 2021-10-01 13:44:04 -05:00
Jason DoveandGitHub 13cfb9728f include season zero episode-num in xmltv (#391) 2021-10-01 13:42:46 -05:00
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
164 changed files with 13430 additions and 215 deletions
+1 -1
View File
@@ -49,7 +49,7 @@ jobs:
tag=$(git describe --tags --abbrev=0) tag=$(git describe --tags --abbrev=0)
tag2="${tag:1}" tag2="${tag:1}"
short=$(git rev-parse --short HEAD) short=$(git rev-parse --short HEAD)
final="${tag2/prealpha/$short}" final="${tag2/alpha/$short}"
echo "GIT_TAG=${final}" >> $GITHUB_ENV echo "GIT_TAG=${final}" >> $GITHUB_ENV
- name: Set up Docker Buildx Base - name: Set up Docker Buildx Base
+1 -1
View File
@@ -82,7 +82,7 @@ jobs:
run: | run: |
tag=$(git describe --tags --abbrev=0) tag=$(git describe --tags --abbrev=0)
echo "GIT_TAG=${tag:1}" >> $GITHUB_ENV 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 - name: Set up Docker Buildx Base
uses: docker/setup-buildx-action@v1 uses: docker/setup-buildx-action@v1
+51 -1
View File
@@ -5,6 +5,52 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased] ## [Unreleased]
## [0.1.0-alpha] - 2021-10-08
### Added
- Add *experimental* streaming mode `HLS Segmenter` (most similar to `HLS Hybrid`)
- This mode is intended to increase client compatibility and reduce issues at program boundaries
- If you want the temporary transcode files to be located on a particular drive, the docker path is `/root/.local/share/etv-transcode`
- Store frame rate with media statistics; this is needed to support HLS Segmenter
- This requires re-ingesting statistics for all media items the first time this version is launched
### Changed
- Use latest iHD driver (21.2.3 vs 20.1.1) in vaapi docker images
### Fixed
- Add downsampling to support transcoding 10-bit HEVC content with the h264_vaapi encoder
- Fix updating statistics when media items are replaced
- Fix XMLTV generation when scheduled episode is missing metadata
## [0.0.62-alpha] - 2021-10-05
### Added
- Support IMDB ids from Plex libraries, which may improve Trakt matching for some items
### Fixed
- Include Specials/Season 0 `episode-num` entry in XMLTV
- Fix some transcoding edge cases with VAAPI and pixel formats `yuv420p10le`, `yuv444p10le` and `yuv444p`
- Update Plex movie and episode paths when they are changed within Plex
- Always use `libx264` software encoder for error messages
## [0.0.61-alpha] - 2021-09-30
### Fixed
- Revert nvenc/cuda filter change from v60
## [0.0.60-alpha] - 2021-09-25
### Added
- Add Trakt list support under `Lists` > `Trakt Lists`
- Trakt lists can be added by url or by `user/list`
- To re-download a Trakt list, simply add it again (no need to delete)
- See `Logs` for unmatched item details
- Trakt lists can only be scheduled by using Smart Collections
- Add seasons to search index
- This is needed because Trakt lists can contain seasons
- This requires rebuilding the search index and search results may be empty or incomplete until the rebuild is complete
### Fixed
- Fix local television scanner to properly update episode metadata when NFO files have been added/changed
- Properly detect ffmpeg nvenc (cuda) support in Hardware Acceleration health check
- Fix nvenc/cuda filter for some yuv420p content
## [0.0.59-alpha] - 2021-09-18 ## [0.0.59-alpha] - 2021-09-18
### Added ### Added
- Add `Health Checks` table to home page to identify and surface common misconfigurations - Add `Health Checks` table to home page to identify and surface common misconfigurations
@@ -614,7 +660,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Initial release to facilitate testing outside of Docker. - Initial release to facilitate testing outside of Docker.
[Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.59-alpha...HEAD [Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.1.0-alpha...HEAD
[0.1.0-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.62-alpha...v0.1.0-alpha
[0.0.62-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.61-alpha...v0.0.62-alpha
[0.0.61-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.60-alpha...v0.0.61-alpha
[0.0.60-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.59-alpha...v0.0.60-alpha
[0.0.59-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.58-alpha...v0.0.59-alpha [0.0.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.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.57-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.56-alpha...v0.0.57-alpha
@@ -28,6 +28,10 @@ namespace ErsatzTV.Application.Channels.Queries
{ {
switch (mode.ToLowerInvariant()) switch (mode.ToLowerInvariant())
{ {
case "segmenter":
channel.StreamingMode = StreamingMode.HttpLiveStreamingSegmenter;
result.Add(channel);
break;
case "hls-direct": case "hls-direct":
channel.StreamingMode = StreamingMode.HttpLiveStreamingDirect; channel.StreamingMode = StreamingMode.HttpLiveStreamingDirect;
result.Add(channel); result.Add(channel);
@@ -13,7 +13,7 @@
</PackageReference> </PackageReference>
<PackageReference Include="MediatR" Version="9.0.0" /> <PackageReference Include="MediatR" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="5.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> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
@@ -0,0 +1,6 @@
namespace ErsatzTV.Application
{
public interface IFFmpegWorkerRequest
{
}
}
+20
View File
@@ -37,6 +37,26 @@ namespace ErsatzTV.Application.MediaCards
.IfNone(string.Empty), .IfNone(string.Empty),
season.SeasonNumber == 0 ? "S" : season.SeasonNumber.ToString()); 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( internal static TelevisionEpisodeCardViewModel ProjectToViewModel(
EpisodeMetadata episodeMetadata, EpisodeMetadata episodeMetadata,
Option<JellyfinMediaSource> maybeJellyfin, Option<JellyfinMediaSource> maybeJellyfin,
@@ -7,6 +7,7 @@ using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt; using LanguageExt;
using MediatR; using MediatR;
using static ErsatzTV.Application.MediaCards.Mapper; using static ErsatzTV.Application.MediaCards.Mapper;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.MediaCards.Queries namespace ErsatzTV.Application.MediaCards.Queries
{ {
@@ -41,7 +42,7 @@ namespace ErsatzTV.Application.MediaCards.Queries
.GetPagedSeasons(request.TelevisionShowId, request.PageNumber, request.PageSize) .GetPagedSeasons(request.TelevisionShowId, request.PageNumber, request.PageSize)
.Map(list => list.Map(s => ProjectToViewModel(s, maybeJellyfin, maybeEmby)).ToList()); .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 System.Collections.Generic;
using ErsatzTV.Core.Search;
using LanguageExt;
namespace ErsatzTV.Application.MediaCards 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, int CollectionId,
List<int> MovieIds, List<int> MovieIds,
List<int> ShowIds, List<int> ShowIds,
List<int> SeasonIds,
List<int> EpisodeIds, List<int> EpisodeIds,
List<int> ArtistIds, List<int> ArtistIds,
List<int> MusicVideoIds) : MediatR.IRequest<Either<BaseError, Unit>>; List<int> MusicVideoIds) : MediatR.IRequest<Either<BaseError, Unit>>;
@@ -52,6 +52,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands
{ {
var allItems = request.MovieIds var allItems = request.MovieIds
.Append(request.ShowIds) .Append(request.ShowIds)
.Append(request.SeasonIds)
.Append(request.EpisodeIds) .Append(request.EpisodeIds)
.Append(request.ArtistIds) .Append(request.ArtistIds)
.Append(request.MusicVideoIds) .Append(request.MusicVideoIds)
@@ -77,12 +78,15 @@ namespace ErsatzTV.Application.MediaCollections.Commands
return Unit.Default; 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 CollectionMustExist(dbContext, request),
await ValidateMovies(request), await ValidateMovies(request),
await ValidateShows(request), await ValidateShows(request),
await ValidateSeasons(request),
await ValidateEpisodes(request)) await ValidateEpisodes(request))
.Apply((collection, _, _, _) => collection); .Apply((collection, _, _, _, _) => collection);
private static Task<Validation<BaseError, Collection>> CollectionMustExist( private static Task<Validation<BaseError, Collection>> CollectionMustExist(
TvContext dbContext, TvContext dbContext,
@@ -106,6 +110,13 @@ namespace ErsatzTV.Application.MediaCollections.Commands
.MapT(_ => Unit.Default) .MapT(_ => Unit.Default)
.Map(v => v.ToValidation<BaseError>("Show does not exist")); .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) => private Task<Validation<BaseError, Unit>> ValidateEpisodes(AddItemsToCollection request) =>
_televisionRepository.AllEpisodesExist(request.EpisodeIds) _televisionRepository.AllEpisodesExist(request.EpisodeIds)
.Map(Optional) .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 => new MultiCollection
{ {
Name = name, Name = name,
MultiCollectionItems = request.Items.Map( MultiCollectionItems = request.Items.Bind(
i => i =>
{ {
if (i.CollectionId.HasValue) if (i.CollectionId.HasValue)
@@ -70,12 +70,10 @@ namespace ErsatzTV.Application.MediaCollections.Commands
}); });
} }
return None; return Option<MultiCollectionItem>.None;
}) })
.Sequence()
.Flatten()
.ToList(), .ToList(),
MultiCollectionSmartItems = request.Items.Map( MultiCollectionSmartItems = request.Items.Bind(
i => i =>
{ {
if (i.SmartCollectionId.HasValue) if (i.SmartCollectionId.HasValue)
@@ -89,10 +87,8 @@ namespace ErsatzTV.Application.MediaCollections.Commands
}); });
} }
return None; return Option<MultiCollectionSmartItem>.None;
}) })
.Sequence()
.Flatten()
.ToList() .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) => internal static SmartCollectionViewModel ProjectToViewModel(SmartCollection collection) =>
new(collection.Id, collection.Name, collection.Query); 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) => private static MultiCollectionItemViewModel ProjectToViewModel(MultiCollectionItem multiCollectionItem) =>
new( new(
multiCollectionItem.MultiCollectionId, 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);
}
@@ -21,6 +21,7 @@ namespace ErsatzTV.Application.Search.Queries
new( new(
await GetIds("movie", request.Query), await GetIds("movie", request.Query),
await GetIds("show", request.Query), await GetIds("show", request.Query),
await GetIds("season", request.Query),
await GetIds("episode", request.Query), await GetIds("episode", request.Query),
await GetIds("artist", request.Query), await GetIds("artist", request.Query),
await GetIds("music_video", 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( public record SearchResultAllItemsViewModel(
List<int> MovieIds, List<int> MovieIds,
List<int> ShowIds, List<int> ShowIds,
List<int> SeasonIds,
List<int> EpisodeIds, List<int> EpisodeIds,
List<int> ArtistIds, List<int> ArtistIds,
List<int> MusicVideoIds); List<int> MusicVideoIds);
@@ -0,0 +1,9 @@
using ErsatzTV.Core;
using LanguageExt;
using MediatR;
using Unit = LanguageExt.Unit;
namespace ErsatzTV.Application.Streaming.Commands
{
public record CleanUpFFmpegSessions : IRequest<Either<BaseError, Unit>>, IFFmpegWorkerRequest;
}
@@ -0,0 +1,27 @@
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using ErsatzTV.Core;
using LanguageExt;
using MediatR;
using Unit = LanguageExt.Unit;
namespace ErsatzTV.Application.Streaming.Commands
{
public class CleanUpFFmpegSessionsHandler : IRequestHandler<CleanUpFFmpegSessions, Either<BaseError, Unit>>
{
private readonly ChannelWriter<IFFmpegWorkerRequest> _channel;
public CleanUpFFmpegSessionsHandler(ChannelWriter<IFFmpegWorkerRequest> channel)
{
_channel = channel;
}
public async Task<Either<BaseError, Unit>>
Handle(CleanUpFFmpegSessions request, CancellationToken cancellationToken)
{
await _channel.WriteAsync(request, cancellationToken);
return Unit.Default;
}
}
}
@@ -0,0 +1,9 @@
using ErsatzTV.Core;
using LanguageExt;
namespace ErsatzTV.Application.Streaming.Commands
{
public record StartFFmpegSession(string ChannelNumber, bool StartAtZero) :
MediatR.IRequest<Either<BaseError, Unit>>,
IFFmpegWorkerRequest;
}
@@ -0,0 +1,75 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Metadata;
using LanguageExt;
using Microsoft.Extensions.Logging;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.Streaming.Commands
{
public class StartFFmpegSessionHandler : MediatR.IRequestHandler<StartFFmpegSession, Either<BaseError, Unit>>
{
private readonly ChannelWriter<IFFmpegWorkerRequest> _channel;
private readonly ILogger<StartFFmpegSessionHandler> _logger;
private readonly IFFmpegSegmenterService _ffmpegSegmenterService;
private readonly ILocalFileSystem _localFileSystem;
public StartFFmpegSessionHandler(
IFFmpegSegmenterService ffmpegSegmenterService,
ILocalFileSystem localFileSystem,
ChannelWriter<IFFmpegWorkerRequest> channel,
ILogger<StartFFmpegSessionHandler> logger)
{
_ffmpegSegmenterService = ffmpegSegmenterService;
_localFileSystem = localFileSystem;
_channel = channel;
_logger = logger;
}
public Task<Either<BaseError, Unit>> Handle(StartFFmpegSession request, CancellationToken cancellationToken) =>
Validate(request)
.MapT(_ => StartProcess(request))
// this weirdness is needed to maintain the error type (.ToEitherAsync() just gives BaseError)
#pragma warning disable VSTHRD103
.Bind(v => v.ToEither().MapLeft(seq => seq.Head()).MapAsync<BaseError, Task<Unit>, Unit>(identity));
#pragma warning restore VSTHRD103
private async Task<Unit> StartProcess(StartFFmpegSession request)
{
await _channel.WriteAsync(request);
// TODO: find some other way to let ffmpeg get ahead
await Task.Delay(TimeSpan.FromSeconds(5));
return Unit.Default;
}
private Task<Validation<BaseError, Unit>> Validate(StartFFmpegSession request) =>
ProcessMustNotExist(request)
.BindT(_ => FolderMustBeEmpty(request));
private Task<Validation<BaseError, Unit>> ProcessMustNotExist(StartFFmpegSession request) =>
Optional(_ffmpegSegmenterService.ProcessExistsForChannel(request.ChannelNumber))
.Filter(exists => exists == false)
.Map(_ => Unit.Default)
.ToValidation<BaseError>(new ChannelHasProcess())
.AsTask();
private Task<Validation<BaseError, Unit>> FolderMustBeEmpty(StartFFmpegSession request)
{
string folder = Path.Combine(FileSystemLayout.TranscodeFolder, request.ChannelNumber);
_logger.LogDebug("Preparing transcode folder {Folder}", folder);
_localFileSystem.EnsureFolderExists(folder);
_localFileSystem.EmptyFolder(folder);
return Task.FromResult<Validation<BaseError, Unit>>(Unit.Default);
}
}
}
@@ -0,0 +1,9 @@
using ErsatzTV.Core;
using LanguageExt;
using MediatR;
using Unit = LanguageExt.Unit;
namespace ErsatzTV.Application.Streaming.Commands
{
public record TouchFFmpegSession(string Path) : IRequest<Either<BaseError, Unit>>, IFFmpegWorkerRequest;
}
@@ -56,6 +56,7 @@ namespace ErsatzTV.Application.Streaming.Queries
channel.StreamingMode = request.Mode.ToLowerInvariant() switch channel.StreamingMode = request.Mode.ToLowerInvariant() switch
{ {
"hls-direct" => StreamingMode.HttpLiveStreamingDirect, "hls-direct" => StreamingMode.HttpLiveStreamingDirect,
"segmenter" => StreamingMode.HttpLiveStreamingSegmenter,
"ts" => StreamingMode.TransportStream, "ts" => StreamingMode.TransportStream,
_ => channel.StreamingMode _ => channel.StreamingMode
}; };
@@ -5,5 +5,6 @@ using MediatR;
namespace ErsatzTV.Application.Streaming.Queries namespace ErsatzTV.Application.Streaming.Queries
{ {
public record FFmpegProcessRequest(string ChannelNumber, string Mode) : IRequest<Either<BaseError, Process>>; public record FFmpegProcessRequest
(string ChannelNumber, string Mode, bool StartAtZero) : IRequest<Either<BaseError, Process>>;
} }
@@ -4,7 +4,8 @@
{ {
public GetConcatProcessByChannelNumber(string scheme, string host, string channelNumber) : base( public GetConcatProcessByChannelNumber(string scheme, string host, string channelNumber) : base(
channelNumber, channelNumber,
"ts") "ts",
false)
{ {
Scheme = scheme; Scheme = scheme;
Host = host; Host = host;
@@ -2,7 +2,10 @@
{ {
public record GetPlayoutItemProcessByChannelNumber : FFmpegProcessRequest public record GetPlayoutItemProcessByChannelNumber : FFmpegProcessRequest
{ {
public GetPlayoutItemProcessByChannelNumber(string channelNumber, string mode) : base(channelNumber, mode) public GetPlayoutItemProcessByChannelNumber(string channelNumber, string mode, bool startAtZero) : base(
channelNumber,
mode,
startAtZero)
{ {
} }
} }
@@ -50,7 +50,7 @@ namespace ErsatzTV.Application.Streaming.Queries
protected override async Task<Either<BaseError, Process>> GetProcess( protected override async Task<Either<BaseError, Process>> GetProcess(
TvContext dbContext, TvContext dbContext,
GetPlayoutItemProcessByChannelNumber _, GetPlayoutItemProcessByChannelNumber request,
Channel channel, Channel channel,
string ffmpegPath) string ffmpegPath)
{ {
@@ -111,7 +111,7 @@ namespace ErsatzTV.Application.Streaming.Queries
version, version,
playoutItemWithPath.Path, playoutItemWithPath.Path,
playoutItemWithPath.PlayoutItem.StartOffset, playoutItemWithPath.PlayoutItem.StartOffset,
now, request.StartAtZero ? playoutItemWithPath.PlayoutItem.StartOffset : now,
maybeGlobalWatermark, maybeGlobalWatermark,
maybeVaapiDriver)); maybeVaapiDriver));
}, },
@@ -14,7 +14,7 @@
<PackageReference Include="LanguageExt.Core" Version="3.4.15" /> <PackageReference Include="LanguageExt.Core" Version="3.4.15" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="5.0.2" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="5.0.2" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.11.0" /> <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> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
@@ -437,7 +437,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
true, true,
true, true,
false, false,
"[0:0]deinterlace_vaapi,scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,hwupload[v]", "[0:0]deinterlace_vaapi,scale_vaapi=format=nv12:w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,hwupload[v]",
"[v]")] "[v]")]
[TestCase( [TestCase(
"h264", "h264",
@@ -451,14 +451,14 @@ namespace ErsatzTV.Core.Tests.FFmpeg
true, true,
true, true,
true, true,
"[0:0]deinterlace_vaapi,scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]", "[0:0]deinterlace_vaapi,scale_vaapi=format=nv12:w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[v]")] "[v]")]
[TestCase( [TestCase(
"h264", "h264",
false, false,
true, true,
false, false,
"[0:0]scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,hwupload[v]", "[0:0]scale_vaapi=format=nv12:w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,hwupload[v]",
"[v]")] "[v]")]
[TestCase( [TestCase(
"h264", "h264",
@@ -472,7 +472,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
false, false,
true, true,
true, true,
"[0:0]scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]", "[0:0]scale_vaapi=format=nv12:w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[v]")] "[v]")]
[TestCase("mpeg4", true, false, false, "[0:0]hwupload,deinterlace_vaapi[v]", "[v]")] [TestCase("mpeg4", true, false, false, "[0:0]hwupload,deinterlace_vaapi[v]", "[v]")]
[TestCase( [TestCase(
@@ -480,7 +480,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
true, true,
true, true,
false, false,
"[0:0]hwupload,deinterlace_vaapi,scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,hwupload[v]", "[0:0]hwupload,deinterlace_vaapi,scale_vaapi=format=nv12:w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,hwupload[v]",
"[v]")] "[v]")]
[TestCase( [TestCase(
"mpeg4", "mpeg4",
@@ -494,14 +494,14 @@ namespace ErsatzTV.Core.Tests.FFmpeg
true, true,
true, true,
true, true,
"[0:0]hwupload,deinterlace_vaapi,scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]", "[0:0]hwupload,deinterlace_vaapi,scale_vaapi=format=nv12:w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[v]")] "[v]")]
[TestCase( [TestCase(
"mpeg4", "mpeg4",
false, false,
true, true,
false, false,
"[0:0]hwupload,scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,hwupload[v]", "[0:0]hwupload,scale_vaapi=format=nv12:w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,hwupload[v]",
"[v]")] "[v]")]
[TestCase( [TestCase(
"mpeg4", "mpeg4",
@@ -515,7 +515,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
false, false,
true, true,
true, true,
"[0:0]hwupload,scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]", "[0:0]hwupload,scale_vaapi=format=nv12:w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[v]")] "[v]")]
public void Should_Return_VAAPI_Video_Filter( public void Should_Return_VAAPI_Video_Filter(
string codec, string codec,
@@ -0,0 +1,243 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Metadata;
using FluentAssertions;
using LanguageExt;
using Microsoft.Extensions.Logging;
using Moq;
using NUnit.Framework;
using static LanguageExt.Prelude;
namespace ErsatzTV.Core.Tests.FFmpeg
{
[TestFixture]
[Explicit]
public class TranscodingTests
{
[Test]
[Explicit]
public void DeleteTestVideos()
{
foreach (string file in Directory.GetFiles(TestContext.CurrentContext.TestDirectory, "*.mkv"))
{
File.Delete(file);
}
Assert.Pass();
}
private class TestData
{
public static string[] InputCodecs =
{
"h264",
"mpeg2video",
"hevc",
"mpeg4"
};
public static string[] InputPixelFormats =
{
"yuv420p",
"yuv420p10le",
"yuvj420p",
"yuv444p",
"yuv444p10le"
};
public static Resolution[] Resolutions =
{
new() { Width = 1920, Height = 1080 },
new() { Width = 1280, Height = 720 }
};
public static string[] SoftwareCodecs =
{
"libx264",
"libx265"
};
public static HardwareAccelerationKind[] NoAcceleration =
{
HardwareAccelerationKind.None
};
public static string[] NvidiaCodecs =
{
"h264_nvenc",
"hevc_nvenc"
};
public static HardwareAccelerationKind[] NvidiaAcceleration =
{
HardwareAccelerationKind.Nvenc
};
public static string[] VaapiCodecs =
{
"h264_vaapi",
"hevc_vaapi"
};
public static HardwareAccelerationKind[] VaapiAcceleration =
{
HardwareAccelerationKind.Vaapi
};
}
[Test, Combinatorial]
public async Task Transcode(
[ValueSource(typeof(TestData), nameof(TestData.InputCodecs))]
string inputCodec,
[ValueSource(typeof(TestData), nameof(TestData.InputPixelFormats))]
string inputPixelFormat,
[ValueSource(typeof(TestData), nameof(TestData.Resolutions))]
Resolution profileResolution,
// [ValueSource(typeof(TestData), nameof(TestData.SoftwareCodecs))] string profileCodec,
// [ValueSource(typeof(TestData), nameof(TestData.NoAcceleration))] HardwareAccelerationKind profileAcceleration)
[ValueSource(typeof(TestData), nameof(TestData.NvidiaCodecs))] string profileCodec,
[ValueSource(typeof(TestData), nameof(TestData.NvidiaAcceleration))] HardwareAccelerationKind profileAcceleration)
// [ValueSource(typeof(TestData), nameof(TestData.VaapiCodecs))] string profileCodec,
// [ValueSource(typeof(TestData), nameof(TestData.VaapiAcceleration))] HardwareAccelerationKind profileAcceleration)
{
string name = GetStringSha256Hash(
$"{inputCodec}_{inputPixelFormat}_{profileResolution}_{profileCodec}_{profileAcceleration}");
string file = Path.Combine(TestContext.CurrentContext.TestDirectory, $"{name}.mkv");
if (!File.Exists(file))
{
var args =
$"-y -f lavfi -i anullsrc=channel_layout=stereo:sample_rate=44100 -f lavfi -i testsrc=duration=1:size=1920x1080:rate=30 -c:a aac -c:v {inputCodec} -shortest -pix_fmt {inputPixelFormat} -strict -2 {file}";
var p1 = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = "ffmpeg",
Arguments = args
}
};
p1.Start();
await p1.WaitForExitAsync();
p1.ExitCode.Should().Be(0);
}
var service = new FFmpegProcessService(
new FFmpegPlaybackSettingsCalculator(),
new FakeStreamSelector(),
new Mock<IImageCache>().Object,
new Mock<ILogger<FFmpegProcessService>>().Object);
MediaVersion v = new MediaVersion();
var metadataRepository = new Mock<IMetadataRepository>();
metadataRepository
.Setup(r => r.UpdateLocalStatistics(It.IsAny<int>(), It.IsAny<MediaVersion>(), It.IsAny<bool>()))
.Callback<int, MediaVersion, bool>((_, version, _) => v = version);
var localStatisticsProvider = new LocalStatisticsProvider(
metadataRepository.Object,
new LocalFileSystem(new Mock<ILogger<LocalFileSystem>>().Object),
new Mock<ILogger<LocalStatisticsProvider>>().Object);
await localStatisticsProvider.RefreshStatistics(
"ffprobe",
new Movie
{
MediaVersions = new List<MediaVersion>
{
new()
{
MediaFiles = new List<MediaFile>
{
new() { Path = file }
}
}
}
});
DateTimeOffset now = DateTimeOffset.Now;
Process process = await service.ForPlayoutItem(
"ffmpeg",
false,
new Channel(Guid.NewGuid())
{
FFmpegProfile = FFmpegProfile.New("test", profileResolution) with
{
HardwareAcceleration = profileAcceleration,
VideoCodec = profileCodec
},
StreamingMode = StreamingMode.TransportStream
},
v,
file,
now,
now,
None,
None);
process.StartInfo.RedirectStandardError = true;
process.Start().Should().BeTrue();
await process.StandardOutput.ReadToEndAsync();
string error = await process.StandardError.ReadToEndAsync();
await process.WaitForExitAsync();
string[] unsupportedMessages =
{
"No support for codec",
"No usable",
"Provided device doesn't support"
};
if (profileAcceleration != HardwareAccelerationKind.None && unsupportedMessages.Any(error.Contains))
{
IEnumerable<string> quotedArgs = process.StartInfo.ArgumentList.Map(a => $"\'{a}\'");
process.ExitCode.Should().Be(1, $"Error message with successful exit code? {string.Join(" ", quotedArgs)}");
Assert.Warn("Unsupported on this hardware");
}
else if (error.Contains("Impossible to convert between"))
{
IEnumerable<string> quotedArgs = process.StartInfo.ArgumentList.Map(a => $"\'{a}\'");
Assert.Fail($"Transcode failure: ffmpeg {string.Join(" ", quotedArgs)}");
}
else
{
process.ExitCode.Should().Be(0, error);
}
}
private static string GetStringSha256Hash(string text)
{
if (string.IsNullOrEmpty(text))
{
return string.Empty;
}
using var sha = new System.Security.Cryptography.SHA256Managed();
byte[] textData = System.Text.Encoding.UTF8.GetBytes(text);
byte[] hash = sha.ComputeHash(textData);
return BitConverter.ToString(hash).Replace("-", string.Empty);
}
private class FakeStreamSelector : IFFmpegStreamSelector
{
public Task<MediaStream> SelectVideoStream(Channel channel, MediaVersion version) =>
version.Streams.First(s => s.MediaStreamKind == MediaStreamKind.Video).AsTask();
public Task<Option<MediaStream>> SelectAudioStream(Channel channel, MediaVersion version) =>
Optional(version.Streams.First(s => s.MediaStreamKind == MediaStreamKind.Audio)).AsTask();
}
}
}
@@ -59,6 +59,8 @@ namespace ErsatzTV.Core.Tests.Fakes
public Task<Either<BaseError, Unit>> CopyFile(string source, string destination) => public Task<Either<BaseError, Unit>> CopyFile(string source, string destination) =>
Task.FromResult(Right<BaseError, Unit>(Unit.Default)); Task.FromResult(Right<BaseError, Unit>(Unit.Default));
public Unit EmptyFolder(string folder) => Unit.Default;
private static List<DirectoryInfo> Split(DirectoryInfo path) private static List<DirectoryInfo> Split(DirectoryInfo path)
{ {
var result = new List<DirectoryInfo>(); var result = new List<DirectoryInfo>();
@@ -11,6 +11,8 @@ namespace ErsatzTV.Core.Tests.Fakes
public class FakeTelevisionRepository : ITelevisionRepository public class FakeTelevisionRepository : ITelevisionRepository
{ {
public Task<bool> AllShowsExist(List<int> showIds) => throw new NotSupportedException(); 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<bool> AllEpisodesExist(List<int> episodeIds) => throw new NotSupportedException();
public Task<List<Show>> GetAllShows() => 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<Option<Show>> GetShow(int showId) => throw new NotSupportedException();
public Task<List<ShowMetadata>> GetShowsForCards(List<int> ids) => 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<EpisodeMetadata>> GetEpisodesForCards(List<int> ids) => throw new NotSupportedException();
public Task<List<Episode>> GetShowItems(int showId) => 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<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<int> GetEpisodeCount(int seasonId) => throw new NotSupportedException();
public Task<List<EpisodeMetadata>> GetPagedEpisodes(int seasonId, int pageNumber, int pageSize) => public Task<List<EpisodeMetadata>> GetPagedEpisodes(int seasonId, int pageNumber, int pageSize) =>
@@ -95,5 +97,6 @@ namespace ErsatzTV.Core.Tests.Fakes
public Task<bool> AddDirector(EpisodeMetadata metadata, Director director) => throw new NotSupportedException(); public Task<bool> AddDirector(EpisodeMetadata metadata, Director director) => throw new NotSupportedException();
public Task<bool> AddWriter(EpisodeMetadata metadata, Writer writer) => throw new NotSupportedException(); public Task<bool> AddWriter(EpisodeMetadata metadata, Writer writer) => throw new NotSupportedException();
public Task<Unit> UpdatePath(int mediaFileId, string path) => throw new NotSupportedException();
} }
} }
@@ -0,0 +1,16 @@
using System.Collections.Generic;
namespace ErsatzTV.Core.Domain
{
public class TraktList
{
public int Id { get; set; }
public int TraktId { get; set; }
public string User { get; set; }
public string List { get; set; }
public string Name { get; set; }
public string Description { get; set; }
public int ItemCount { get; set; }
public List<TraktListItem> Items { get; set; }
}
}
@@ -0,0 +1,31 @@
using System.Collections.Generic;
namespace ErsatzTV.Core.Domain
{
public class TraktListItem
{
public int Id { get; set; }
public int TraktListId { get; set; }
public TraktList TraktList { get; set; }
public TraktListItemKind Kind { get; set; }
public int TraktId { get; set; }
public int Rank { get; set; }
public string Title { get; set; }
public int? Year { get; set; }
public int? Season { get; set; }
public int? Episode { get; set; }
public List<TraktListItemGuid> Guids { get; set; }
public int? MediaItemId { get; set; }
public MediaItem MediaItem { get; set; }
public string DisplayTitle => Kind switch
{
TraktListItemKind.Movie => $"{Title} ({Year})",
TraktListItemKind.Show => $"{Title} ({Year})",
TraktListItemKind.Season => $"{Title} ({Year}) S{Season:00}",
_ => $"{Title} ({Year}) S{Season:00}E{Episode:00}"
};
}
}
@@ -0,0 +1,10 @@
namespace ErsatzTV.Core.Domain
{
public class TraktListItemGuid
{
public int Id { get; set; }
public string Guid { get; set; }
public int TraktListItemId { get; set; }
public TraktListItem TraktListItem { get; set; }
}
}
@@ -0,0 +1,10 @@
namespace ErsatzTV.Core.Domain
{
public enum TraktListItemKind
{
Movie,
Show,
Season,
Episode
}
}
+1
View File
@@ -25,6 +25,7 @@
public static ConfigElementKey PlayoutsPageSize => new("pages.playouts.page_size"); public static ConfigElementKey PlayoutsPageSize => new("pages.playouts.page_size");
public static ConfigElementKey PlayoutsDetailPageSize => new("pages.playouts.detail_page_size"); public static ConfigElementKey PlayoutsDetailPageSize => new("pages.playouts.detail_page_size");
public static ConfigElementKey LogsPageSize => new("pages.logs.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 LibraryRefreshInterval => new("scanner.library_refresh_interval");
public static ConfigElementKey PlayoutDaysToBuild => new("playout.days_to_build"); public static ConfigElementKey PlayoutDaysToBuild => new("playout.days_to_build");
} }
@@ -9,5 +9,6 @@ namespace ErsatzTV.Core.Domain
public LibraryPath LibraryPath { get; set; } public LibraryPath LibraryPath { get; set; }
public List<Collection> Collections { get; set; } public List<Collection> Collections { get; set; }
public List<CollectionItem> CollectionItems { get; set; } public List<CollectionItem> CollectionItems { get; set; }
public List<TraktListItem> TraktListItems { get; set; }
} }
} }
@@ -13,6 +13,7 @@ namespace ErsatzTV.Core.Domain
public TimeSpan Duration { get; set; } public TimeSpan Duration { get; set; }
public string SampleAspectRatio { get; set; } public string SampleAspectRatio { get; set; }
public string DisplayAspectRatio { get; set; } public string DisplayAspectRatio { get; set; }
public string RFrameRate { get; set; }
public VideoScanKind VideoScanKind { get; set; } public VideoScanKind VideoScanKind { get; set; }
public DateTime DateAdded { get; set; } public DateTime DateAdded { get; set; }
public DateTime DateUpdated { get; set; } public DateTime DateUpdated { get; set; }
+2
View File
@@ -8,5 +8,7 @@ namespace ErsatzTV.Core.Domain
public string Name { get; set; } public string Name { get; set; }
public int Height { get; set; } public int Height { get; set; }
public int Width { get; set; } public int Width { get; set; }
public override string ToString() => $"{Width}x{Height}";
} }
} }
+2 -1
View File
@@ -4,6 +4,7 @@
{ {
TransportStream = 1, TransportStream = 1,
HttpLiveStreamingDirect = 2, HttpLiveStreamingDirect = 2,
HttpLiveStreamingHybrid = 3 HttpLiveStreamingHybrid = 3,
HttpLiveStreamingSegmenter = 4
} }
} }
@@ -96,6 +96,8 @@ namespace ErsatzTV.Core.Emby
foreach (EmbyMovie incoming in validMovies) foreach (EmbyMovie incoming in validMovies)
{ {
EmbyMovie incomingMovie = incoming;
decimal percentCompletion = (decimal) validMovies.IndexOf(incoming) / validMovies.Count; decimal percentCompletion = (decimal) validMovies.IndexOf(incoming) / validMovies.Count;
await _mediator.Publish(new LibraryScanProgress(library.Id, percentCompletion)); await _mediator.Publish(new LibraryScanProgress(library.Id, percentCompletion));
@@ -122,12 +124,14 @@ namespace ErsatzTV.Core.Emby
updateStatistics = true; updateStatistics = true;
incoming.LibraryPathId = library.Paths.Head().Id; incoming.LibraryPathId = library.Paths.Head().Id;
Option<EmbyMovie> updated = await _movieRepository.UpdateEmby(incoming); Option<EmbyMovie> maybeUpdated = await _movieRepository.UpdateEmby(incoming);
if (updated.IsSome) foreach (EmbyMovie updated in maybeUpdated)
{ {
await _searchIndex.UpdateItems( await _searchIndex.UpdateItems(
_searchRepository, _searchRepository,
new List<MediaItem> { updated.ValueUnsafe() }); new List<MediaItem> { updated });
incomingMovie = updated;
} }
} }
catch (Exception ex) catch (Exception ex)
@@ -174,12 +178,12 @@ namespace ErsatzTV.Core.Emby
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", localPath); _logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", localPath);
Either<BaseError, bool> refreshResult = Either<BaseError, bool> refreshResult =
await _localStatisticsProvider.RefreshStatistics(ffprobePath, incoming, localPath); await _localStatisticsProvider.RefreshStatistics(ffprobePath, incomingMovie, localPath);
await refreshResult.Match( await refreshResult.Match(
async _ => async _ =>
{ {
Option<MediaItem> updated = await _searchRepository.GetItemToIndex(incoming.Id); Option<MediaItem> updated = await _searchRepository.GetItemToIndex(incomingMovie.Id);
if (updated.IsSome) if (updated.IsSome)
{ {
await _searchIndex.UpdateItems( await _searchIndex.UpdateItems(
@@ -227,7 +227,11 @@ namespace ErsatzTV.Core.Emby
incoming.ShowId = show.Id; incoming.ShowId = show.Id;
incoming.LibraryPathId = library.Paths.Head().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 () => async () =>
{ {
@@ -238,7 +242,11 @@ namespace ErsatzTV.Core.Emby
show.ShowMetadata.Head().Title, show.ShowMetadata.Head().Title,
incoming.SeasonMetadata.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 = List<EmbyItemEtag> existingEpisodes =
@@ -318,6 +326,7 @@ namespace ErsatzTV.Core.Emby
{ {
foreach (EmbyEpisode incoming in episodes) foreach (EmbyEpisode incoming in episodes)
{ {
EmbyEpisode incomingEpisode = incoming;
var updateStatistics = false; var updateStatistics = false;
Option<EmbyItemEtag> maybeExisting = existingEpisodes.Find(ie => ie.ItemId == incoming.ItemId); Option<EmbyItemEtag> maybeExisting = existingEpisodes.Find(ie => ie.ItemId == incoming.ItemId);
@@ -341,12 +350,14 @@ namespace ErsatzTV.Core.Emby
incoming.SeasonId = season.Id; incoming.SeasonId = season.Id;
incoming.LibraryPathId = library.Paths.Head().Id; incoming.LibraryPathId = library.Paths.Head().Id;
Option<EmbyEpisode> updated = await _televisionRepository.Update(incoming); Option<EmbyEpisode> maybeUpdated = await _televisionRepository.Update(incoming);
if (updated.IsSome) foreach (EmbyEpisode updated in maybeUpdated)
{ {
await _searchIndex.UpdateItems( await _searchIndex.UpdateItems(
_searchRepository, _searchRepository,
new List<MediaItem> { updated.ValueUnsafe() }); new List<MediaItem> { updated });
incomingEpisode = updated;
} }
} }
catch (Exception ex) catch (Exception ex)
@@ -395,7 +406,7 @@ namespace ErsatzTV.Core.Emby
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", localPath); _logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", localPath);
Either<BaseError, bool> refreshResult = Either<BaseError, bool> refreshResult =
await _localStatisticsProvider.RefreshStatistics(ffprobePath, incoming, localPath); await _localStatisticsProvider.RefreshStatistics(ffprobePath, incomingEpisode, localPath);
refreshResult.Match( refreshResult.Match(
_ => { }, _ => { },
@@ -0,0 +1,9 @@
namespace ErsatzTV.Core.Errors
{
public class ChannelHasProcess : BaseError
{
public ChannelHasProcess() : base("Channel already has ffmpeg process")
{
}
}
}
+1 -1
View File
@@ -16,7 +16,7 @@
<PackageReference Include="MediatR" Version="9.0.0" /> <PackageReference Include="MediatR" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="5.0.0" /> <PackageReference Include="Microsoft.Extensions.Http" Version="5.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" 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> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
@@ -21,6 +21,7 @@ namespace ErsatzTV.Core.FFmpeg
private Option<IDisplaySize> _scaleToSize = None; private Option<IDisplaySize> _scaleToSize = None;
private Option<ChannelWatermark> _watermark; private Option<ChannelWatermark> _watermark;
private string _pixelFormat; private string _pixelFormat;
private string _videoEncoder;
public FFmpegComplexFilterBuilder WithHardwareAcceleration(HardwareAccelerationKind hardwareAccelerationKind) public FFmpegComplexFilterBuilder WithHardwareAcceleration(HardwareAccelerationKind hardwareAccelerationKind)
{ {
@@ -77,6 +78,12 @@ namespace ErsatzTV.Core.FFmpeg
return this; return this;
} }
public FFmpegComplexFilterBuilder WithVideoEncoder(string videoEncoder)
{
_videoEncoder = videoEncoder;
return this;
}
public Option<FFmpegComplexFilter> Build(int videoStreamIndex, Option<int> audioStreamIndex) public Option<FFmpegComplexFilter> Build(int videoStreamIndex, Option<int> audioStreamIndex)
{ {
var complexFilter = new StringBuilder(); var complexFilter = new StringBuilder();
@@ -129,6 +136,20 @@ namespace ErsatzTV.Core.FFmpeg
} }
} }
string[] h264hevc = { "h264", "hevc" };
if (acceleration == HardwareAccelerationKind.Vaapi && (_pixelFormat ?? string.Empty).EndsWith("p10le") &&
h264hevc.Contains(_inputCodec)
&& (_pixelFormat != "yuv420p10le" || _inputCodec != "hevc"))
{
videoFilterQueue.Add("format=p010le,format=nv12|vaapi,hwupload");
}
if (acceleration == HardwareAccelerationKind.Vaapi && _pixelFormat == "yuv444p" && h264hevc.Contains(_inputCodec))
{
videoFilterQueue.Add("format=nv12|vaapi,hwupload");
}
_scaleToSize.IfSome( _scaleToSize.IfSome(
size => size =>
{ {
@@ -138,7 +159,7 @@ namespace ErsatzTV.Core.FFmpeg
HardwareAccelerationKind.Nvenc when _pixelFormat == "yuv420p10le" => HardwareAccelerationKind.Nvenc when _pixelFormat == "yuv420p10le" =>
$"hwdownload,format=p010le,format=nv12,hwupload,scale_npp={size.Width}:{size.Height}", $"hwdownload,format=p010le,format=nv12,hwupload,scale_npp={size.Width}:{size.Height}",
HardwareAccelerationKind.Nvenc => $"scale_npp={size.Width}:{size.Height}", HardwareAccelerationKind.Nvenc => $"scale_npp={size.Width}:{size.Height}",
HardwareAccelerationKind.Vaapi => $"scale_vaapi=w={size.Width}:h={size.Height}", HardwareAccelerationKind.Vaapi => $"scale_vaapi=format=nv12:w={size.Width}:h={size.Height}",
_ => $"scale={size.Width}:{size.Height}:flags=fast_bilinear" _ => $"scale={size.Width}:{size.Height}:flags=fast_bilinear"
}; };
@@ -233,6 +254,13 @@ namespace ErsatzTV.Core.FFmpeg
complexFilter.Append(audioLabel); complexFilter.Append(audioLabel);
} }
// vaapi downsample 10bit hevc to 8bit h264
if (acceleration == HardwareAccelerationKind.Vaapi && !videoFilterQueue.Any() &&
_pixelFormat == "yuv420p10le" && _videoEncoder.StartsWith("h264"))
{
videoFilterQueue.Add("scale_vaapi=format=nv12");
}
if (videoFilterQueue.Any() || !string.IsNullOrWhiteSpace(watermarkOverlay)) if (videoFilterQueue.Any() || !string.IsNullOrWhiteSpace(watermarkOverlay))
{ {
if (hasAudioFilters) if (hasAudioFilters)
@@ -70,6 +70,7 @@ namespace ErsatzTV.Core.FFmpeg
result.Deinterlace = false; result.Deinterlace = false;
break; break;
case StreamingMode.HttpLiveStreamingHybrid: case StreamingMode.HttpLiveStreamingHybrid:
case StreamingMode.HttpLiveStreamingSegmenter:
case StreamingMode.TransportStream: case StreamingMode.TransportStream:
result.HardwareAcceleration = ffmpegProfile.HardwareAcceleration; result.HardwareAcceleration = ffmpegProfile.HardwareAcceleration;
@@ -147,8 +148,8 @@ namespace ErsatzTV.Core.FFmpeg
{ {
ThreadCount = ffmpegProfile.ThreadCount, ThreadCount = ffmpegProfile.ThreadCount,
FormatFlags = CommonFormatFlags, FormatFlags = CommonFormatFlags,
VideoCodec = ffmpegProfile.VideoCodec, VideoCodec = "libx264",
AudioCodec = ffmpegProfile.AudioCodec AudioCodec = ffmpegProfile.AudioCodec,
}; };
private static bool NeedToScale(FFmpegProfile ffmpegProfile, MediaVersion version) => private static bool NeedToScale(FFmpegProfile ffmpegProfile, MediaVersion version) =>
+45 -3
View File
@@ -26,6 +26,7 @@ using System.Text;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.FFmpeg; using ErsatzTV.Core.Interfaces.FFmpeg;
using LanguageExt; using LanguageExt;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Core.FFmpeg namespace ErsatzTV.Core.FFmpeg
{ {
@@ -41,15 +42,17 @@ namespace ErsatzTV.Core.FFmpeg
private readonly List<string> _arguments = new(); private readonly List<string> _arguments = new();
private readonly string _ffmpegPath; private readonly string _ffmpegPath;
private readonly bool _saveReports; private readonly bool _saveReports;
private readonly ILogger _logger;
private FFmpegComplexFilterBuilder _complexFilterBuilder = new(); private FFmpegComplexFilterBuilder _complexFilterBuilder = new();
private bool _isConcat; private bool _isConcat;
private VaapiDriver _vaapiDriver; private VaapiDriver _vaapiDriver;
private HardwareAccelerationKind _hwAccel; private HardwareAccelerationKind _hwAccel;
public FFmpegProcessBuilder(string ffmpegPath, bool saveReports) public FFmpegProcessBuilder(string ffmpegPath, bool saveReports, ILogger logger)
{ {
_ffmpegPath = ffmpegPath; _ffmpegPath = ffmpegPath;
_saveReports = saveReports; _saveReports = saveReports;
_logger = logger;
} }
public FFmpegProcessBuilder WithVaapiDriver(Option<VaapiDriver> maybeVaapiDriver) public FFmpegProcessBuilder WithVaapiDriver(Option<VaapiDriver> maybeVaapiDriver)
@@ -307,13 +310,47 @@ namespace ErsatzTV.Core.FFmpeg
return this; return this;
} }
public FFmpegProcessBuilder WithHls(string channelNumber, MediaVersion mediaVersion)
{
if (!int.TryParse(mediaVersion.RFrameRate, out int frameRate))
{
string[] split = (mediaVersion.RFrameRate ?? string.Empty).Split("/");
if (int.TryParse(split[0], out int left) && int.TryParse(split[1], out int right))
{
frameRate = (int)Math.Round(left / (double)right);
}
else
{
_logger.LogInformation("Unable to detect framerate, using {FrameRate}", 24);
frameRate = 24;
}
}
_arguments.AddRange(
new[]
{
"-g", $"{frameRate * 2}",
"-keyint_min", $"{frameRate * 2}",
// "-force_key_frames",
// "expr:gte(t,n_forced*2)",
"-f", "hls",
"-hls_time", "2",
"-hls_list_size", "10",
"-segment_list_flags", "+live",
"-hls_flags", "delete_segments+program_date_time+append_list+discont_start+omit_endlist",
Path.Combine(FileSystemLayout.TranscodeFolder, channelNumber, "live.m3u8")
});
return this;
}
public FFmpegProcessBuilder WithPlaybackArgs(FFmpegPlaybackSettings playbackSettings) public FFmpegProcessBuilder WithPlaybackArgs(FFmpegPlaybackSettings playbackSettings)
{ {
var arguments = new List<string> var arguments = new List<string>
{ {
"-c:v", playbackSettings.VideoCodec, "-c:v", playbackSettings.VideoCodec,
"-flags", "cgop", "-flags", "cgop",
"-sc_threshold", "1000000000" "-sc_threshold", "0" // disable scene change detection
}; };
string[] videoBitrateArgs = playbackSettings.VideoBitrate.Match( string[] videoBitrateArgs = playbackSettings.VideoBitrate.Match(
@@ -402,8 +439,13 @@ namespace ErsatzTV.Core.FFmpeg
return this; return this;
} }
public FFmpegProcessBuilder WithFilterComplex(MediaStream videoStream, Option<MediaStream> maybeAudioStream) public FFmpegProcessBuilder WithFilterComplex(
MediaStream videoStream,
Option<MediaStream> maybeAudioStream,
string videoCodec)
{ {
_complexFilterBuilder = _complexFilterBuilder.WithVideoEncoder(videoCodec);
int videoStreamIndex = videoStream.Index; int videoStreamIndex = videoStream.Index;
Option<int> maybeIndex = maybeAudioStream.Map(ms => ms.Index); Option<int> maybeIndex = maybeAudioStream.Map(ms => ms.Index);
+24 -11
View File
@@ -6,6 +6,7 @@ using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.FFmpeg; using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Images; using ErsatzTV.Core.Interfaces.Images;
using LanguageExt; using LanguageExt;
using Microsoft.Extensions.Logging;
using static LanguageExt.Prelude; using static LanguageExt.Prelude;
namespace ErsatzTV.Core.FFmpeg namespace ErsatzTV.Core.FFmpeg
@@ -14,16 +15,19 @@ namespace ErsatzTV.Core.FFmpeg
{ {
private readonly IFFmpegStreamSelector _ffmpegStreamSelector; private readonly IFFmpegStreamSelector _ffmpegStreamSelector;
private readonly IImageCache _imageCache; private readonly IImageCache _imageCache;
private readonly ILogger<FFmpegProcessService> _logger;
private readonly FFmpegPlaybackSettingsCalculator _playbackSettingsCalculator; private readonly FFmpegPlaybackSettingsCalculator _playbackSettingsCalculator;
public FFmpegProcessService( public FFmpegProcessService(
FFmpegPlaybackSettingsCalculator ffmpegPlaybackSettingsService, FFmpegPlaybackSettingsCalculator ffmpegPlaybackSettingsService,
IFFmpegStreamSelector ffmpegStreamSelector, IFFmpegStreamSelector ffmpegStreamSelector,
IImageCache imageCache) IImageCache imageCache,
ILogger<FFmpegProcessService> logger)
{ {
_playbackSettingsCalculator = ffmpegPlaybackSettingsService; _playbackSettingsCalculator = ffmpegPlaybackSettingsService;
_ffmpegStreamSelector = ffmpegStreamSelector; _ffmpegStreamSelector = ffmpegStreamSelector;
_imageCache = imageCache; _imageCache = imageCache;
_logger = logger;
} }
public async Task<Process> ForPlayoutItem( public async Task<Process> ForPlayoutItem(
@@ -56,7 +60,7 @@ namespace ErsatzTV.Core.FFmpeg
p => _imageCache.IsAnimated(p), p => _imageCache.IsAnimated(p),
() => Task.FromResult(false)); () => Task.FromResult(false));
FFmpegProcessBuilder builder = new FFmpegProcessBuilder(ffmpegPath, saveReports) FFmpegProcessBuilder builder = new FFmpegProcessBuilder(ffmpegPath, saveReports, _logger)
.WithThreads(playbackSettings.ThreadCount) .WithThreads(playbackSettings.ThreadCount)
.WithHardwareAcceleration(playbackSettings.HardwareAcceleration) .WithHardwareAcceleration(playbackSettings.HardwareAcceleration)
.WithVaapiDriver(maybeVaapiDriver) .WithVaapiDriver(maybeVaapiDriver)
@@ -82,7 +86,7 @@ namespace ErsatzTV.Core.FFmpeg
} }
builder = builder builder = builder
.WithFilterComplex(videoStream, maybeAudioStream); .WithFilterComplex(videoStream, maybeAudioStream, channel.FFmpegProfile.VideoCodec);
}, },
() => () =>
{ {
@@ -91,28 +95,37 @@ namespace ErsatzTV.Core.FFmpeg
builder = builder builder = builder
.WithDeinterlace(playbackSettings.Deinterlace) .WithDeinterlace(playbackSettings.Deinterlace)
.WithBlackBars(channel.FFmpegProfile.Resolution) .WithBlackBars(channel.FFmpegProfile.Resolution)
.WithFilterComplex(videoStream, maybeAudioStream); .WithFilterComplex(videoStream, maybeAudioStream, channel.FFmpegProfile.VideoCodec);
} }
else if (playbackSettings.Deinterlace) else if (playbackSettings.Deinterlace)
{ {
builder = builder.WithDeinterlace(playbackSettings.Deinterlace) builder = builder.WithDeinterlace(playbackSettings.Deinterlace)
.WithAlignedAudio(playbackSettings.AudioDuration) .WithAlignedAudio(playbackSettings.AudioDuration)
.WithFilterComplex(videoStream, maybeAudioStream); .WithFilterComplex(videoStream, maybeAudioStream, channel.FFmpegProfile.VideoCodec);
} }
else else
{ {
builder = builder builder = builder
.WithFilterComplex(videoStream, maybeAudioStream); .WithFilterComplex(videoStream, maybeAudioStream, channel.FFmpegProfile.VideoCodec);
} }
}); });
return builder.WithPlaybackArgs(playbackSettings) builder = builder.WithPlaybackArgs(playbackSettings)
.WithMetadata(channel, maybeAudioStream) .WithMetadata(channel, maybeAudioStream)
.WithFormat("mpegts") .WithDuration(start + version.Duration - now);
.WithDuration(start + version.Duration - now)
switch (channel.StreamingMode)
{
// HLS needs to segment and generate playlist
case StreamingMode.HttpLiveStreamingSegmenter:
return builder.WithHls(channel.Number, version)
.Build();
default:
return builder.WithFormat("mpegts")
.WithPipe() .WithPipe()
.Build(); .Build();
} }
}
public Process ForError(string ffmpegPath, Channel channel, Option<TimeSpan> duration, string errorMessage) public Process ForError(string ffmpegPath, Channel channel, Option<TimeSpan> duration, string errorMessage)
{ {
@@ -121,7 +134,7 @@ namespace ErsatzTV.Core.FFmpeg
IDisplaySize desiredResolution = channel.FFmpegProfile.Resolution; IDisplaySize desiredResolution = channel.FFmpegProfile.Resolution;
FFmpegProcessBuilder builder = new FFmpegProcessBuilder(ffmpegPath, false) FFmpegProcessBuilder builder = new FFmpegProcessBuilder(ffmpegPath, false, _logger)
.WithThreads(1) .WithThreads(1)
.WithQuiet() .WithQuiet()
.WithFormatFlags(playbackSettings.FormatFlags) .WithFormatFlags(playbackSettings.FormatFlags)
@@ -144,7 +157,7 @@ namespace ErsatzTV.Core.FFmpeg
{ {
FFmpegPlaybackSettings playbackSettings = _playbackSettingsCalculator.ConcatSettings; FFmpegPlaybackSettings playbackSettings = _playbackSettingsCalculator.ConcatSettings;
return new FFmpegProcessBuilder(ffmpegPath, saveReports) return new FFmpegProcessBuilder(ffmpegPath, saveReports, _logger)
.WithThreads(1) .WithThreads(1)
.WithQuiet() .WithQuiet()
.WithFormatFlags(playbackSettings.FormatFlags) .WithFormatFlags(playbackSettings.FormatFlags)
@@ -0,0 +1,96 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using ErsatzTV.Core.Interfaces.FFmpeg;
using LanguageExt;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Core.FFmpeg
{
public class FFmpegSegmenterService : IFFmpegSegmenterService
{
private static readonly ConcurrentDictionary<string, ProcessAndToken> Processes = new();
private readonly ILogger<FFmpegSegmenterService> _logger;
public FFmpegSegmenterService(ILogger<FFmpegSegmenterService> logger) => _logger = logger;
public bool ProcessExistsForChannel(string channelNumber)
{
if (Processes.TryGetValue(channelNumber, out ProcessAndToken processAndToken))
{
if (!processAndToken.Process.HasExited || !Processes.TryRemove(
new KeyValuePair<string, ProcessAndToken>(channelNumber, processAndToken)))
{
return true;
}
}
return false;
}
public bool TryAdd(string channelNumber, Process process)
{
var cts = new CancellationTokenSource();
var processAndToken = new ProcessAndToken(process, cts, DateTimeOffset.Now);
if (Processes.TryAdd(channelNumber, processAndToken))
{
CancellationToken token = cts.Token;
token.Register(process.Kill);
return true;
}
return false;
}
public void TouchChannel(string channelNumber)
{
if (Processes.TryGetValue(channelNumber, out ProcessAndToken processAndToken))
{
ProcessAndToken newValue = processAndToken with { LastAccess = DateTimeOffset.Now };
if (!Processes.TryUpdate(channelNumber, newValue, processAndToken))
{
_logger.LogWarning("Failed to update last access for channel {Channel}", channelNumber);
}
}
}
public void CleanUpSessions()
{
foreach ((string key, (_, CancellationTokenSource cts, DateTimeOffset lastAccess)) in Processes.ToList())
{
// TODO: configure this time span? 5 min?
if (DateTimeOffset.Now.Subtract(lastAccess) > TimeSpan.FromMinutes(2))
{
_logger.LogDebug("Cleaning up ffmpeg session for channel {Channel}", key);
cts.Cancel();
Processes.TryRemove(key, out _);
}
}
}
public Unit KillAll()
{
foreach ((string key, ProcessAndToken processAndToken) in Processes.ToList())
{
try
{
processAndToken.TokenSource.Cancel();
Processes.TryRemove(key, out _);
}
catch (Exception ex)
{
_logger.LogInformation(ex, "Error killing process");
}
}
return Unit.Default;
}
private record ProcessAndToken(Process Process, CancellationTokenSource TokenSource, DateTimeOffset LastAccess);
}
}
+7
View File
@@ -11,6 +11,13 @@ namespace ErsatzTV.Core
Environment.SpecialFolderOption.Create), Environment.SpecialFolderOption.Create),
"ersatztv"); "ersatztv");
// TODO: find a different spot for this; configurable?
public static readonly string TranscodeFolder = Path.Combine(
Environment.GetFolderPath(
Environment.SpecialFolder.LocalApplicationData,
Environment.SpecialFolderOption.Create),
"etv-transcode");
public static readonly string DatabasePath = Path.Combine(AppDataFolder, "ersatztv.sqlite3"); public static readonly string DatabasePath = Path.Combine(AppDataFolder, "ersatztv.sqlite3");
public static readonly string LogDatabasePath = Path.Combine(AppDataFolder, "logs.sqlite3"); public static readonly string LogDatabasePath = Path.Combine(AppDataFolder, "logs.sqlite3");
@@ -0,0 +1,14 @@
using System.Diagnostics;
using LanguageExt;
namespace ErsatzTV.Core.Interfaces.FFmpeg
{
public interface IFFmpegSegmenterService
{
bool ProcessExistsForChannel(string channelNumber);
bool TryAdd(string channelNumber, Process process);
void TouchChannel(string channelNumber);
void CleanUpSessions();
Unit KillAll();
}
}
@@ -7,6 +7,7 @@ namespace ErsatzTV.Core.Interfaces.Locking
event EventHandler OnLibraryChanged; event EventHandler OnLibraryChanged;
event EventHandler OnPlexChanged; event EventHandler OnPlexChanged;
event EventHandler<Type> OnRemoteMediaSourceChanged; event EventHandler<Type> OnRemoteMediaSourceChanged;
event EventHandler OnTraktChanged;
bool LockLibrary(int libraryId); bool LockLibrary(int libraryId);
bool UnlockLibrary(int libraryId); bool UnlockLibrary(int libraryId);
bool IsLibraryLocked(int libraryId); bool IsLibraryLocked(int libraryId);
@@ -16,5 +17,8 @@ namespace ErsatzTV.Core.Interfaces.Locking
bool IsRemoteMediaSourceLocked<TMediaSource>(); bool IsRemoteMediaSourceLocked<TMediaSource>();
bool LockRemoteMediaSource<TMediaSource>(); bool LockRemoteMediaSource<TMediaSource>();
bool UnlockRemoteMediaSource<TMediaSource>(); bool UnlockRemoteMediaSource<TMediaSource>();
bool IsTraktLocked();
bool LockTrakt();
bool UnlockTrakt();
} }
} }
@@ -15,5 +15,6 @@ namespace ErsatzTV.Core.Interfaces.Metadata
IEnumerable<string> ListFiles(string folder); IEnumerable<string> ListFiles(string folder);
bool FileExists(string path); bool FileExists(string path);
Task<Either<BaseError, Unit>> CopyFile(string source, string destination); Task<Either<BaseError, Unit>> CopyFile(string source, string destination);
Unit EmptyFolder(string folder);
} }
} }
@@ -14,7 +14,7 @@ namespace ErsatzTV.Core.Interfaces.Repositories
Task<bool> AddShow(EmbyShow show); Task<bool> AddShow(EmbyShow show);
Task<Option<EmbyShow>> Update(EmbyShow show); Task<Option<EmbyShow>> Update(EmbyShow show);
Task<bool> AddSeason(EmbyShow show, EmbySeason season); 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<bool> AddEpisode(EmbySeason season, EmbyEpisode episode);
Task<Option<EmbyEpisode>> Update(EmbyEpisode episode); Task<Option<EmbyEpisode>> Update(EmbyEpisode episode);
Task<List<int>> RemoveMissingShows(EmbyLibrary library, List<string> showIds); Task<List<int>> RemoveMissingShows(EmbyLibrary library, List<string> showIds);
@@ -14,7 +14,7 @@ namespace ErsatzTV.Core.Interfaces.Repositories
Task<bool> AddShow(JellyfinShow show); Task<bool> AddShow(JellyfinShow show);
Task<Option<JellyfinShow>> Update(JellyfinShow show); Task<Option<JellyfinShow>> Update(JellyfinShow show);
Task<bool> AddSeason(JellyfinShow show, JellyfinSeason season); 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<bool> AddEpisode(JellyfinSeason season, JellyfinEpisode episode);
Task<Option<JellyfinEpisode>> Update(JellyfinEpisode episode); Task<Option<JellyfinEpisode>> Update(JellyfinEpisode episode);
Task<List<int>> RemoveMissingShows(JellyfinLibrary library, List<string> showIds); Task<List<int>> RemoveMissingShows(JellyfinLibrary library, List<string> showIds);
@@ -33,5 +33,6 @@ namespace ErsatzTV.Core.Interfaces.Repositories
Task<Option<EmbyMovie>> UpdateEmby(EmbyMovie movie); Task<Option<EmbyMovie>> UpdateEmby(EmbyMovie movie);
Task<bool> AddDirector(MovieMetadata metadata, Director director); Task<bool> AddDirector(MovieMetadata metadata, Director director);
Task<bool> AddWriter(MovieMetadata metadata, Writer writer); Task<bool> AddWriter(MovieMetadata metadata, Writer writer);
Task<Unit> UpdatePath(int mediaFileId, string path);
} }
} }
@@ -10,6 +10,7 @@ namespace ErsatzTV.Core.Interfaces.Repositories
Task<List<int>> GetItemIdsToIndex(); Task<List<int>> GetItemIdsToIndex();
Task<Option<MediaItem>> GetItemToIndex(int id); Task<Option<MediaItem>> GetItemToIndex(int id);
Task<List<string>> GetLanguagesForShow(Show show); Task<List<string>> GetLanguagesForShow(Show show);
Task<List<string>> GetLanguagesForSeason(Season season);
Task<List<string>> GetLanguagesForArtist(Artist artist); Task<List<string>> GetLanguagesForArtist(Artist artist);
Task<List<string>> GetAllLanguageCodes(List<string> mediaCodes); Task<List<string>> GetAllLanguageCodes(List<string> mediaCodes);
} }
@@ -9,10 +9,12 @@ namespace ErsatzTV.Core.Interfaces.Repositories
public interface ITelevisionRepository public interface ITelevisionRepository
{ {
Task<bool> AllShowsExist(List<int> showIds); Task<bool> AllShowsExist(List<int> showIds);
Task<bool> AllSeasonsExist(List<int> seasonIds);
Task<bool> AllEpisodesExist(List<int> episodeIds); Task<bool> AllEpisodesExist(List<int> episodeIds);
Task<List<Show>> GetAllShows(); Task<List<Show>> GetAllShows();
Task<Option<Show>> GetShow(int showId); Task<Option<Show>> GetShow(int showId);
Task<List<ShowMetadata>> GetShowsForCards(List<int> ids); Task<List<ShowMetadata>> GetShowsForCards(List<int> ids);
Task<List<SeasonMetadata>> GetSeasonsForCards(List<int> ids);
Task<List<EpisodeMetadata>> GetEpisodesForCards(List<int> ids); Task<List<EpisodeMetadata>> GetEpisodesForCards(List<int> ids);
Task<List<Episode>> GetShowItems(int showId); Task<List<Episode>> GetShowItems(int showId);
Task<List<Season>> GetAllSeasons(); Task<List<Season>> GetAllSeasons();
@@ -49,5 +51,6 @@ namespace ErsatzTV.Core.Interfaces.Repositories
Task<Unit> RemoveMetadata(Episode episode, EpisodeMetadata metadata); Task<Unit> RemoveMetadata(Episode episode, EpisodeMetadata metadata);
Task<bool> AddDirector(EpisodeMetadata metadata, Director director); Task<bool> AddDirector(EpisodeMetadata metadata, Director director);
Task<bool> AddWriter(EpisodeMetadata metadata, Writer writer); Task<bool> AddWriter(EpisodeMetadata metadata, Writer writer);
Task<Unit> UpdatePath(int mediaFileId, string path);
} }
} }
@@ -0,0 +1,14 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Trakt;
using LanguageExt;
namespace ErsatzTV.Core.Interfaces.Trakt
{
public interface ITraktApiClient
{
Task<Either<BaseError, TraktList>> GetUserList(string user, string list);
Task<Either<BaseError, List<TraktListItemWithGuids>>> GetUserListItems(string user, string list);
}
}
+3 -3
View File
@@ -200,10 +200,10 @@ namespace ErsatzTV.Core.Iptv
if (!isSameCustomShow) if (!isSameCustomShow)
{ {
int s = Optional(episode.Season?.SeasonNumber).IfNone(0); int s = Optional(episode.Season?.SeasonNumber).IfNone(-1);
// TODO: multi-episode? // TODO: multi-episode?
int e = episode.EpisodeMetadata.Head().EpisodeNumber; int e = episode.EpisodeMetadata.HeadOrNone().Match(em => em.EpisodeNumber, -1);
if (s > 0 && e > 0) if (s >= 0 && e > 0)
{ {
xml.WriteStartElement("episode-num"); xml.WriteStartElement("episode-num");
xml.WriteAttributeString("system", "onscreen"); xml.WriteAttributeString("system", "onscreen");
+1
View File
@@ -44,6 +44,7 @@ namespace ErsatzTV.Core.Iptv
{ {
StreamingMode.HttpLiveStreamingDirect => "m3u8?mode=hls-direct", StreamingMode.HttpLiveStreamingDirect => "m3u8?mode=hls-direct",
StreamingMode.HttpLiveStreamingHybrid => "m3u8", StreamingMode.HttpLiveStreamingHybrid => "m3u8",
StreamingMode.HttpLiveStreamingSegmenter => "m3u8?mode=segmenter",
_ => "ts" _ => "ts"
}; };
@@ -96,6 +96,8 @@ namespace ErsatzTV.Core.Jellyfin
foreach (JellyfinMovie incoming in validMovies) foreach (JellyfinMovie incoming in validMovies)
{ {
JellyfinMovie incomingMovie = incoming;
decimal percentCompletion = (decimal) validMovies.IndexOf(incoming) / validMovies.Count; decimal percentCompletion = (decimal) validMovies.IndexOf(incoming) / validMovies.Count;
await _mediator.Publish(new LibraryScanProgress(library.Id, percentCompletion)); await _mediator.Publish(new LibraryScanProgress(library.Id, percentCompletion));
@@ -122,12 +124,14 @@ namespace ErsatzTV.Core.Jellyfin
updateStatistics = true; updateStatistics = true;
incoming.LibraryPathId = library.Paths.Head().Id; incoming.LibraryPathId = library.Paths.Head().Id;
Option<JellyfinMovie> updated = await _movieRepository.UpdateJellyfin(incoming); Option<JellyfinMovie> maybeUpdated = await _movieRepository.UpdateJellyfin(incoming);
if (updated.IsSome) foreach (JellyfinMovie updated in maybeUpdated)
{ {
await _searchIndex.UpdateItems( await _searchIndex.UpdateItems(
_searchRepository, _searchRepository,
new List<MediaItem> { updated.ValueUnsafe() }); new List<MediaItem> { updated });
incomingMovie = updated;
} }
} }
catch (Exception ex) catch (Exception ex)
@@ -174,12 +178,12 @@ namespace ErsatzTV.Core.Jellyfin
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", localPath); _logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", localPath);
Either<BaseError, bool> refreshResult = Either<BaseError, bool> refreshResult =
await _localStatisticsProvider.RefreshStatistics(ffprobePath, incoming, localPath); await _localStatisticsProvider.RefreshStatistics(ffprobePath, incomingMovie, localPath);
await refreshResult.Match( await refreshResult.Match(
async _ => async _ =>
{ {
Option<MediaItem> updated = await _searchRepository.GetItemToIndex(incoming.Id); Option<MediaItem> updated = await _searchRepository.GetItemToIndex(incomingMovie.Id);
if (updated.IsSome) if (updated.IsSome)
{ {
await _searchIndex.UpdateItems( await _searchIndex.UpdateItems(
@@ -227,7 +227,11 @@ namespace ErsatzTV.Core.Jellyfin
incoming.ShowId = show.Id; incoming.ShowId = show.Id;
incoming.LibraryPathId = library.Paths.Head().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 () => async () =>
{ {
@@ -238,7 +242,11 @@ namespace ErsatzTV.Core.Jellyfin
show.ShowMetadata.Head().Title, show.ShowMetadata.Head().Title,
incoming.SeasonMetadata.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 = List<JellyfinItemEtag> existingEpisodes =
@@ -319,6 +327,8 @@ namespace ErsatzTV.Core.Jellyfin
{ {
foreach (JellyfinEpisode incoming in episodes) foreach (JellyfinEpisode incoming in episodes)
{ {
JellyfinEpisode incomingEpisode = incoming;
var updateStatistics = false; var updateStatistics = false;
Option<JellyfinItemEtag> maybeExisting = existingEpisodes.Find(ie => ie.ItemId == incoming.ItemId); Option<JellyfinItemEtag> maybeExisting = existingEpisodes.Find(ie => ie.ItemId == incoming.ItemId);
@@ -342,12 +352,14 @@ namespace ErsatzTV.Core.Jellyfin
incoming.SeasonId = season.Id; incoming.SeasonId = season.Id;
incoming.LibraryPathId = library.Paths.Head().Id; incoming.LibraryPathId = library.Paths.Head().Id;
Option<JellyfinEpisode> updated = await _televisionRepository.Update(incoming); Option<JellyfinEpisode> maybeUpdated = await _televisionRepository.Update(incoming);
if (updated.IsSome) foreach (JellyfinEpisode updated in maybeUpdated)
{ {
await _searchIndex.UpdateItems( await _searchIndex.UpdateItems(
_searchRepository, _searchRepository,
new List<MediaItem> { updated.ValueUnsafe() }); new List<MediaItem> { updated });
incomingEpisode = updated;
} }
} }
catch (Exception ex) catch (Exception ex)
@@ -396,7 +408,7 @@ namespace ErsatzTV.Core.Jellyfin
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", localPath); _logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", localPath);
Either<BaseError, bool> refreshResult = Either<BaseError, bool> refreshResult =
await _localStatisticsProvider.RefreshStatistics(ffprobePath, incoming, localPath); await _localStatisticsProvider.RefreshStatistics(ffprobePath, incomingEpisode, localPath);
refreshResult.Match( refreshResult.Match(
_ => { }, _ => { },
+37
View File
@@ -5,18 +5,33 @@ using System.Threading.Tasks;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Metadata;
using LanguageExt; using LanguageExt;
using Microsoft.Extensions.Logging;
using static LanguageExt.Prelude; using static LanguageExt.Prelude;
namespace ErsatzTV.Core.Metadata namespace ErsatzTV.Core.Metadata
{ {
public class LocalFileSystem : ILocalFileSystem public class LocalFileSystem : ILocalFileSystem
{ {
private readonly ILogger<LocalFileSystem> _logger;
public LocalFileSystem(ILogger<LocalFileSystem> logger)
{
_logger = logger;
}
public Unit EnsureFolderExists(string folder) public Unit EnsureFolderExists(string folder)
{
try
{ {
if (!Directory.Exists(folder)) if (!Directory.Exists(folder))
{ {
Directory.CreateDirectory(folder); Directory.CreateDirectory(folder);
} }
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to ensure folder exists at {Folder}", folder);
}
return Unit.Default; return Unit.Default;
} }
@@ -56,5 +71,27 @@ namespace ErsatzTV.Core.Metadata
return BaseError.New(ex.ToString()); return BaseError.New(ex.ToString());
} }
} }
public Unit EmptyFolder(string folder)
{
try
{
foreach (string file in Directory.GetFiles(folder))
{
File.Delete(file);
}
foreach (string directory in Directory.GetDirectories(folder))
{
Directory.Delete(directory, true);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to empty folder at {Folder}", folder);
}
return Unit.Default;
}
} }
} }
@@ -206,12 +206,12 @@ namespace ErsatzTV.Core.Metadata
updated = await _metadataRepository.Add(metadata) || updated; updated = await _metadataRepository.Add(metadata) || updated;
} }
foreach (EpisodeMetadata metadata in toUpdate) foreach (EpisodeMetadata existing in toUpdate)
{ {
Option<EpisodeMetadata> maybeExisting = Option<EpisodeMetadata> maybeIncoming =
episode.EpisodeMetadata.Find(em => em.EpisodeNumber == metadata.EpisodeNumber); episodeMetadata.Find(em => em.EpisodeNumber == existing.EpisodeNumber);
updated = await maybeExisting.Match( updated = await maybeIncoming.Match(
async existing => async metadata =>
{ {
existing.Outline = metadata.Outline; existing.Outline = metadata.Outline;
existing.Plot = metadata.Plot; existing.Plot = metadata.Plot;
@@ -188,6 +188,7 @@ namespace ErsatzTV.Core.Metadata
version.Width = videoStream.width; version.Width = videoStream.width;
version.Height = videoStream.height; version.Height = videoStream.height;
version.VideoScanKind = ScanKindFromFieldOrder(videoStream.field_order); version.VideoScanKind = ScanKindFromFieldOrder(videoStream.field_order);
version.RFrameRate = videoStream.r_frame_rate;
var stream = new MediaStream var stream = new MediaStream
{ {
@@ -184,6 +184,9 @@ namespace ErsatzTV.Core.Metadata
{ {
await ScanEpisodes(libraryPath, ffprobePath, season, seasonFolder); await ScanEpisodes(libraryPath, ffprobePath, season, seasonFolder);
await _libraryRepository.SetEtag(libraryPath, knownFolder, seasonFolder, etag); await _libraryRepository.SetEtag(libraryPath, knownFolder, seasonFolder, etag);
season.Show = show;
await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { season });
}, },
error => error =>
{ {
+19 -1
View File
@@ -156,6 +156,24 @@ namespace ErsatzTV.Core.Plex
MediaVersion existingVersion = existing.MediaVersions.Head(); MediaVersion existingVersion = existing.MediaVersions.Head();
MediaVersion incomingVersion = incoming.MediaVersions.Head(); MediaVersion incomingVersion = incoming.MediaVersions.Head();
foreach (MediaFile incomingFile in incomingVersion.MediaFiles.HeadOrNone())
{
foreach (MediaFile existingFile in existingVersion.MediaFiles.HeadOrNone())
{
if (incomingFile.Path != existingFile.Path)
{
_logger.LogDebug(
"Plex movie has moved from {OldPath} to {NewPath}",
existingFile.Path,
incomingFile.Path);
existingFile.Path = incomingFile.Path;
await _movieRepository.UpdatePath(existingFile.Id, incomingFile.Path);
}
}
}
if (incomingVersion.DateUpdated > existingVersion.DateUpdated || !existingVersion.Streams.Any()) if (incomingVersion.DateUpdated > existingVersion.DateUpdated || !existingVersion.Streams.Any())
{ {
string localPath = _plexPathReplacementService.GetReplacementPlexPath( string localPath = _plexPathReplacementService.GetReplacementPlexPath(
@@ -165,7 +183,7 @@ namespace ErsatzTV.Core.Plex
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", localPath); _logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", localPath);
Either<BaseError, bool> refreshResult = Either<BaseError, bool> refreshResult =
await _localStatisticsProvider.RefreshStatistics(ffprobePath, incoming, localPath); await _localStatisticsProvider.RefreshStatistics(ffprobePath, existing, localPath);
await refreshResult.Match( await refreshResult.Match(
async _ => async _ =>
@@ -314,13 +314,19 @@ namespace ErsatzTV.Core.Plex
.BindT(existing => UpdateMetadataAndArtwork(existing, incoming)); .BindT(existing => UpdateMetadataAndArtwork(existing, incoming));
await maybeSeason.Match( await maybeSeason.Match(
async season => await ScanEpisodes( async season =>
{
await ScanEpisodes(
library, library,
pathReplacements, pathReplacements,
season, season,
connection, connection,
token, token,
ffprobePath), ffprobePath);
season.Show = show;
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { season });
},
error => error =>
{ {
_logger.LogWarning( _logger.LogWarning(
@@ -509,6 +515,24 @@ namespace ErsatzTV.Core.Plex
MediaVersion existingVersion = existing.MediaVersions.Head(); MediaVersion existingVersion = existing.MediaVersions.Head();
MediaVersion incomingVersion = incoming.MediaVersions.Head(); MediaVersion incomingVersion = incoming.MediaVersions.Head();
foreach (MediaFile incomingFile in incomingVersion.MediaFiles.HeadOrNone())
{
foreach (MediaFile existingFile in existingVersion.MediaFiles.HeadOrNone())
{
if (incomingFile.Path != existingFile.Path)
{
_logger.LogDebug(
"Plex episode has moved from {OldPath} to {NewPath}",
existingFile.Path,
incomingFile.Path);
existingFile.Path = incomingFile.Path;
await _televisionRepository.UpdatePath(existingFile.Id, incomingFile.Path);
}
}
}
if (incomingVersion.DateUpdated > existingVersion.DateUpdated || !existingVersion.Streams.Any()) if (incomingVersion.DateUpdated > existingVersion.DateUpdated || !existingVersion.Streams.Any())
{ {
string localPath = _plexPathReplacementService.GetReplacementPlexPath( string localPath = _plexPathReplacementService.GetReplacementPlexPath(
@@ -518,7 +542,7 @@ namespace ErsatzTV.Core.Plex
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", localPath); _logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", localPath);
Either<BaseError, bool> refreshResult = Either<BaseError, bool> refreshResult =
await _localStatisticsProvider.RefreshStatistics(ffprobePath, incoming, localPath); await _localStatisticsProvider.RefreshStatistics(ffprobePath, existing, localPath);
await refreshResult.Match( await refreshResult.Match(
async _ => async _ =>
@@ -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) .ThenInclude(mm => mm.Artwork)
.Include(m => m.ShowMetadata) .Include(m => m.ShowMetadata)
.ThenInclude(mm => mm.Guids) .ThenInclude(mm => mm.Guids)
.Include(m => m.TraktListItems)
.ThenInclude(tli => tli.TraktList)
.Filter(m => m.ItemId == show.ItemId) .Filter(m => m.ItemId == show.ItemId)
.OrderBy(m => m.ItemId) .OrderBy(m => m.ItemId)
.SingleOrDefaultAsync(); .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(); await using TvContext dbContext = _dbContextFactory.CreateDbContext();
Option<EmbySeason> maybeExisting = await dbContext.EmbySeasons Option<EmbySeason> maybeExisting = await dbContext.EmbySeasons
@@ -376,7 +378,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
await dbContext.SaveChangesAsync(); await dbContext.SaveChangesAsync();
return Unit.Default; return maybeExisting;
} }
public async Task<bool> AddEpisode(EmbySeason season, EmbyEpisode episode) public async Task<bool> AddEpisode(EmbySeason season, EmbyEpisode episode)
@@ -432,6 +434,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Include(m => m.EpisodeMetadata) .Include(m => m.EpisodeMetadata)
.ThenInclude(mm => mm.Writers) .ThenInclude(mm => mm.Writers)
.Include(m => m.Season) .Include(m => m.Season)
.Include(m => m.TraktListItems)
.ThenInclude(tli => tli.TraktList)
.Filter(m => m.ItemId == episode.ItemId) .Filter(m => m.ItemId == episode.ItemId)
.OrderBy(m => m.ItemId) .OrderBy(m => m.ItemId)
.SingleOrDefaultAsync(); .SingleOrDefaultAsync();
@@ -90,6 +90,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(mm => mm.Artwork) .ThenInclude(mm => mm.Artwork)
.Include(m => m.ShowMetadata) .Include(m => m.ShowMetadata)
.ThenInclude(mm => mm.Guids) .ThenInclude(mm => mm.Guids)
.Include(m => m.TraktListItems)
.ThenInclude(tli => tli.TraktList)
.Filter(m => m.ItemId == show.ItemId) .Filter(m => m.ItemId == show.ItemId)
.OrderBy(m => m.ItemId) .OrderBy(m => m.ItemId)
.SingleOrDefaultAsync(); .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(); await using TvContext dbContext = _dbContextFactory.CreateDbContext();
Option<JellyfinSeason> maybeExisting = await dbContext.JellyfinSeasons Option<JellyfinSeason> maybeExisting = await dbContext.JellyfinSeasons
@@ -372,11 +374,12 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
{ {
metadata.Artwork.Remove(artworkToRemove); metadata.Artwork.Remove(artworkToRemove);
} }
}
await dbContext.SaveChangesAsync(); await dbContext.SaveChangesAsync();
await dbContext.Entry(existing.LibraryPath).Reference(lp => lp.Library).LoadAsync();
}
return Unit.Default; return maybeExisting;
} }
public async Task<bool> AddEpisode(JellyfinSeason season, JellyfinEpisode episode) public async Task<bool> AddEpisode(JellyfinSeason season, JellyfinEpisode episode)
@@ -432,6 +435,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Include(m => m.EpisodeMetadata) .Include(m => m.EpisodeMetadata)
.ThenInclude(mm => mm.Writers) .ThenInclude(mm => mm.Writers)
.Include(m => m.Season) .Include(m => m.Season)
.Include(m => m.TraktListItems)
.ThenInclude(tli => tli.TraktList)
.Filter(m => m.ItemId == episode.ItemId) .Filter(m => m.ItemId == episode.ItemId)
.OrderBy(m => m.ItemId) .OrderBy(m => m.ItemId)
.SingleOrDefaultAsync(); .SingleOrDefaultAsync();
@@ -114,6 +114,12 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
result.AddRange(await GetShowItemsFromShowId(dbContext, showId)); 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) foreach (int artistId in searchResults.Items.Filter(i => i.Type == SearchIndex.ArtistType)
.Map(i => i.Id)) .Map(i => i.Id))
{ {
@@ -365,6 +365,14 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
WHERE l.Id IN @ids)", WHERE l.Id IN @ids)",
new { ids = libraryIds }); 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( await _dbConnection.ExecuteAsync(
@"DELETE FROM MediaItem WHERE Id IN @"DELETE FROM MediaItem WHERE Id IN
(SELECT m.Id FROM MediaItem m (SELECT m.Id FROM MediaItem m
@@ -391,7 +399,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
WHERE l.Id IN @ids)", WHERE l.Id IN @ids)",
new { ids = libraryIds }); 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) => public Task EnablePlexLibrarySync(IEnumerable<int> libraryIds) =>
@@ -530,6 +538,14 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
WHERE l.Id IN @ids)", WHERE l.Id IN @ids)",
new { ids = libraryIds }); 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( await _dbConnection.ExecuteAsync(
@"DELETE FROM MediaItem WHERE Id IN @"DELETE FROM MediaItem WHERE Id IN
(SELECT m.Id FROM MediaItem m (SELECT m.Id FROM MediaItem m
@@ -556,7 +572,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
WHERE l.Id IN @ids)", WHERE l.Id IN @ids)",
new { ids = libraryIds }); 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) public Task<Option<JellyfinLibrary>> GetJellyfinLibrary(int jellyfinLibraryId)
@@ -928,6 +944,14 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
WHERE l.Id IN @ids)", WHERE l.Id IN @ids)",
new { ids = libraryIds }); 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( await _dbConnection.ExecuteAsync(
@"DELETE FROM MediaItem WHERE Id IN @"DELETE FROM MediaItem WHERE Id IN
(SELECT m.Id FROM MediaItem m (SELECT m.Id FROM MediaItem m
@@ -954,7 +978,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
WHERE l.Id IN @ids)", WHERE l.Id IN @ids)",
new { ids = libraryIds }); new { ids = libraryIds });
return movieIds.Append(showIds).Append(episodeIds).ToList(); return movieIds.Append(showIds).Append(seasonIds).Append(episodeIds).ToList();
} }
} }
} }
@@ -133,6 +133,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
existing.Width = incoming.Width; existing.Width = incoming.Width;
existing.Height = incoming.Height; existing.Height = incoming.Height;
existing.VideoScanKind = incoming.VideoScanKind; existing.VideoScanKind = incoming.VideoScanKind;
existing.RFrameRate = incoming.RFrameRate;
} }
var toAdd = incoming.Streams.Filter(s => existing.Streams.All(es => es.Index != s.Index)).ToList(); var toAdd = incoming.Streams.Filter(s => existing.Streams.All(es => es.Index != s.Index)).ToList();
@@ -89,6 +89,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(mv => mv.MediaFiles) .ThenInclude(mv => mv.MediaFiles)
.Include(i => i.MediaVersions) .Include(i => i.MediaVersions)
.ThenInclude(mv => mv.Streams) .ThenInclude(mv => mv.Streams)
.Include(i => i.TraktListItems)
.ThenInclude(tli => tli.TraktList)
.OrderBy(i => i.MediaVersions.First().MediaFiles.First().Path) .OrderBy(i => i.MediaVersions.First().MediaFiles.First().Path)
.SingleOrDefaultAsync(i => i.MediaVersions.First().MediaFiles.First().Path == path); .SingleOrDefaultAsync(i => i.MediaVersions.First().MediaFiles.First().Path == path);
@@ -129,6 +131,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(mv => mv.Streams) .ThenInclude(mv => mv.Streams)
.Include(i => i.LibraryPath) .Include(i => i.LibraryPath)
.ThenInclude(lp => lp.Library) .ThenInclude(lp => lp.Library)
.Include(i => i.TraktListItems)
.ThenInclude(tli => tli.TraktList)
.OrderBy(i => i.Key) .OrderBy(i => i.Key)
.SingleOrDefaultAsync(i => i.Key == item.Key); .SingleOrDefaultAsync(i => i.Key == item.Key);
@@ -315,6 +319,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(mm => mm.Writers) .ThenInclude(mm => mm.Writers)
.Include(m => m.MovieMetadata) .Include(m => m.MovieMetadata)
.ThenInclude(mm => mm.Guids) .ThenInclude(mm => mm.Guids)
.Include(m => m.TraktListItems)
.ThenInclude(tli => tli.TraktList)
.Filter(m => m.ItemId == movie.ItemId) .Filter(m => m.ItemId == movie.ItemId)
.OrderBy(m => m.ItemId) .OrderBy(m => m.ItemId)
.SingleOrDefaultAsync(); .SingleOrDefaultAsync();
@@ -570,6 +576,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Include(m => m.MovieMetadata) .Include(m => m.MovieMetadata)
.ThenInclude(mm => mm.Guids) .ThenInclude(mm => mm.Guids)
.Filter(m => m.ItemId == movie.ItemId) .Filter(m => m.ItemId == movie.ItemId)
.Include(m => m.TraktListItems)
.ThenInclude(tli => tli.TraktList)
.OrderBy(m => m.ItemId) .OrderBy(m => m.ItemId)
.SingleOrDefaultAsync(); .SingleOrDefaultAsync();
@@ -766,6 +774,11 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
"INSERT INTO Writer (Name, MovieMetadataId) VALUES (@Name, @MetadataId)", "INSERT INTO Writer (Name, MovieMetadataId) VALUES (@Name, @MetadataId)",
new { writer.Name, MetadataId = metadata.Id }).Map(result => result > 0); new { writer.Name, MetadataId = metadata.Id }).Map(result => result > 0);
public Task<Unit> UpdatePath(int mediaFileId, string path) =>
_dbConnection.ExecuteAsync(
"UPDATE MediaFile SET Path = @Path WHERE Id = @MediaFileId",
new { Path = path, MediaFileId = mediaFileId }).Map(_ => Unit.Default);
private static async Task<Either<BaseError, MediaItemScanResult<Movie>>> AddMovie( private static async Task<Either<BaseError, MediaItemScanResult<Movie>>> AddMovie(
TvContext dbContext, TvContext dbContext,
int libraryPathId, int libraryPathId,
@@ -786,7 +799,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
}, },
Streams = new List<MediaStream>() Streams = new List<MediaStream>()
} }
} },
TraktListItems = new List<TraktListItem>()
}; };
await dbContext.Movies.AddAsync(movie); await dbContext.Movies.AddAsync(movie);
await dbContext.SaveChangesAsync(); await dbContext.SaveChangesAsync();
@@ -47,6 +47,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(mv => mv.MediaFiles) .ThenInclude(mv => mv.MediaFiles)
.Include(mv => mv.MediaVersions) .Include(mv => mv.MediaVersions)
.ThenInclude(mv => mv.Streams) .ThenInclude(mv => mv.Streams)
.Include(mv => mv.TraktListItems)
.ThenInclude(tli => tli.TraktList)
.OrderBy(i => i.MediaVersions.First().MediaFiles.First().Path) .OrderBy(i => i.MediaVersions.First().MediaFiles.First().Path)
.SingleOrDefaultAsync(i => i.MediaVersions.First().MediaFiles.First().Path == path); .SingleOrDefaultAsync(i => i.MediaVersions.First().MediaFiles.First().Path == path);
@@ -188,7 +190,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
}, },
Streams = new List<MediaStream>() Streams = new List<MediaStream>()
} }
} },
TraktListItems = new List<TraktListItem>()
}; };
await dbContext.MusicVideos.AddAsync(musicVideo); await dbContext.MusicVideos.AddAsync(musicVideo);
@@ -65,6 +65,16 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Include(mi => (mi as Episode).MediaVersions) .Include(mi => (mi as Episode).MediaVersions)
.ThenInclude(em => em.Streams) .ThenInclude(em => em.Streams)
.Include(mi => (mi as Episode).Season) .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) .Include(mi => (mi as Show).ShowMetadata)
.ThenInclude(mm => mm.Genres) .ThenInclude(mm => mm.Genres)
.Include(mi => (mi as Show).ShowMetadata) .Include(mi => (mi as Show).ShowMetadata)
@@ -87,6 +97,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(mm => mm.Styles) .ThenInclude(mm => mm.Styles)
.Include(mi => (mi as Artist).ArtistMetadata) .Include(mi => (mi as Artist).ArtistMetadata)
.ThenInclude(mm => mm.Moods) .ThenInclude(mm => mm.Moods)
.Include(mi => mi.TraktListItems)
.ThenInclude(tli => tli.TraktList)
.OrderBy(mi => mi.Id) .OrderBy(mi => mi.Id)
.SingleOrDefaultAsync(mi => mi.Id == id) .SingleOrDefaultAsync(mi => mi.Id == id)
.Map(Optional); .Map(Optional);
@@ -102,6 +114,15 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
WHERE MediaStreamKind = 2 AND S.ShowId = @ShowId", WHERE MediaStreamKind = 2 AND S.ShowId = @ShowId",
new { ShowId = show.Id }).Map(result => result.ToList()); 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) => public Task<List<string>> GetLanguagesForArtist(Artist artist) =>
_dbConnection.QueryAsync<string>( _dbConnection.QueryAsync<string>(
@"SELECT DISTINCT Language @"SELECT DISTINCT Language
@@ -31,6 +31,12 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
new { ShowIds = showIds }) new { ShowIds = showIds })
.Map(c => c == showIds.Count); .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) => public Task<bool> AllEpisodesExist(List<int> episodeIds) =>
_dbConnection.QuerySingleAsync<int>( _dbConnection.QuerySingleAsync<int>(
"SELECT COUNT(*) FROM Episode WHERE Id in @EpisodeIds", "SELECT COUNT(*) FROM Episode WHERE Id in @EpisodeIds",
@@ -80,6 +86,23 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ToListAsync(); .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) public async Task<List<EpisodeMetadata>> GetEpisodesForCards(List<int> ids)
{ {
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); await using TvContext dbContext = _dbContextFactory.CreateDbContext();
@@ -221,6 +244,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(sm => sm.Guids) .ThenInclude(sm => sm.Guids)
.Include(s => s.LibraryPath) .Include(s => s.LibraryPath)
.ThenInclude(lp => lp.Library) .ThenInclude(lp => lp.Library)
.Include(s => s.TraktListItems)
.ThenInclude(tli => tli.TraktList)
.OrderBy(s => s.Id) .OrderBy(s => s.Id)
.SingleOrDefaultAsync(s => s.Id == id) .SingleOrDefaultAsync(s => s.Id == id)
.Map(Optional); .Map(Optional);
@@ -247,7 +272,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
{ {
LibraryPathId = libraryPathId, LibraryPathId = libraryPathId,
ShowMetadata = new List<ShowMetadata> { metadata }, ShowMetadata = new List<ShowMetadata> { metadata },
Seasons = new List<Season>() Seasons = new List<Season>(),
TraktListItems = new List<TraktListItem>()
}; };
await dbContext.Shows.AddAsync(show); await dbContext.Shows.AddAsync(show);
@@ -271,6 +297,10 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(sm => sm.Artwork) .ThenInclude(sm => sm.Artwork)
.Include(s => s.SeasonMetadata) .Include(s => s.SeasonMetadata)
.ThenInclude(sm => sm.Guids) .ThenInclude(sm => sm.Guids)
.Include(s => s.LibraryPath)
.ThenInclude(lp => lp.Library)
.Include(s => s.TraktListItems)
.ThenInclude(tli => tli.TraktList)
.OrderBy(s => s.ShowId) .OrderBy(s => s.ShowId)
.ThenBy(s => s.SeasonNumber) .ThenBy(s => s.SeasonNumber)
.SingleOrDefaultAsync(s => s.ShowId == show.Id && s.SeasonNumber == seasonNumber); .SingleOrDefaultAsync(s => s.ShowId == show.Id && s.SeasonNumber == seasonNumber);
@@ -311,6 +341,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Include(i => i.LibraryPath) .Include(i => i.LibraryPath)
.ThenInclude(lp => lp.Library) .ThenInclude(lp => lp.Library)
.Include(i => i.Season) .Include(i => i.Season)
.Include(i => i.TraktListItems)
.ThenInclude(tli => tli.TraktList)
.OrderBy(i => i.MediaVersions.First().MediaFiles.First().Path) .OrderBy(i => i.MediaVersions.First().MediaFiles.First().Path)
.SingleOrDefaultAsync(i => i.MediaVersions.First().MediaFiles.First().Path == path); .SingleOrDefaultAsync(i => i.MediaVersions.First().MediaFiles.First().Path == path);
@@ -414,6 +446,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(sm => sm.Guids) .ThenInclude(sm => sm.Guids)
.Include(i => i.LibraryPath) .Include(i => i.LibraryPath)
.ThenInclude(lp => lp.Library) .ThenInclude(lp => lp.Library)
.Include(i => i.TraktListItems)
.ThenInclude(tli => tli.TraktList)
.OrderBy(i => i.Key) .OrderBy(i => i.Key)
.SingleOrDefaultAsync(i => i.Key == item.Key); .SingleOrDefaultAsync(i => i.Key == item.Key);
@@ -432,6 +466,10 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(sm => sm.Artwork) .ThenInclude(sm => sm.Artwork)
.Include(i => i.SeasonMetadata) .Include(i => i.SeasonMetadata)
.ThenInclude(sm => sm.Guids) .ThenInclude(sm => sm.Guids)
.Include(s => s.LibraryPath)
.ThenInclude(l => l.Library)
.Include(s => s.TraktListItems)
.ThenInclude(tli => tli.TraktList)
.OrderBy(i => i.Key) .OrderBy(i => i.Key)
.SingleOrDefaultAsync(i => i.Key == item.Key); .SingleOrDefaultAsync(i => i.Key == item.Key);
@@ -469,6 +507,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Include(i => i.LibraryPath) .Include(i => i.LibraryPath)
.ThenInclude(lp => lp.Library) .ThenInclude(lp => lp.Library)
.Include(e => e.Season) .Include(e => e.Season)
.Include(e => e.TraktListItems)
.ThenInclude(tli => tli.TraktList)
.OrderBy(i => i.Key) .OrderBy(i => i.Key)
.SingleOrDefaultAsync(i => i.Key == item.Key); .SingleOrDefaultAsync(i => i.Key == item.Key);
@@ -528,6 +568,11 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
"INSERT INTO Writer (Name, EpisodeMetadataId) VALUES (@Name, @MetadataId)", "INSERT INTO Writer (Name, EpisodeMetadataId) VALUES (@Name, @MetadataId)",
new { writer.Name, MetadataId = metadata.Id }).Map(result => result > 0); new { writer.Name, MetadataId = metadata.Id }).Map(result => result > 0);
public Task<Unit> UpdatePath(int mediaFileId, string path) =>
_dbConnection.ExecuteAsync(
"UPDATE MediaFile SET Path = @Path WHERE Id = @MediaFileId",
new { Path = path, MediaFileId = mediaFileId }).Map(_ => Unit.Default);
public async Task<List<Episode>> GetShowItems(int showId) public async Task<List<Episode>> GetShowItems(int showId)
{ {
IEnumerable<int> ids = await _dbConnection.QueryAsync<int>( IEnumerable<int> ids = await _dbConnection.QueryAsync<int>(
@@ -669,10 +714,15 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
DateAdded = DateTime.UtcNow, DateAdded = DateTime.UtcNow,
Guids = new List<MetadataGuid>() Guids = new List<MetadataGuid>()
} }
} },
TraktListItems = new List<TraktListItem>()
}; };
await dbContext.Seasons.AddAsync(season); await dbContext.Seasons.AddAsync(season);
await dbContext.SaveChangesAsync(); await dbContext.SaveChangesAsync();
await dbContext.Entry(season).Reference(s => s.LibraryPath).LoadAsync();
await dbContext.Entry(season.LibraryPath).Reference(lp => lp.Library).LoadAsync();
return season; return season;
} }
catch (Exception ex) catch (Exception ex)
@@ -724,7 +774,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
}, },
Streams = new List<MediaStream>() Streams = new List<MediaStream>()
} }
} },
TraktListItems = new List<TraktListItem>()
}; };
await dbContext.Episodes.AddAsync(episode); await dbContext.Episodes.AddAsync(episode);
await dbContext.SaveChangesAsync(); await dbContext.SaveChangesAsync();
@@ -772,6 +823,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
await dbContext.PlexSeasons.AddAsync(item); await dbContext.PlexSeasons.AddAsync(item);
await dbContext.SaveChangesAsync(); await dbContext.SaveChangesAsync();
await dbContext.Entry(item).Reference(i => i.LibraryPath).LoadAsync(); await dbContext.Entry(item).Reference(i => i.LibraryPath).LoadAsync();
await dbContext.Entry(item.LibraryPath).Reference(lp => lp.Library).LoadAsync();
return item; return item;
} }
catch (Exception ex) catch (Exception ex)
@@ -42,6 +42,7 @@ namespace ErsatzTV.Infrastructure.Data
public DbSet<Show> Shows { get; set; } public DbSet<Show> Shows { get; set; }
public DbSet<ShowMetadata> ShowMetadata { get; set; } public DbSet<ShowMetadata> ShowMetadata { get; set; }
public DbSet<Season> Seasons { get; set; } public DbSet<Season> Seasons { get; set; }
public DbSet<SeasonMetadata> SeasonMetadata { get; set; }
public DbSet<Episode> Episodes { get; set; } public DbSet<Episode> Episodes { get; set; }
public DbSet<EpisodeMetadata> EpisodeMetadata { get; set; } public DbSet<EpisodeMetadata> EpisodeMetadata { get; set; }
public DbSet<PlexMovie> PlexMovies { get; set; } public DbSet<PlexMovie> PlexMovies { get; set; }
@@ -68,6 +69,7 @@ namespace ErsatzTV.Infrastructure.Data
public DbSet<FFmpegProfile> FFmpegProfiles { get; set; } public DbSet<FFmpegProfile> FFmpegProfiles { get; set; }
public DbSet<Resolution> Resolutions { get; set; } public DbSet<Resolution> Resolutions { get; set; }
public DbSet<LanguageCode> LanguageCodes { get; set; } public DbSet<LanguageCode> LanguageCodes { get; set; }
public DbSet<TraktList> TraktLists { get; set; }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) => protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) =>
optionsBuilder.UseLoggerFactory(_loggerFactory); optionsBuilder.UseLoggerFactory(_loggerFactory);
@@ -211,7 +211,8 @@ namespace ErsatzTV.Infrastructure.Emby
ItemId = item.Id, ItemId = item.Id,
Etag = item.Etag, Etag = item.Etag,
MediaVersions = new List<MediaVersion> { version }, MediaVersions = new List<MediaVersion> { version },
MovieMetadata = new List<MovieMetadata> { metadata } MovieMetadata = new List<MovieMetadata> { metadata },
TraktListItems = new List<TraktListItem>()
}; };
return movie; return movie;
@@ -335,7 +336,8 @@ namespace ErsatzTV.Infrastructure.Emby
{ {
ItemId = item.Id, ItemId = item.Id,
Etag = item.Etag, Etag = item.Etag,
ShowMetadata = new List<ShowMetadata> { metadata } ShowMetadata = new List<ShowMetadata> { metadata },
TraktListItems = new List<TraktListItem>()
}; };
return show; return show;
@@ -461,7 +463,8 @@ namespace ErsatzTV.Infrastructure.Emby
{ {
ItemId = item.Id, ItemId = item.Id,
Etag = item.Etag, Etag = item.Etag,
SeasonMetadata = new List<SeasonMetadata> { metadata } SeasonMetadata = new List<SeasonMetadata> { metadata },
TraktListItems = new List<TraktListItem>()
}; };
if (item.IndexNumber.HasValue) if (item.IndexNumber.HasValue)
@@ -509,7 +512,8 @@ namespace ErsatzTV.Infrastructure.Emby
ItemId = item.Id, ItemId = item.Id,
Etag = item.Etag, Etag = item.Etag,
MediaVersions = new List<MediaVersion> { version }, MediaVersions = new List<MediaVersion> { version },
EpisodeMetadata = new List<EpisodeMetadata> { metadata } EpisodeMetadata = new List<EpisodeMetadata> { metadata },
TraktListItems = new List<TraktListItem>()
}; };
return episode; return episode;
@@ -22,12 +22,13 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="5.0.10" /> <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> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Refit" Version="6.0.94" /> <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>
<ItemGroup> <ItemGroup>
@@ -35,9 +35,7 @@ namespace ErsatzTV.Infrastructure.Health.Checks
{ {
var paths = episodes.SelectMany(e => e.MediaVersions.Map(mv => mv.MediaFiles)) var paths = episodes.SelectMany(e => e.MediaVersions.Map(mv => mv.MediaFiles))
.Flatten() .Flatten()
.Map(f => Optional<string>(Path.GetDirectoryName(f.Path))) .Bind(f => Optional<string>(Path.GetDirectoryName(f.Path)))
.Sequence()
.Flatten()
.Distinct() .Distinct()
.Take(5) .Take(5)
.ToList(); .ToList();
@@ -109,6 +109,9 @@ namespace ErsatzTV.Infrastructure.Health.Checks
case "nvenc": case "nvenc":
result.Add(HardwareAccelerationKind.Nvenc); result.Add(HardwareAccelerationKind.Nvenc);
break; break;
case "cuda":
result.Add(HardwareAccelerationKind.Nvenc);
break;
case "qsv": case "qsv":
// qsv is only supported on windows // qsv is only supported on windows
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))

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