Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27fefa1b38 | ||
|
|
fc3175591e | ||
|
|
3363d2c9d7 | ||
|
|
1d5217fa84 | ||
|
|
904cdb8780 | ||
|
|
85fee64565 | ||
|
|
13cfb9728f | ||
|
|
60b82876ea | ||
|
|
a99249c375 | ||
|
|
36e6ef4c18 | ||
|
|
21e53532c1 | ||
|
|
a864d53327 | ||
|
|
e6446f9983 | ||
|
|
ad40213f90 | ||
|
|
45c6d20fd0 | ||
|
|
5439db89a7 | ||
|
|
a39231bb5a | ||
|
|
4c8584b517 |
@@ -49,7 +49,7 @@ jobs:
|
||||
tag=$(git describe --tags --abbrev=0)
|
||||
tag2="${tag:1}"
|
||||
short=$(git rev-parse --short HEAD)
|
||||
final="${tag2/prealpha/$short}"
|
||||
final="${tag2/alpha/$short}"
|
||||
echo "GIT_TAG=${final}" >> $GITHUB_ENV
|
||||
|
||||
- name: Set up Docker Buildx Base
|
||||
|
||||
@@ -82,7 +82,7 @@ jobs:
|
||||
run: |
|
||||
tag=$(git describe --tags --abbrev=0)
|
||||
echo "GIT_TAG=${tag:1}" >> $GITHUB_ENV
|
||||
echo "DOCKER_TAG=${tag/-prealpha/}" >> $GITHUB_ENV
|
||||
echo "DOCKER_TAG=${tag/-alpha/}" >> $GITHUB_ENV
|
||||
|
||||
- name: Set up Docker Buildx Base
|
||||
uses: docker/setup-buildx-action@v1
|
||||
|
||||
+34
-1
@@ -5,6 +5,36 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.0.62-alpha] - 2021-10-05
|
||||
### Added
|
||||
- Support IMDB ids from Plex libraries, which may improve Trakt matching for some items
|
||||
|
||||
### Fixed
|
||||
- Include Specials/Season 0 `episode-num` entry in XMLTV
|
||||
- Fix some transcoding edge cases with VAAPI and pixel formats `yuv420p10le`, `yuv444p10le` and `yuv444p`
|
||||
- Update Plex movie and episode paths when they are changed within Plex
|
||||
- Always use `libx264` software encoder for error messages
|
||||
|
||||
## [0.0.61-alpha] - 2021-09-30
|
||||
### Fixed
|
||||
- Revert nvenc/cuda filter change from v60
|
||||
|
||||
## [0.0.60-alpha] - 2021-09-25
|
||||
### Added
|
||||
- Add Trakt list support under `Lists` > `Trakt Lists`
|
||||
- Trakt lists can be added by url or by `user/list`
|
||||
- To re-download a Trakt list, simply add it again (no need to delete)
|
||||
- See `Logs` for unmatched item details
|
||||
- Trakt lists can only be scheduled by using Smart Collections
|
||||
- Add seasons to search index
|
||||
- This is needed because Trakt lists can contain seasons
|
||||
- This requires rebuilding the search index and search results may be empty or incomplete until the rebuild is complete
|
||||
|
||||
### Fixed
|
||||
- Fix local television scanner to properly update episode metadata when NFO files have been added/changed
|
||||
- Properly detect ffmpeg nvenc (cuda) support in Hardware Acceleration health check
|
||||
- Fix nvenc/cuda filter for some yuv420p content
|
||||
|
||||
## [0.0.59-alpha] - 2021-09-18
|
||||
### Added
|
||||
- Add `Health Checks` table to home page to identify and surface common misconfigurations
|
||||
@@ -614,7 +644,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
- Initial release to facilitate testing outside of Docker.
|
||||
|
||||
|
||||
[Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.59-alpha...HEAD
|
||||
[Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.62-alpha...HEAD
|
||||
[0.0.62-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.61-alpha...v0.0.62-alpha
|
||||
[0.0.61-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.60-alpha...v0.0.61-alpha
|
||||
[0.0.60-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.59-alpha...v0.0.60-alpha
|
||||
[0.0.59-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.58-alpha...v0.0.59-alpha
|
||||
[0.0.58-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.57-alpha...v0.0.58-alpha
|
||||
[0.0.57-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.56-alpha...v0.0.57-alpha
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
</PackageReference>
|
||||
<PackageReference Include="MediatR" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="5.0.0" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="16.10.56">
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.0.63">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
|
||||
@@ -21,7 +21,7 @@ namespace ErsatzTV.Application.MediaCards
|
||||
showMetadata.Year?.ToString(),
|
||||
showMetadata.SortTitle,
|
||||
GetPoster(showMetadata, maybeJellyfin, maybeEmby));
|
||||
|
||||
|
||||
internal static TelevisionSeasonCardViewModel ProjectToViewModel(
|
||||
Season season,
|
||||
Option<JellyfinMediaSource> maybeJellyfin,
|
||||
@@ -37,6 +37,26 @@ namespace ErsatzTV.Application.MediaCards
|
||||
.IfNone(string.Empty),
|
||||
season.SeasonNumber == 0 ? "S" : season.SeasonNumber.ToString());
|
||||
|
||||
internal static TelevisionSeasonCardViewModel ProjectToViewModel(
|
||||
SeasonMetadata seasonMetadata,
|
||||
Option<JellyfinMediaSource> maybeJellyfin,
|
||||
Option<EmbyMediaSource> maybeEmby)
|
||||
{
|
||||
string showTitle = seasonMetadata.Season.Show.ShowMetadata.HeadOrNone().Match(
|
||||
m => m.Title ?? string.Empty,
|
||||
() => string.Empty);
|
||||
|
||||
return new TelevisionSeasonCardViewModel(
|
||||
showTitle,
|
||||
seasonMetadata.SeasonId,
|
||||
seasonMetadata.Season.SeasonNumber,
|
||||
showTitle,
|
||||
GetSeasonName(seasonMetadata.Season.SeasonNumber),
|
||||
$"{showTitle}_{seasonMetadata.Season.SeasonNumber:0000}",
|
||||
GetPoster(seasonMetadata, maybeJellyfin, maybeEmby),
|
||||
seasonMetadata.Season.SeasonNumber == 0 ? "S" : seasonMetadata.Season.SeasonNumber.ToString());
|
||||
}
|
||||
|
||||
internal static TelevisionEpisodeCardViewModel ProjectToViewModel(
|
||||
EpisodeMetadata episodeMetadata,
|
||||
Option<JellyfinMediaSource> maybeJellyfin,
|
||||
|
||||
@@ -7,6 +7,7 @@ using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using static ErsatzTV.Application.MediaCards.Mapper;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCards.Queries
|
||||
{
|
||||
@@ -41,7 +42,7 @@ namespace ErsatzTV.Application.MediaCards.Queries
|
||||
.GetPagedSeasons(request.TelevisionShowId, request.PageNumber, request.PageSize)
|
||||
.Map(list => list.Map(s => ProjectToViewModel(s, maybeJellyfin, maybeEmby)).ToList());
|
||||
|
||||
return new TelevisionSeasonCardResultsViewModel(count, results);
|
||||
return new TelevisionSeasonCardResultsViewModel(count, results, None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
using System.Collections.Generic;
|
||||
using ErsatzTV.Core.Search;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCards
|
||||
{
|
||||
public record TelevisionSeasonCardResultsViewModel(int Count, List<TelevisionSeasonCardViewModel> Cards);
|
||||
public record TelevisionSeasonCardResultsViewModel(
|
||||
int Count,
|
||||
List<TelevisionSeasonCardViewModel> Cards,
|
||||
Option<SearchPageMap> PageMap);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands
|
||||
int CollectionId,
|
||||
List<int> MovieIds,
|
||||
List<int> ShowIds,
|
||||
List<int> SeasonIds,
|
||||
List<int> EpisodeIds,
|
||||
List<int> ArtistIds,
|
||||
List<int> MusicVideoIds) : MediatR.IRequest<Either<BaseError, Unit>>;
|
||||
|
||||
@@ -52,6 +52,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands
|
||||
{
|
||||
var allItems = request.MovieIds
|
||||
.Append(request.ShowIds)
|
||||
.Append(request.SeasonIds)
|
||||
.Append(request.EpisodeIds)
|
||||
.Append(request.ArtistIds)
|
||||
.Append(request.MusicVideoIds)
|
||||
@@ -77,12 +78,15 @@ namespace ErsatzTV.Application.MediaCollections.Commands
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private async Task<Validation<BaseError, Collection>> Validate(TvContext dbContext, AddItemsToCollection request) =>
|
||||
private async Task<Validation<BaseError, Collection>> Validate(
|
||||
TvContext dbContext,
|
||||
AddItemsToCollection request) =>
|
||||
(await CollectionMustExist(dbContext, request),
|
||||
await ValidateMovies(request),
|
||||
await ValidateShows(request),
|
||||
await ValidateSeasons(request),
|
||||
await ValidateEpisodes(request))
|
||||
.Apply((collection, _, _, _) => collection);
|
||||
.Apply((collection, _, _, _, _) => collection);
|
||||
|
||||
private static Task<Validation<BaseError, Collection>> CollectionMustExist(
|
||||
TvContext dbContext,
|
||||
@@ -106,6 +110,13 @@ namespace ErsatzTV.Application.MediaCollections.Commands
|
||||
.MapT(_ => Unit.Default)
|
||||
.Map(v => v.ToValidation<BaseError>("Show does not exist"));
|
||||
|
||||
private Task<Validation<BaseError, Unit>> ValidateSeasons(AddItemsToCollection request) =>
|
||||
_televisionRepository.AllSeasonsExist(request.SeasonIds)
|
||||
.Map(Optional)
|
||||
.Filter(v => v == true)
|
||||
.MapT(_ => Unit.Default)
|
||||
.Map(v => v.ToValidation<BaseError>("Season does not exist"));
|
||||
|
||||
private Task<Validation<BaseError, Unit>> ValidateEpisodes(AddItemsToCollection request) =>
|
||||
_televisionRepository.AllEpisodesExist(request.EpisodeIds)
|
||||
.Map(Optional)
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
using ErsatzTV.Core;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Unit = LanguageExt.Unit;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections.Commands
|
||||
{
|
||||
public record AddTraktList(string TraktListUrl) : IRequest<Either<BaseError, Unit>>, IBackgroundServiceRequest;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Interfaces.Locking;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Interfaces.Trakt;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Unit = LanguageExt.Unit;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections.Commands
|
||||
{
|
||||
public class AddTraktListHandler : TraktCommandBase, IRequestHandler<AddTraktList, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly IEntityLocker _entityLocker;
|
||||
|
||||
public AddTraktListHandler(
|
||||
ITraktApiClient traktApiClient,
|
||||
ISearchRepository searchRepository,
|
||||
ISearchIndex searchIndex,
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ILogger<AddTraktListHandler> logger,
|
||||
IEntityLocker entityLocker)
|
||||
: base(traktApiClient, searchRepository, searchIndex, logger)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_entityLocker = entityLocker;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Unit>> Handle(AddTraktList request, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
Validation<BaseError, Parameters> validation = ValidateUrl(request);
|
||||
return await validation.Match(
|
||||
DoAdd,
|
||||
error => Task.FromResult<Either<BaseError, Unit>>(error.Join()));
|
||||
}
|
||||
finally
|
||||
{
|
||||
_entityLocker.UnlockTrakt();
|
||||
}
|
||||
}
|
||||
|
||||
private static Validation<BaseError, Parameters> ValidateUrl(AddTraktList request)
|
||||
{
|
||||
const string PATTERN = @"(?:https:\/\/trakt\.tv\/users\/)?([\w\-_]+)\/(?:lists\/)?([\w\-_]+)";
|
||||
Match match = Regex.Match(request.TraktListUrl, PATTERN);
|
||||
if (match.Success)
|
||||
{
|
||||
string user = match.Groups[1].Value;
|
||||
string list = match.Groups[2].Value;
|
||||
return new Parameters(user, list);
|
||||
}
|
||||
|
||||
return BaseError.New("Invalid Trakt list url");
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, Unit>> DoAdd(Parameters parameters)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
|
||||
return await TraktApiClient.GetUserList(parameters.User, parameters.List)
|
||||
.BindT(list => SaveList(dbContext, list))
|
||||
.BindT(list => SaveListItems(dbContext, list))
|
||||
.BindT(list => MatchListItems(dbContext, list))
|
||||
.MapT(_ => Unit.Default);
|
||||
|
||||
// match list items (and update in search index)
|
||||
}
|
||||
|
||||
private record Parameters(string User, string List);
|
||||
}
|
||||
}
|
||||
@@ -56,7 +56,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands
|
||||
name => new MultiCollection
|
||||
{
|
||||
Name = name,
|
||||
MultiCollectionItems = request.Items.Map(
|
||||
MultiCollectionItems = request.Items.Bind(
|
||||
i =>
|
||||
{
|
||||
if (i.CollectionId.HasValue)
|
||||
@@ -70,12 +70,10 @@ namespace ErsatzTV.Application.MediaCollections.Commands
|
||||
});
|
||||
}
|
||||
|
||||
return None;
|
||||
return Option<MultiCollectionItem>.None;
|
||||
})
|
||||
.Sequence()
|
||||
.Flatten()
|
||||
.ToList(),
|
||||
MultiCollectionSmartItems = request.Items.Map(
|
||||
MultiCollectionSmartItems = request.Items.Bind(
|
||||
i =>
|
||||
{
|
||||
if (i.SmartCollectionId.HasValue)
|
||||
@@ -89,10 +87,8 @@ namespace ErsatzTV.Application.MediaCollections.Commands
|
||||
});
|
||||
}
|
||||
|
||||
return None;
|
||||
return Option<MultiCollectionSmartItem>.None;
|
||||
})
|
||||
.Sequence()
|
||||
.Flatten()
|
||||
.ToList()
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
using ErsatzTV.Core;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections.Commands
|
||||
{
|
||||
public record DeleteTraktList(int TraktListId) : IRequest<Either<BaseError, LanguageExt.Unit>>,
|
||||
IBackgroundServiceRequest;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Locking;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Interfaces.Trakt;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using LanguageExt;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections.Commands
|
||||
{
|
||||
public class DeleteTraktListHandler : TraktCommandBase, MediatR.IRequestHandler<DeleteTraktList, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly ISearchRepository _searchRepository;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly IEntityLocker _entityLocker;
|
||||
|
||||
public DeleteTraktListHandler(
|
||||
ITraktApiClient traktApiClient,
|
||||
ISearchRepository searchRepository,
|
||||
ISearchIndex searchIndex,
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ILogger<DeleteTraktListHandler> logger,
|
||||
IEntityLocker entityLocker)
|
||||
: base(traktApiClient, searchRepository, searchIndex, logger)
|
||||
{
|
||||
_searchRepository = searchRepository;
|
||||
_searchIndex = searchIndex;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_entityLocker = entityLocker;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Unit>> Handle(
|
||||
DeleteTraktList request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
|
||||
Validation<BaseError, TraktList> validation = await TraktListMustExist(dbContext, request.TraktListId);
|
||||
return await validation.Apply(c => DoDeletion(dbContext, c));
|
||||
}
|
||||
finally
|
||||
{
|
||||
_entityLocker.UnlockTrakt();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Unit> DoDeletion(TvContext dbContext, TraktList traktList)
|
||||
{
|
||||
var mediaItemIds = traktList.Items.Bind(i => Optional(i.MediaItemId)).ToList();
|
||||
|
||||
dbContext.TraktLists.Remove(traktList);
|
||||
if (await dbContext.SaveChangesAsync() > 0)
|
||||
{
|
||||
foreach (int mediaItemId in mediaItemIds)
|
||||
{
|
||||
foreach (MediaItem mediaItem in await _searchRepository.GetItemToIndex(mediaItemId))
|
||||
{
|
||||
await _searchIndex.UpdateItems(_searchRepository, new[] { mediaItem }.ToList());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_searchIndex.Commit();
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using ErsatzTV.Core;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Unit = LanguageExt.Unit;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections.Commands
|
||||
{
|
||||
public record MatchTraktListItems(int TraktListId, bool Unlock = true) : IRequest<Either<BaseError, Unit>>,
|
||||
IBackgroundServiceRequest;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Locking;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Interfaces.Trakt;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Unit = LanguageExt.Unit;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections.Commands
|
||||
{
|
||||
public class MatchTraktListItemsHandler : TraktCommandBase,
|
||||
IRequestHandler<MatchTraktListItems, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly IEntityLocker _entityLocker;
|
||||
|
||||
public MatchTraktListItemsHandler(
|
||||
ITraktApiClient traktApiClient,
|
||||
ISearchRepository searchRepository,
|
||||
ISearchIndex searchIndex,
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ILogger<MatchTraktListItemsHandler> logger,
|
||||
IEntityLocker entityLocker) : base(traktApiClient, searchRepository, searchIndex, logger)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_entityLocker = entityLocker;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Unit>> Handle(
|
||||
MatchTraktListItems request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
|
||||
Validation<BaseError, TraktList> validation = await TraktListMustExist(dbContext, request.TraktListId);
|
||||
return await validation.Match(
|
||||
async l => await MatchListItems(dbContext, l).MapT(_ => Unit.Default),
|
||||
error => Task.FromResult<Either<BaseError, Unit>>(error.Join()));
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (request.Unlock)
|
||||
{
|
||||
_entityLocker.UnlockTrakt();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Interfaces.Trakt;
|
||||
using ErsatzTV.Core.Trakt;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using LanguageExt;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections.Commands
|
||||
{
|
||||
public abstract class TraktCommandBase
|
||||
{
|
||||
private readonly ISearchRepository _searchRepository;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
protected TraktCommandBase(
|
||||
ITraktApiClient traktApiClient,
|
||||
ISearchRepository searchRepository,
|
||||
ISearchIndex searchIndex,
|
||||
ILogger logger)
|
||||
{
|
||||
_searchRepository = searchRepository;
|
||||
_searchIndex = searchIndex;
|
||||
_logger = logger;
|
||||
TraktApiClient = traktApiClient;
|
||||
}
|
||||
|
||||
protected ITraktApiClient TraktApiClient { get; }
|
||||
|
||||
protected static Task<Validation<BaseError, TraktList>>
|
||||
TraktListMustExist(TvContext dbContext, int traktListId) =>
|
||||
dbContext.TraktLists
|
||||
.Include(l => l.Items)
|
||||
.ThenInclude(i => i.Guids)
|
||||
.SelectOneAsync(c => c.Id, c => c.Id == traktListId)
|
||||
.Map(o => o.ToValidation<BaseError>($"TraktList {traktListId} does not exist."));
|
||||
|
||||
protected async Task<Either<BaseError, TraktList>> SaveList(TvContext dbContext, TraktList list)
|
||||
{
|
||||
Option<TraktList> maybeExisting = await dbContext.TraktLists
|
||||
.Include(l => l.Items)
|
||||
.ThenInclude(i => i.Guids)
|
||||
.SelectOneAsync(tl => tl.Id, tl => tl.User == list.User && tl.List == list.List);
|
||||
|
||||
return await maybeExisting.Match(
|
||||
async existing =>
|
||||
{
|
||||
existing.Name = list.Name;
|
||||
existing.Description = list.Description;
|
||||
existing.ItemCount = list.ItemCount;
|
||||
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
return existing;
|
||||
},
|
||||
async () =>
|
||||
{
|
||||
await dbContext.TraktLists.AddAsync(list);
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
return list;
|
||||
});
|
||||
}
|
||||
|
||||
protected async Task<Either<BaseError, TraktList>> SaveListItems(TvContext dbContext, TraktList list)
|
||||
{
|
||||
Either<BaseError, List<TraktListItemWithGuids>> maybeItems =
|
||||
await TraktApiClient.GetUserListItems(list.User, list.List);
|
||||
|
||||
return await maybeItems.Match<Task<Either<BaseError, TraktList>>>(
|
||||
async items =>
|
||||
{
|
||||
var toAdd = items.Filter(i => list.Items.All(i2 => i2.TraktId != i.TraktId)).ToList();
|
||||
var toRemove = list.Items.Filter(i => items.All(i2 => i2.TraktId != i.TraktId)).ToList();
|
||||
var toUpdate = list.Items.Filter(i => !toRemove.Contains(i)).ToList();
|
||||
|
||||
list.Items.RemoveAll(toRemove.Contains);
|
||||
list.Items.AddRange(toAdd.Map(a => ProjectItem(list, a)));
|
||||
|
||||
foreach (TraktListItem existing in toUpdate)
|
||||
{
|
||||
Option<TraktListItem> maybeIncoming = list.Items.Find(i => i.TraktId == existing.TraktId);
|
||||
foreach (TraktListItem incoming in maybeIncoming)
|
||||
{
|
||||
existing.Kind = incoming.Kind;
|
||||
existing.Rank = incoming.Rank;
|
||||
existing.Title = incoming.Title;
|
||||
existing.Year = incoming.Year;
|
||||
existing.Season = incoming.Season;
|
||||
existing.Episode = incoming.Episode;
|
||||
existing.Guids.Clear();
|
||||
existing.Guids.AddRange(incoming.Guids);
|
||||
existing.MediaItemId = null;
|
||||
existing.MediaItem = null;
|
||||
}
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
return list;
|
||||
},
|
||||
error => Task.FromResult(Left<BaseError, TraktList>(error)));
|
||||
}
|
||||
|
||||
protected async Task<Either<BaseError, TraktList>> MatchListItems(TvContext dbContext, TraktList list)
|
||||
{
|
||||
try
|
||||
{
|
||||
var ids = new System.Collections.Generic.HashSet<int>();
|
||||
|
||||
foreach (TraktListItem item in list.Items
|
||||
.OrderBy(i => i.Title).ThenBy(i => i.Year).ThenBy(i => i.Season).ThenBy(i => i.Episode))
|
||||
{
|
||||
switch (item.Kind)
|
||||
{
|
||||
case TraktListItemKind.Movie:
|
||||
Option<int> maybeMovieId = await IdentifyMovie(dbContext, item);
|
||||
foreach (int movieId in maybeMovieId)
|
||||
{
|
||||
ids.Add(movieId);
|
||||
item.MediaItemId = movieId;
|
||||
}
|
||||
|
||||
break;
|
||||
case TraktListItemKind.Show:
|
||||
Option<int> maybeShowId = await IdentifyShow(dbContext, item);
|
||||
foreach (int showId in maybeShowId)
|
||||
{
|
||||
ids.Add(showId);
|
||||
item.MediaItemId = showId;
|
||||
}
|
||||
|
||||
break;
|
||||
case TraktListItemKind.Season:
|
||||
Option<int> maybeSeasonId = await IdentifySeason(dbContext, item);
|
||||
foreach (int seasonId in maybeSeasonId)
|
||||
{
|
||||
ids.Add(seasonId);
|
||||
item.MediaItemId = seasonId;
|
||||
}
|
||||
|
||||
break;
|
||||
default:
|
||||
Option<int> maybeEpisodeId = await IdentifyEpisode(dbContext, item);
|
||||
foreach (int episodeId in maybeEpisodeId)
|
||||
{
|
||||
ids.Add(episodeId);
|
||||
item.MediaItemId = episodeId;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
foreach (int mediaItemId in ids)
|
||||
{
|
||||
Option<MediaItem> maybeItem = await _searchRepository.GetItemToIndex(mediaItemId);
|
||||
foreach (MediaItem item in maybeItem)
|
||||
{
|
||||
await _searchIndex.UpdateItems(_searchRepository, new[] { item }.ToList());
|
||||
}
|
||||
}
|
||||
|
||||
_searchIndex.Commit();
|
||||
|
||||
return list;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error matching trakt list items");
|
||||
return BaseError.New(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static TraktListItem ProjectItem(TraktList list, TraktListItemWithGuids item)
|
||||
{
|
||||
var result = new TraktListItem
|
||||
{
|
||||
TraktList = list,
|
||||
Kind = item.Kind,
|
||||
TraktId = item.TraktId,
|
||||
Rank = item.Rank,
|
||||
Title = item.Title,
|
||||
Year = item.Year,
|
||||
Season = item.Season,
|
||||
Episode = item.Episode,
|
||||
};
|
||||
|
||||
result.Guids = item.Guids.Map(g => new TraktListItemGuid { Guid = g, TraktListItem = result }).ToList();
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task<Option<int>> IdentifyMovie(TvContext dbContext, TraktListItem item)
|
||||
{
|
||||
var guids = item.Guids.Map(g => g.Guid).ToList();
|
||||
|
||||
Option<int> maybeMovieByGuid = await dbContext.MovieMetadata
|
||||
.Filter(mm => mm.Guids.Any(g => guids.Contains(g.Guid)))
|
||||
.FirstOrDefaultAsync()
|
||||
.Map(Optional)
|
||||
.MapT(mm => mm.MovieId);
|
||||
|
||||
foreach (int movieId in maybeMovieByGuid)
|
||||
{
|
||||
_logger.LogDebug("Located trakt movie {Title} by id", item.DisplayTitle);
|
||||
return movieId;
|
||||
}
|
||||
|
||||
Option<int> maybeMovieByTitleYear = await dbContext.MovieMetadata
|
||||
.Filter(mm => mm.Title == item.Title && mm.Year == item.Year)
|
||||
.FirstOrDefaultAsync()
|
||||
.Map(Optional)
|
||||
.MapT(mm => mm.MovieId);
|
||||
|
||||
foreach (int movieId in maybeMovieByTitleYear)
|
||||
{
|
||||
_logger.LogDebug("Located trakt movie {Title} by title/year", item.DisplayTitle);
|
||||
return movieId;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Unable to locate trakt movie {Title}", item.DisplayTitle);
|
||||
|
||||
return None;
|
||||
}
|
||||
|
||||
private async Task<Option<int>> IdentifyShow(TvContext dbContext, TraktListItem item)
|
||||
{
|
||||
var guids = item.Guids.Map(g => g.Guid).ToList();
|
||||
|
||||
Option<int> maybeShowByGuid = await dbContext.ShowMetadata
|
||||
.Filter(sm => sm.Guids.Any(g => guids.Contains(g.Guid)))
|
||||
.FirstOrDefaultAsync()
|
||||
.Map(Optional)
|
||||
.MapT(sm => sm.ShowId);
|
||||
|
||||
foreach (int showId in maybeShowByGuid)
|
||||
{
|
||||
_logger.LogDebug("Located trakt show {Title} by id", item.DisplayTitle);
|
||||
return showId;
|
||||
}
|
||||
|
||||
Option<int> maybeShowByTitleYear = await dbContext.ShowMetadata
|
||||
.Filter(sm => sm.Title == item.Title && sm.Year == item.Year)
|
||||
.FirstOrDefaultAsync()
|
||||
.Map(Optional)
|
||||
.MapT(sm => sm.ShowId);
|
||||
|
||||
foreach (int showId in maybeShowByTitleYear)
|
||||
{
|
||||
_logger.LogDebug("Located trakt show {Title} by title/year", item.Title);
|
||||
return showId;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Unable to locate trakt show {Title}", item.DisplayTitle);
|
||||
|
||||
return None;
|
||||
}
|
||||
|
||||
private async Task<Option<int>> IdentifySeason(TvContext dbContext, TraktListItem item)
|
||||
{
|
||||
var guids = item.Guids.Map(g => g.Guid).ToList();
|
||||
|
||||
Option<int> maybeSeasonByGuid = await dbContext.SeasonMetadata
|
||||
.Filter(sm => sm.Guids.Any(g => guids.Contains(g.Guid)))
|
||||
.FirstOrDefaultAsync()
|
||||
.Map(Optional)
|
||||
.MapT(sm => sm.SeasonId);
|
||||
|
||||
foreach (int seasonId in maybeSeasonByGuid)
|
||||
{
|
||||
_logger.LogDebug("Located trakt season {Title} by id", item.DisplayTitle);
|
||||
return seasonId;
|
||||
}
|
||||
|
||||
Option<int> maybeSeasonByTitleYear = await dbContext.SeasonMetadata
|
||||
.Filter(sm => sm.Season.Show.ShowMetadata.Any(s => s.Title == item.Title && s.Year == item.Year))
|
||||
.Filter(sm => sm.Season.SeasonNumber == item.Season)
|
||||
.FirstOrDefaultAsync()
|
||||
.Map(Optional)
|
||||
.MapT(sm => sm.SeasonId);
|
||||
|
||||
foreach (int seasonId in maybeSeasonByTitleYear)
|
||||
{
|
||||
_logger.LogDebug("Located trakt season {Title} by title/year/season", item.DisplayTitle);
|
||||
return seasonId;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Unable to locate trakt season {Title}", item.DisplayTitle);
|
||||
|
||||
return None;
|
||||
}
|
||||
|
||||
private async Task<Option<int>> IdentifyEpisode(TvContext dbContext, TraktListItem item)
|
||||
{
|
||||
var guids = item.Guids.Map(g => g.Guid).ToList();
|
||||
|
||||
Option<int> maybeEpisodeByGuid = await dbContext.EpisodeMetadata
|
||||
.Filter(em => em.Guids.Any(g => guids.Contains(g.Guid)))
|
||||
.FirstOrDefaultAsync()
|
||||
.Map(Optional)
|
||||
.MapT(sm => sm.EpisodeId);
|
||||
|
||||
foreach (int episodeId in maybeEpisodeByGuid)
|
||||
{
|
||||
_logger.LogDebug("Located trakt episode {Title} by id", item.DisplayTitle);
|
||||
return episodeId;
|
||||
}
|
||||
|
||||
Option<int> maybeEpisodeByTitleYear = await dbContext.EpisodeMetadata
|
||||
.Filter(sm => sm.Episode.Season.Show.ShowMetadata.Any(s => s.Title == item.Title && s.Year == item.Year))
|
||||
.Filter(em => em.Episode.Season.SeasonNumber == item.Season)
|
||||
.Filter(sm => sm.Episode.EpisodeMetadata.Any(e => e.EpisodeNumber == item.Episode))
|
||||
.FirstOrDefaultAsync()
|
||||
.Map(Optional)
|
||||
.MapT(sm => sm.EpisodeId);
|
||||
|
||||
foreach (int episodeId in maybeEpisodeByTitleYear)
|
||||
{
|
||||
_logger.LogDebug("Located trakt episode {Title} by title/year/season/episode", item.DisplayTitle);
|
||||
return episodeId;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Unable to locate trakt episode {Title}", item.DisplayTitle);
|
||||
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,15 @@ namespace ErsatzTV.Application.MediaCollections
|
||||
internal static SmartCollectionViewModel ProjectToViewModel(SmartCollection collection) =>
|
||||
new(collection.Id, collection.Name, collection.Query);
|
||||
|
||||
internal static TraktListViewModel ProjectToViewModel(TraktList traktList) =>
|
||||
new(
|
||||
traktList.Id,
|
||||
traktList.TraktId,
|
||||
$"{traktList.User}/{traktList.List}",
|
||||
traktList.Name,
|
||||
traktList.ItemCount,
|
||||
traktList.Items.Count(i => i.MediaItemId.HasValue));
|
||||
|
||||
private static MultiCollectionItemViewModel ProjectToViewModel(MultiCollectionItem multiCollectionItem) =>
|
||||
new(
|
||||
multiCollectionItem.MultiCollectionId,
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections
|
||||
{
|
||||
public record PagedTraktListsViewModel(int TotalCount, List<TraktListViewModel> Page);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections.Queries
|
||||
{
|
||||
public record GetPagedTraktLists(int PageNum, int PageSize) : IRequest<PagedTraktListsViewModel>;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Data;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Dapper;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using static ErsatzTV.Application.MediaCollections.Mapper;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections.Queries
|
||||
{
|
||||
public class GetPagedTraktListsHandler : IRequestHandler<GetPagedTraktLists, PagedTraktListsViewModel>
|
||||
{
|
||||
private readonly IDbConnection _dbConnection;
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
|
||||
public GetPagedTraktListsHandler(IDbContextFactory<TvContext> dbContextFactory, IDbConnection dbConnection)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_dbConnection = dbConnection;
|
||||
}
|
||||
|
||||
public async Task<PagedTraktListsViewModel> Handle(
|
||||
GetPagedTraktLists request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
int count = await _dbConnection.QuerySingleAsync<int>(@"SELECT COUNT (*) FROM TraktList");
|
||||
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
List<TraktListViewModel> page = await dbContext.TraktLists.FromSqlRaw(
|
||||
@"SELECT * FROM TraktList
|
||||
ORDER BY Name
|
||||
COLLATE NOCASE
|
||||
LIMIT {0} OFFSET {1}",
|
||||
request.PageSize,
|
||||
request.PageNum * request.PageSize)
|
||||
.Include(l => l.Items)
|
||||
.ToListAsync(cancellationToken)
|
||||
.Map(list => list.Map(ProjectToViewModel).ToList());
|
||||
|
||||
return new PagedTraktListsViewModel(count, page);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
namespace ErsatzTV.Application.MediaCollections
|
||||
{
|
||||
public record TraktListViewModel(int Id, int TraktId, string Slug, string Name, int ItemCount, int MatchCount);
|
||||
}
|
||||
@@ -21,6 +21,7 @@ namespace ErsatzTV.Application.Search.Queries
|
||||
new(
|
||||
await GetIds("movie", request.Query),
|
||||
await GetIds("show", request.Query),
|
||||
await GetIds("season", request.Query),
|
||||
await GetIds("episode", request.Query),
|
||||
await GetIds("artist", request.Query),
|
||||
await GetIds("music_video", request.Query));
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
using ErsatzTV.Application.MediaCards;
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.Search.Queries
|
||||
{
|
||||
public record QuerySearchIndexSeasons
|
||||
(string Query, int PageNumber, int PageSize) : IRequest<TelevisionSeasonCardResultsViewModel>;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Application.MediaCards;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Search;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using static ErsatzTV.Application.MediaCards.Mapper;
|
||||
|
||||
namespace ErsatzTV.Application.Search.Queries
|
||||
{
|
||||
public class
|
||||
QuerySearchIndexSeasonsHandler : IRequestHandler<QuerySearchIndexSeasons, TelevisionSeasonCardResultsViewModel>
|
||||
{
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
private readonly ITelevisionRepository _televisionRepository;
|
||||
|
||||
public QuerySearchIndexSeasonsHandler(
|
||||
ISearchIndex searchIndex,
|
||||
ITelevisionRepository televisionRepository,
|
||||
IMediaSourceRepository mediaSourceRepository)
|
||||
{
|
||||
_searchIndex = searchIndex;
|
||||
_televisionRepository = televisionRepository;
|
||||
_mediaSourceRepository = mediaSourceRepository;
|
||||
}
|
||||
|
||||
public async Task<TelevisionSeasonCardResultsViewModel> Handle(
|
||||
QuerySearchIndexSeasons request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
SearchResult searchResult = await _searchIndex.Search(
|
||||
request.Query,
|
||||
(request.PageNumber - 1) * request.PageSize,
|
||||
request.PageSize);
|
||||
|
||||
Option<JellyfinMediaSource> maybeJellyfin = await _mediaSourceRepository.GetAllJellyfin()
|
||||
.Map(list => list.HeadOrNone());
|
||||
|
||||
Option<EmbyMediaSource> maybeEmby = await _mediaSourceRepository.GetAllEmby()
|
||||
.Map(list => list.HeadOrNone());
|
||||
|
||||
List<TelevisionSeasonCardViewModel> items = await _televisionRepository
|
||||
.GetSeasonsForCards(searchResult.Items.Map(i => i.Id).ToList())
|
||||
.Map(list => list.Map(s => ProjectToViewModel(s, maybeJellyfin, maybeEmby)).ToList());
|
||||
|
||||
return new TelevisionSeasonCardResultsViewModel(searchResult.TotalCount, items, searchResult.PageMap);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ namespace ErsatzTV.Application.Search
|
||||
public record SearchResultAllItemsViewModel(
|
||||
List<int> MovieIds,
|
||||
List<int> ShowIds,
|
||||
List<int> SeasonIds,
|
||||
List<int> EpisodeIds,
|
||||
List<int> ArtistIds,
|
||||
List<int> MusicVideoIds);
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<PackageReference Include="LanguageExt.Core" Version="3.4.15" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="5.0.2" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.11.0" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="16.10.56">
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.0.63">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
|
||||
@@ -437,7 +437,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
"[0:0]deinterlace_vaapi,scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,hwupload[v]",
|
||||
"[0:0]deinterlace_vaapi,scale_vaapi=format=nv12:w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,hwupload[v]",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
"h264",
|
||||
@@ -451,14 +451,14 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
"[0:0]deinterlace_vaapi,scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
||||
"[0:0]deinterlace_vaapi,scale_vaapi=format=nv12:w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
"h264",
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
"[0:0]scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,hwupload[v]",
|
||||
"[0:0]scale_vaapi=format=nv12:w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,hwupload[v]",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
"h264",
|
||||
@@ -472,7 +472,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
"[0:0]scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
||||
"[0:0]scale_vaapi=format=nv12:w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
||||
"[v]")]
|
||||
[TestCase("mpeg4", true, false, false, "[0:0]hwupload,deinterlace_vaapi[v]", "[v]")]
|
||||
[TestCase(
|
||||
@@ -480,7 +480,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
"[0:0]hwupload,deinterlace_vaapi,scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,hwupload[v]",
|
||||
"[0:0]hwupload,deinterlace_vaapi,scale_vaapi=format=nv12:w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,hwupload[v]",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
"mpeg4",
|
||||
@@ -494,14 +494,14 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
"[0:0]hwupload,deinterlace_vaapi,scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
||||
"[0:0]hwupload,deinterlace_vaapi,scale_vaapi=format=nv12:w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
"mpeg4",
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
"[0:0]hwupload,scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,hwupload[v]",
|
||||
"[0:0]hwupload,scale_vaapi=format=nv12:w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,hwupload[v]",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
"mpeg4",
|
||||
@@ -515,7 +515,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
"[0:0]hwupload,scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
||||
"[0:0]hwupload,scale_vaapi=format=nv12:w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
||||
"[v]")]
|
||||
public void Should_Return_VAAPI_Video_Filter(
|
||||
string codec,
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Images;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
using FluentAssertions;
|
||||
using LanguageExt;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using NUnit.Framework;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
{
|
||||
[TestFixture]
|
||||
[Explicit]
|
||||
public class TranscodingTests
|
||||
{
|
||||
[Test]
|
||||
[Explicit]
|
||||
public void DeleteTestVideos()
|
||||
{
|
||||
foreach (string file in Directory.GetFiles(TestContext.CurrentContext.TestDirectory, "*.mkv"))
|
||||
{
|
||||
File.Delete(file);
|
||||
}
|
||||
|
||||
Assert.Pass();
|
||||
}
|
||||
|
||||
private class TestData
|
||||
{
|
||||
public static string[] InputCodecs =
|
||||
{
|
||||
"h264",
|
||||
"mpeg2video",
|
||||
"hevc",
|
||||
"mpeg4"
|
||||
};
|
||||
|
||||
public static string[] InputPixelFormats =
|
||||
{
|
||||
"yuv420p",
|
||||
"yuv420p10le",
|
||||
"yuvj420p",
|
||||
"yuv444p",
|
||||
"yuv444p10le"
|
||||
};
|
||||
|
||||
public static Resolution[] Resolutions =
|
||||
{
|
||||
new() { Width = 1920, Height = 1080 },
|
||||
new() { Width = 1280, Height = 720 }
|
||||
};
|
||||
|
||||
public static string[] SoftwareCodecs =
|
||||
{
|
||||
"libx264",
|
||||
"libx265"
|
||||
};
|
||||
|
||||
public static HardwareAccelerationKind[] NoAcceleration =
|
||||
{
|
||||
HardwareAccelerationKind.None
|
||||
};
|
||||
|
||||
public static string[] NvidiaCodecs =
|
||||
{
|
||||
"h264_nvenc",
|
||||
"hevc_nvenc"
|
||||
};
|
||||
|
||||
public static HardwareAccelerationKind[] NvidiaAcceleration =
|
||||
{
|
||||
HardwareAccelerationKind.Nvenc
|
||||
};
|
||||
|
||||
public static string[] VaapiCodecs =
|
||||
{
|
||||
"h264_vaapi",
|
||||
"hevc_vaapi"
|
||||
};
|
||||
|
||||
public static HardwareAccelerationKind[] VaapiAcceleration =
|
||||
{
|
||||
HardwareAccelerationKind.Vaapi
|
||||
};
|
||||
}
|
||||
|
||||
[Test, Combinatorial]
|
||||
public async Task Transcode(
|
||||
[ValueSource(typeof(TestData), nameof(TestData.InputCodecs))]
|
||||
string inputCodec,
|
||||
[ValueSource(typeof(TestData), nameof(TestData.InputPixelFormats))]
|
||||
string inputPixelFormat,
|
||||
[ValueSource(typeof(TestData), nameof(TestData.Resolutions))]
|
||||
Resolution profileResolution,
|
||||
// [ValueSource(typeof(TestData), nameof(TestData.SoftwareCodecs))] string profileCodec,
|
||||
// [ValueSource(typeof(TestData), nameof(TestData.NoAcceleration))] HardwareAccelerationKind profileAcceleration)
|
||||
[ValueSource(typeof(TestData), nameof(TestData.NvidiaCodecs))] string profileCodec,
|
||||
[ValueSource(typeof(TestData), nameof(TestData.NvidiaAcceleration))] HardwareAccelerationKind profileAcceleration)
|
||||
// [ValueSource(typeof(TestData), nameof(TestData.VaapiCodecs))] string profileCodec,
|
||||
// [ValueSource(typeof(TestData), nameof(TestData.VaapiAcceleration))] HardwareAccelerationKind profileAcceleration)
|
||||
{
|
||||
string name = GetStringSha256Hash(
|
||||
$"{inputCodec}_{inputPixelFormat}_{profileResolution}_{profileCodec}_{profileAcceleration}");
|
||||
|
||||
string file = Path.Combine(TestContext.CurrentContext.TestDirectory, $"{name}.mkv");
|
||||
if (!File.Exists(file))
|
||||
{
|
||||
var args =
|
||||
$"-y -f lavfi -i anullsrc=channel_layout=stereo:sample_rate=44100 -f lavfi -i testsrc=duration=1:size=1920x1080:rate=30 -c:a aac -c:v {inputCodec} -shortest -pix_fmt {inputPixelFormat} -strict -2 {file}";
|
||||
var p1 = new Process
|
||||
{
|
||||
StartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "ffmpeg",
|
||||
Arguments = args
|
||||
}
|
||||
};
|
||||
|
||||
p1.Start();
|
||||
await p1.WaitForExitAsync();
|
||||
p1.ExitCode.Should().Be(0);
|
||||
}
|
||||
|
||||
var service = new FFmpegProcessService(
|
||||
new FFmpegPlaybackSettingsCalculator(),
|
||||
new FakeStreamSelector(),
|
||||
new Mock<IImageCache>().Object);
|
||||
|
||||
MediaVersion v = new MediaVersion();
|
||||
|
||||
var metadataRepository = new Mock<IMetadataRepository>();
|
||||
metadataRepository
|
||||
.Setup(r => r.UpdateLocalStatistics(It.IsAny<int>(), It.IsAny<MediaVersion>(), It.IsAny<bool>()))
|
||||
.Callback<int, MediaVersion, bool>((_, version, _) => v = version);
|
||||
|
||||
var localStatisticsProvider = new LocalStatisticsProvider(
|
||||
metadataRepository.Object,
|
||||
new LocalFileSystem(),
|
||||
new Mock<ILogger<LocalStatisticsProvider>>().Object);
|
||||
|
||||
await localStatisticsProvider.RefreshStatistics(
|
||||
"ffprobe",
|
||||
new Movie
|
||||
{
|
||||
MediaVersions = new List<MediaVersion>
|
||||
{
|
||||
new()
|
||||
{
|
||||
MediaFiles = new List<MediaFile>
|
||||
{
|
||||
new() { Path = file }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
DateTimeOffset now = DateTimeOffset.Now;
|
||||
|
||||
Process process = await service.ForPlayoutItem(
|
||||
"ffmpeg",
|
||||
false,
|
||||
new Channel(Guid.NewGuid())
|
||||
{
|
||||
FFmpegProfile = FFmpegProfile.New("test", profileResolution) with
|
||||
{
|
||||
HardwareAcceleration = profileAcceleration,
|
||||
VideoCodec = profileCodec
|
||||
},
|
||||
StreamingMode = StreamingMode.TransportStream
|
||||
},
|
||||
v,
|
||||
file,
|
||||
now,
|
||||
now,
|
||||
None,
|
||||
None);
|
||||
|
||||
process.StartInfo.RedirectStandardError = true;
|
||||
|
||||
process.Start().Should().BeTrue();
|
||||
|
||||
await process.StandardOutput.ReadToEndAsync();
|
||||
string error = await process.StandardError.ReadToEndAsync();
|
||||
await process.WaitForExitAsync();
|
||||
|
||||
string[] unsupportedMessages =
|
||||
{
|
||||
"No support for codec",
|
||||
"No usable",
|
||||
"Provided device doesn't support"
|
||||
};
|
||||
|
||||
if (profileAcceleration != HardwareAccelerationKind.None && unsupportedMessages.Any(error.Contains))
|
||||
{
|
||||
IEnumerable<string> quotedArgs = process.StartInfo.ArgumentList.Map(a => $"\'{a}\'");
|
||||
process.ExitCode.Should().Be(1, $"Error message with successful exit code? {string.Join(" ", quotedArgs)}");
|
||||
Assert.Warn("Unsupported on this hardware");
|
||||
}
|
||||
else if (error.Contains("Impossible to convert between"))
|
||||
{
|
||||
IEnumerable<string> quotedArgs = process.StartInfo.ArgumentList.Map(a => $"\'{a}\'");
|
||||
Assert.Fail($"Transcode failure: ffmpeg {string.Join(" ", quotedArgs)}");
|
||||
}
|
||||
else
|
||||
{
|
||||
process.ExitCode.Should().Be(0, error);
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetStringSha256Hash(string text)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
using var sha = new System.Security.Cryptography.SHA256Managed();
|
||||
byte[] textData = System.Text.Encoding.UTF8.GetBytes(text);
|
||||
byte[] hash = sha.ComputeHash(textData);
|
||||
return BitConverter.ToString(hash).Replace("-", string.Empty);
|
||||
}
|
||||
|
||||
private class FakeStreamSelector : IFFmpegStreamSelector
|
||||
{
|
||||
public Task<MediaStream> SelectVideoStream(Channel channel, MediaVersion version) =>
|
||||
version.Streams.First(s => s.MediaStreamKind == MediaStreamKind.Video).AsTask();
|
||||
|
||||
public Task<Option<MediaStream>> SelectAudioStream(Channel channel, MediaVersion version) =>
|
||||
Optional(version.Streams.First(s => s.MediaStreamKind == MediaStreamKind.Audio)).AsTask();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,8 @@ namespace ErsatzTV.Core.Tests.Fakes
|
||||
public class FakeTelevisionRepository : ITelevisionRepository
|
||||
{
|
||||
public Task<bool> AllShowsExist(List<int> showIds) => throw new NotSupportedException();
|
||||
public Task<bool> AllSeasonsExist(List<int> seasonIds) => throw new NotSupportedException();
|
||||
|
||||
public Task<bool> AllEpisodesExist(List<int> episodeIds) => throw new NotSupportedException();
|
||||
|
||||
public Task<List<Show>> GetAllShows() => throw new NotSupportedException();
|
||||
@@ -18,6 +20,8 @@ namespace ErsatzTV.Core.Tests.Fakes
|
||||
public Task<Option<Show>> GetShow(int showId) => throw new NotSupportedException();
|
||||
|
||||
public Task<List<ShowMetadata>> GetShowsForCards(List<int> ids) => throw new NotSupportedException();
|
||||
public Task<List<SeasonMetadata>> GetSeasonsForCards(List<int> ids) => throw new NotSupportedException();
|
||||
|
||||
public Task<List<EpisodeMetadata>> GetEpisodesForCards(List<int> ids) => throw new NotSupportedException();
|
||||
|
||||
public Task<List<Episode>> GetShowItems(int showId) => throw new NotSupportedException();
|
||||
@@ -33,8 +37,6 @@ namespace ErsatzTV.Core.Tests.Fakes
|
||||
|
||||
public Task<List<Episode>> GetSeasonItems(int seasonId) => throw new NotSupportedException();
|
||||
|
||||
public Task<Option<Episode>> GetEpisode(int episodeId) => throw new NotSupportedException();
|
||||
|
||||
public Task<int> GetEpisodeCount(int seasonId) => throw new NotSupportedException();
|
||||
|
||||
public Task<List<EpisodeMetadata>> GetPagedEpisodes(int seasonId, int pageNumber, int pageSize) =>
|
||||
@@ -95,5 +97,6 @@ namespace ErsatzTV.Core.Tests.Fakes
|
||||
public Task<bool> AddDirector(EpisodeMetadata metadata, Director director) => throw new NotSupportedException();
|
||||
|
||||
public Task<bool> AddWriter(EpisodeMetadata metadata, Writer writer) => throw new NotSupportedException();
|
||||
public Task<Unit> UpdatePath(int mediaFileId, string path) => throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
public class TraktList
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int TraktId { get; set; }
|
||||
public string User { get; set; }
|
||||
public string List { get; set; }
|
||||
public string Name { get; set; }
|
||||
public string Description { get; set; }
|
||||
public int ItemCount { get; set; }
|
||||
public List<TraktListItem> Items { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
public class TraktListItem
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int TraktListId { get; set; }
|
||||
public TraktList TraktList { get; set; }
|
||||
|
||||
public TraktListItemKind Kind { get; set; }
|
||||
public int TraktId { get; set; }
|
||||
public int Rank { get; set; }
|
||||
public string Title { get; set; }
|
||||
public int? Year { get; set; }
|
||||
public int? Season { get; set; }
|
||||
public int? Episode { get; set; }
|
||||
public List<TraktListItemGuid> Guids { get; set; }
|
||||
|
||||
public int? MediaItemId { get; set; }
|
||||
public MediaItem MediaItem { get; set; }
|
||||
|
||||
public string DisplayTitle => Kind switch
|
||||
{
|
||||
TraktListItemKind.Movie => $"{Title} ({Year})",
|
||||
TraktListItemKind.Show => $"{Title} ({Year})",
|
||||
TraktListItemKind.Season => $"{Title} ({Year}) S{Season:00}",
|
||||
_ => $"{Title} ({Year}) S{Season:00}E{Episode:00}"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
public class TraktListItemGuid
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Guid { get; set; }
|
||||
public int TraktListItemId { get; set; }
|
||||
public TraktListItem TraktListItem { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
public enum TraktListItemKind
|
||||
{
|
||||
Movie,
|
||||
Show,
|
||||
Season,
|
||||
Episode
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@
|
||||
public static ConfigElementKey PlayoutsPageSize => new("pages.playouts.page_size");
|
||||
public static ConfigElementKey PlayoutsDetailPageSize => new("pages.playouts.detail_page_size");
|
||||
public static ConfigElementKey LogsPageSize => new("pages.logs.page_size");
|
||||
public static ConfigElementKey TraktListsPageSize => new("pages.trakt.lists_page_size");
|
||||
public static ConfigElementKey LibraryRefreshInterval => new("scanner.library_refresh_interval");
|
||||
public static ConfigElementKey PlayoutDaysToBuild => new("playout.days_to_build");
|
||||
}
|
||||
|
||||
@@ -9,5 +9,6 @@ namespace ErsatzTV.Core.Domain
|
||||
public LibraryPath LibraryPath { get; set; }
|
||||
public List<Collection> Collections { get; set; }
|
||||
public List<CollectionItem> CollectionItems { get; set; }
|
||||
public List<TraktListItem> TraktListItems { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,5 +8,7 @@ namespace ErsatzTV.Core.Domain
|
||||
public string Name { get; set; }
|
||||
public int Height { get; set; }
|
||||
public int Width { get; set; }
|
||||
|
||||
public override string ToString() => $"{Width}x{Height}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,7 +227,11 @@ namespace ErsatzTV.Core.Emby
|
||||
incoming.ShowId = show.Id;
|
||||
incoming.LibraryPathId = library.Paths.Head().Id;
|
||||
|
||||
await _televisionRepository.Update(incoming);
|
||||
foreach (EmbySeason updated in await _televisionRepository.Update(incoming))
|
||||
{
|
||||
incoming.Show = show;
|
||||
await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { updated });
|
||||
}
|
||||
},
|
||||
async () =>
|
||||
{
|
||||
@@ -238,7 +242,11 @@ namespace ErsatzTV.Core.Emby
|
||||
show.ShowMetadata.Head().Title,
|
||||
incoming.SeasonMetadata.Head().Title);
|
||||
|
||||
await _televisionRepository.AddSeason(show, incoming);
|
||||
if (await _televisionRepository.AddSeason(show, incoming))
|
||||
{
|
||||
incoming.Show = show;
|
||||
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { incoming });
|
||||
}
|
||||
});
|
||||
|
||||
List<EmbyItemEtag> existingEpisodes =
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<PackageReference Include="MediatR" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="5.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="5.0.0" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="16.10.56">
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.0.63">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
|
||||
@@ -129,6 +129,20 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
}
|
||||
}
|
||||
|
||||
string[] h264hevc = { "h264", "hevc" };
|
||||
|
||||
if (acceleration == HardwareAccelerationKind.Vaapi && (_pixelFormat ?? string.Empty).EndsWith("p10le") &&
|
||||
h264hevc.Contains(_inputCodec)
|
||||
&& (_pixelFormat != "yuv420p10le" || _inputCodec != "hevc"))
|
||||
{
|
||||
videoFilterQueue.Add("format=p010le,format=nv12|vaapi,hwupload");
|
||||
}
|
||||
|
||||
if (acceleration == HardwareAccelerationKind.Vaapi && _pixelFormat == "yuv444p" && h264hevc.Contains(_inputCodec))
|
||||
{
|
||||
videoFilterQueue.Add("format=nv12|vaapi,hwupload");
|
||||
}
|
||||
|
||||
_scaleToSize.IfSome(
|
||||
size =>
|
||||
{
|
||||
@@ -138,7 +152,7 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
HardwareAccelerationKind.Nvenc when _pixelFormat == "yuv420p10le" =>
|
||||
$"hwdownload,format=p010le,format=nv12,hwupload,scale_npp={size.Width}:{size.Height}",
|
||||
HardwareAccelerationKind.Nvenc => $"scale_npp={size.Width}:{size.Height}",
|
||||
HardwareAccelerationKind.Vaapi => $"scale_vaapi=w={size.Width}:h={size.Height}",
|
||||
HardwareAccelerationKind.Vaapi => $"scale_vaapi=format=nv12:w={size.Width}:h={size.Height}",
|
||||
_ => $"scale={size.Width}:{size.Height}:flags=fast_bilinear"
|
||||
};
|
||||
|
||||
|
||||
@@ -147,8 +147,8 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
{
|
||||
ThreadCount = ffmpegProfile.ThreadCount,
|
||||
FormatFlags = CommonFormatFlags,
|
||||
VideoCodec = ffmpegProfile.VideoCodec,
|
||||
AudioCodec = ffmpegProfile.AudioCodec
|
||||
VideoCodec = "libx264",
|
||||
AudioCodec = ffmpegProfile.AudioCodec,
|
||||
};
|
||||
|
||||
private static bool NeedToScale(FFmpegProfile ffmpegProfile, MediaVersion version) =>
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace ErsatzTV.Core.Interfaces.Locking
|
||||
event EventHandler OnLibraryChanged;
|
||||
event EventHandler OnPlexChanged;
|
||||
event EventHandler<Type> OnRemoteMediaSourceChanged;
|
||||
event EventHandler OnTraktChanged;
|
||||
bool LockLibrary(int libraryId);
|
||||
bool UnlockLibrary(int libraryId);
|
||||
bool IsLibraryLocked(int libraryId);
|
||||
@@ -16,5 +17,8 @@ namespace ErsatzTV.Core.Interfaces.Locking
|
||||
bool IsRemoteMediaSourceLocked<TMediaSource>();
|
||||
bool LockRemoteMediaSource<TMediaSource>();
|
||||
bool UnlockRemoteMediaSource<TMediaSource>();
|
||||
bool IsTraktLocked();
|
||||
bool LockTrakt();
|
||||
bool UnlockTrakt();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ namespace ErsatzTV.Core.Interfaces.Repositories
|
||||
Task<bool> AddShow(EmbyShow show);
|
||||
Task<Option<EmbyShow>> Update(EmbyShow show);
|
||||
Task<bool> AddSeason(EmbyShow show, EmbySeason season);
|
||||
Task<Unit> Update(EmbySeason season);
|
||||
Task<Option<EmbySeason>> Update(EmbySeason season);
|
||||
Task<bool> AddEpisode(EmbySeason season, EmbyEpisode episode);
|
||||
Task<Option<EmbyEpisode>> Update(EmbyEpisode episode);
|
||||
Task<List<int>> RemoveMissingShows(EmbyLibrary library, List<string> showIds);
|
||||
|
||||
@@ -14,7 +14,7 @@ namespace ErsatzTV.Core.Interfaces.Repositories
|
||||
Task<bool> AddShow(JellyfinShow show);
|
||||
Task<Option<JellyfinShow>> Update(JellyfinShow show);
|
||||
Task<bool> AddSeason(JellyfinShow show, JellyfinSeason season);
|
||||
Task<Unit> Update(JellyfinSeason season);
|
||||
Task<Option<JellyfinSeason>> Update(JellyfinSeason season);
|
||||
Task<bool> AddEpisode(JellyfinSeason season, JellyfinEpisode episode);
|
||||
Task<Option<JellyfinEpisode>> Update(JellyfinEpisode episode);
|
||||
Task<List<int>> RemoveMissingShows(JellyfinLibrary library, List<string> showIds);
|
||||
|
||||
@@ -33,5 +33,6 @@ namespace ErsatzTV.Core.Interfaces.Repositories
|
||||
Task<Option<EmbyMovie>> UpdateEmby(EmbyMovie movie);
|
||||
Task<bool> AddDirector(MovieMetadata metadata, Director director);
|
||||
Task<bool> AddWriter(MovieMetadata metadata, Writer writer);
|
||||
Task<Unit> UpdatePath(int mediaFileId, string path);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ namespace ErsatzTV.Core.Interfaces.Repositories
|
||||
Task<List<int>> GetItemIdsToIndex();
|
||||
Task<Option<MediaItem>> GetItemToIndex(int id);
|
||||
Task<List<string>> GetLanguagesForShow(Show show);
|
||||
Task<List<string>> GetLanguagesForSeason(Season season);
|
||||
Task<List<string>> GetLanguagesForArtist(Artist artist);
|
||||
Task<List<string>> GetAllLanguageCodes(List<string> mediaCodes);
|
||||
}
|
||||
|
||||
@@ -9,10 +9,12 @@ namespace ErsatzTV.Core.Interfaces.Repositories
|
||||
public interface ITelevisionRepository
|
||||
{
|
||||
Task<bool> AllShowsExist(List<int> showIds);
|
||||
Task<bool> AllSeasonsExist(List<int> seasonIds);
|
||||
Task<bool> AllEpisodesExist(List<int> episodeIds);
|
||||
Task<List<Show>> GetAllShows();
|
||||
Task<Option<Show>> GetShow(int showId);
|
||||
Task<List<ShowMetadata>> GetShowsForCards(List<int> ids);
|
||||
Task<List<SeasonMetadata>> GetSeasonsForCards(List<int> ids);
|
||||
Task<List<EpisodeMetadata>> GetEpisodesForCards(List<int> ids);
|
||||
Task<List<Episode>> GetShowItems(int showId);
|
||||
Task<List<Season>> GetAllSeasons();
|
||||
@@ -49,5 +51,6 @@ namespace ErsatzTV.Core.Interfaces.Repositories
|
||||
Task<Unit> RemoveMetadata(Episode episode, EpisodeMetadata metadata);
|
||||
Task<bool> AddDirector(EpisodeMetadata metadata, Director director);
|
||||
Task<bool> AddWriter(EpisodeMetadata metadata, Writer writer);
|
||||
Task<Unit> UpdatePath(int mediaFileId, string path);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Trakt;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Trakt
|
||||
{
|
||||
public interface ITraktApiClient
|
||||
{
|
||||
Task<Either<BaseError, TraktList>> GetUserList(string user, string list);
|
||||
Task<Either<BaseError, List<TraktListItemWithGuids>>> GetUserListItems(string user, string list);
|
||||
}
|
||||
}
|
||||
@@ -200,10 +200,10 @@ namespace ErsatzTV.Core.Iptv
|
||||
|
||||
if (!isSameCustomShow)
|
||||
{
|
||||
int s = Optional(episode.Season?.SeasonNumber).IfNone(0);
|
||||
int s = Optional(episode.Season?.SeasonNumber).IfNone(-1);
|
||||
// TODO: multi-episode?
|
||||
int e = episode.EpisodeMetadata.Head().EpisodeNumber;
|
||||
if (s > 0 && e > 0)
|
||||
if (s >= 0 && e > 0)
|
||||
{
|
||||
xml.WriteStartElement("episode-num");
|
||||
xml.WriteAttributeString("system", "onscreen");
|
||||
|
||||
@@ -227,7 +227,11 @@ namespace ErsatzTV.Core.Jellyfin
|
||||
incoming.ShowId = show.Id;
|
||||
incoming.LibraryPathId = library.Paths.Head().Id;
|
||||
|
||||
await _televisionRepository.Update(incoming);
|
||||
foreach (JellyfinSeason updated in await _televisionRepository.Update(incoming))
|
||||
{
|
||||
incoming.Show = show;
|
||||
await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { updated });
|
||||
}
|
||||
},
|
||||
async () =>
|
||||
{
|
||||
@@ -238,7 +242,11 @@ namespace ErsatzTV.Core.Jellyfin
|
||||
show.ShowMetadata.Head().Title,
|
||||
incoming.SeasonMetadata.Head().Title);
|
||||
|
||||
await _televisionRepository.AddSeason(show, incoming);
|
||||
if (await _televisionRepository.AddSeason(show, incoming))
|
||||
{
|
||||
incoming.Show = show;
|
||||
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { incoming });
|
||||
}
|
||||
});
|
||||
|
||||
List<JellyfinItemEtag> existingEpisodes =
|
||||
|
||||
@@ -206,12 +206,12 @@ namespace ErsatzTV.Core.Metadata
|
||||
updated = await _metadataRepository.Add(metadata) || updated;
|
||||
}
|
||||
|
||||
foreach (EpisodeMetadata metadata in toUpdate)
|
||||
foreach (EpisodeMetadata existing in toUpdate)
|
||||
{
|
||||
Option<EpisodeMetadata> maybeExisting =
|
||||
episode.EpisodeMetadata.Find(em => em.EpisodeNumber == metadata.EpisodeNumber);
|
||||
updated = await maybeExisting.Match(
|
||||
async existing =>
|
||||
Option<EpisodeMetadata> maybeIncoming =
|
||||
episodeMetadata.Find(em => em.EpisodeNumber == existing.EpisodeNumber);
|
||||
updated = await maybeIncoming.Match(
|
||||
async metadata =>
|
||||
{
|
||||
existing.Outline = metadata.Outline;
|
||||
existing.Plot = metadata.Plot;
|
||||
|
||||
@@ -184,6 +184,9 @@ namespace ErsatzTV.Core.Metadata
|
||||
{
|
||||
await ScanEpisodes(libraryPath, ffprobePath, season, seasonFolder);
|
||||
await _libraryRepository.SetEtag(libraryPath, knownFolder, seasonFolder, etag);
|
||||
|
||||
season.Show = show;
|
||||
await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { season });
|
||||
},
|
||||
error =>
|
||||
{
|
||||
|
||||
@@ -156,6 +156,24 @@ namespace ErsatzTV.Core.Plex
|
||||
MediaVersion existingVersion = existing.MediaVersions.Head();
|
||||
MediaVersion incomingVersion = incoming.MediaVersions.Head();
|
||||
|
||||
foreach (MediaFile incomingFile in incomingVersion.MediaFiles.HeadOrNone())
|
||||
{
|
||||
foreach (MediaFile existingFile in existingVersion.MediaFiles.HeadOrNone())
|
||||
{
|
||||
if (incomingFile.Path != existingFile.Path)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Plex movie has moved from {OldPath} to {NewPath}",
|
||||
existingFile.Path,
|
||||
incomingFile.Path);
|
||||
|
||||
existingFile.Path = incomingFile.Path;
|
||||
|
||||
await _movieRepository.UpdatePath(existingFile.Id, incomingFile.Path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (incomingVersion.DateUpdated > existingVersion.DateUpdated || !existingVersion.Streams.Any())
|
||||
{
|
||||
string localPath = _plexPathReplacementService.GetReplacementPlexPath(
|
||||
|
||||
@@ -314,13 +314,19 @@ namespace ErsatzTV.Core.Plex
|
||||
.BindT(existing => UpdateMetadataAndArtwork(existing, incoming));
|
||||
|
||||
await maybeSeason.Match(
|
||||
async season => await ScanEpisodes(
|
||||
library,
|
||||
pathReplacements,
|
||||
season,
|
||||
connection,
|
||||
token,
|
||||
ffprobePath),
|
||||
async season =>
|
||||
{
|
||||
await ScanEpisodes(
|
||||
library,
|
||||
pathReplacements,
|
||||
season,
|
||||
connection,
|
||||
token,
|
||||
ffprobePath);
|
||||
|
||||
season.Show = show;
|
||||
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { season });
|
||||
},
|
||||
error =>
|
||||
{
|
||||
_logger.LogWarning(
|
||||
@@ -508,6 +514,24 @@ namespace ErsatzTV.Core.Plex
|
||||
{
|
||||
MediaVersion existingVersion = existing.MediaVersions.Head();
|
||||
MediaVersion incomingVersion = incoming.MediaVersions.Head();
|
||||
|
||||
foreach (MediaFile incomingFile in incomingVersion.MediaFiles.HeadOrNone())
|
||||
{
|
||||
foreach (MediaFile existingFile in existingVersion.MediaFiles.HeadOrNone())
|
||||
{
|
||||
if (incomingFile.Path != existingFile.Path)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Plex episode has moved from {OldPath} to {NewPath}",
|
||||
existingFile.Path,
|
||||
incomingFile.Path);
|
||||
|
||||
existingFile.Path = incomingFile.Path;
|
||||
|
||||
await _televisionRepository.UpdatePath(existingFile.Id, incomingFile.Path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (incomingVersion.DateUpdated > existingVersion.DateUpdated || !existingVersion.Streams.Any())
|
||||
{
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace ErsatzTV.Core.Trakt
|
||||
{
|
||||
public class TraktConfiguration
|
||||
{
|
||||
public string ClientId { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using System.Collections.Generic;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Core.Trakt
|
||||
{
|
||||
public record TraktListItemWithGuids(
|
||||
int TraktId,
|
||||
int Rank,
|
||||
string DisplayTitle,
|
||||
string Title,
|
||||
int? Year,
|
||||
int Season,
|
||||
int Episode,
|
||||
TraktListItemKind Kind,
|
||||
List<string> Guids);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Data.Configurations
|
||||
{
|
||||
public class TraktListConfiguration : IEntityTypeConfiguration<TraktList>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<TraktList> builder)
|
||||
{
|
||||
builder.ToTable("TraktList");
|
||||
|
||||
builder.HasMany(l => l.Items)
|
||||
.WithOne(i => i.TraktList)
|
||||
.HasForeignKey(i => i.TraktListId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Data.Configurations
|
||||
{
|
||||
public class TraktListItemConfiguration : IEntityTypeConfiguration<TraktListItem>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<TraktListItem> builder)
|
||||
{
|
||||
builder.ToTable("TraktListItem");
|
||||
|
||||
builder.HasOne(i => i.MediaItem)
|
||||
.WithMany(mi => mi.TraktListItems)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
builder.HasMany(i => i.Guids)
|
||||
.WithOne(g => g.TraktListItem)
|
||||
.HasForeignKey(g => g.TraktListItemId);
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Data.Configurations
|
||||
{
|
||||
public class TraktListItemGuidConfiguration : IEntityTypeConfiguration<TraktListItemGuid>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<TraktListItemGuid> builder) => builder.ToTable("TraktListItemGuid");
|
||||
}
|
||||
}
|
||||
@@ -90,6 +90,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
.ThenInclude(mm => mm.Artwork)
|
||||
.Include(m => m.ShowMetadata)
|
||||
.ThenInclude(mm => mm.Guids)
|
||||
.Include(m => m.TraktListItems)
|
||||
.ThenInclude(tli => tli.TraktList)
|
||||
.Filter(m => m.ItemId == show.ItemId)
|
||||
.OrderBy(m => m.ItemId)
|
||||
.SingleOrDefaultAsync();
|
||||
@@ -267,7 +269,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Unit> Update(EmbySeason season)
|
||||
public async Task<Option<EmbySeason>> Update(EmbySeason season)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
Option<EmbySeason> maybeExisting = await dbContext.EmbySeasons
|
||||
@@ -376,7 +378,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
return Unit.Default;
|
||||
return maybeExisting;
|
||||
}
|
||||
|
||||
public async Task<bool> AddEpisode(EmbySeason season, EmbyEpisode episode)
|
||||
@@ -432,6 +434,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
.Include(m => m.EpisodeMetadata)
|
||||
.ThenInclude(mm => mm.Writers)
|
||||
.Include(m => m.Season)
|
||||
.Include(m => m.TraktListItems)
|
||||
.ThenInclude(tli => tli.TraktList)
|
||||
.Filter(m => m.ItemId == episode.ItemId)
|
||||
.OrderBy(m => m.ItemId)
|
||||
.SingleOrDefaultAsync();
|
||||
|
||||
@@ -90,6 +90,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
.ThenInclude(mm => mm.Artwork)
|
||||
.Include(m => m.ShowMetadata)
|
||||
.ThenInclude(mm => mm.Guids)
|
||||
.Include(m => m.TraktListItems)
|
||||
.ThenInclude(tli => tli.TraktList)
|
||||
.Filter(m => m.ItemId == show.ItemId)
|
||||
.OrderBy(m => m.ItemId)
|
||||
.SingleOrDefaultAsync();
|
||||
@@ -284,7 +286,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Unit> Update(JellyfinSeason season)
|
||||
public async Task<Option<JellyfinSeason>> Update(JellyfinSeason season)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
Option<JellyfinSeason> maybeExisting = await dbContext.JellyfinSeasons
|
||||
@@ -372,11 +374,12 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
{
|
||||
metadata.Artwork.Remove(artworkToRemove);
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync();
|
||||
await dbContext.Entry(existing.LibraryPath).Reference(lp => lp.Library).LoadAsync();
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
return Unit.Default;
|
||||
return maybeExisting;
|
||||
}
|
||||
|
||||
public async Task<bool> AddEpisode(JellyfinSeason season, JellyfinEpisode episode)
|
||||
@@ -432,6 +435,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
.Include(m => m.EpisodeMetadata)
|
||||
.ThenInclude(mm => mm.Writers)
|
||||
.Include(m => m.Season)
|
||||
.Include(m => m.TraktListItems)
|
||||
.ThenInclude(tli => tli.TraktList)
|
||||
.Filter(m => m.ItemId == episode.ItemId)
|
||||
.OrderBy(m => m.ItemId)
|
||||
.SingleOrDefaultAsync();
|
||||
|
||||
@@ -114,6 +114,12 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
result.AddRange(await GetShowItemsFromShowId(dbContext, showId));
|
||||
}
|
||||
|
||||
foreach (int seasonId in searchResults.Items.Filter(i => i.Type == SearchIndex.SeasonType)
|
||||
.Map(i => i.Id))
|
||||
{
|
||||
result.AddRange(await GetSeasonItemsFromSeasonId(dbContext, seasonId));
|
||||
}
|
||||
|
||||
foreach (int artistId in searchResults.Items.Filter(i => i.Type == SearchIndex.ArtistType)
|
||||
.Map(i => i.Id))
|
||||
{
|
||||
|
||||
@@ -365,6 +365,14 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
WHERE l.Id IN @ids)",
|
||||
new { ids = libraryIds });
|
||||
|
||||
List<int> seasonIds = await _dbConnection.QueryAsync<int>(
|
||||
@"SELECT m.Id FROM MediaItem m
|
||||
INNER JOIN PlexSeason ps ON ps.Id = m.Id
|
||||
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId
|
||||
INNER JOIN Library l ON l.Id = lp.LibraryId
|
||||
WHERE l.Id IN @ids",
|
||||
new { ids = libraryIds }).Map(result => result.ToList());
|
||||
|
||||
await _dbConnection.ExecuteAsync(
|
||||
@"DELETE FROM MediaItem WHERE Id IN
|
||||
(SELECT m.Id FROM MediaItem m
|
||||
@@ -391,7 +399,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
WHERE l.Id IN @ids)",
|
||||
new { ids = libraryIds });
|
||||
|
||||
return movieIds.Append(showIds).Append(episodeIds).ToList();
|
||||
return movieIds.Append(showIds).Append(seasonIds).Append(episodeIds).ToList();
|
||||
}
|
||||
|
||||
public Task EnablePlexLibrarySync(IEnumerable<int> libraryIds) =>
|
||||
@@ -530,6 +538,14 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
WHERE l.Id IN @ids)",
|
||||
new { ids = libraryIds });
|
||||
|
||||
List<int> seasonIds = await _dbConnection.QueryAsync<int>(
|
||||
@"SELECT m.Id FROM MediaItem m
|
||||
INNER JOIN JellyfinSeason js ON js.Id = m.Id
|
||||
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId
|
||||
INNER JOIN Library l ON l.Id = lp.LibraryId
|
||||
WHERE l.Id IN @ids",
|
||||
new { ids = libraryIds }).Map(result => result.ToList());
|
||||
|
||||
await _dbConnection.ExecuteAsync(
|
||||
@"DELETE FROM MediaItem WHERE Id IN
|
||||
(SELECT m.Id FROM MediaItem m
|
||||
@@ -556,7 +572,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
WHERE l.Id IN @ids)",
|
||||
new { ids = libraryIds });
|
||||
|
||||
return movieIds.Append(showIds).Append(episodeIds).ToList();
|
||||
return movieIds.Append(showIds).Append(seasonIds).Append(episodeIds).ToList();
|
||||
}
|
||||
|
||||
public Task<Option<JellyfinLibrary>> GetJellyfinLibrary(int jellyfinLibraryId)
|
||||
@@ -928,6 +944,14 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
WHERE l.Id IN @ids)",
|
||||
new { ids = libraryIds });
|
||||
|
||||
List<int> seasonIds = await _dbConnection.QueryAsync<int>(
|
||||
@"SELECT m.Id FROM MediaItem m
|
||||
INNER JOIN EmbySeason es ON es.Id = m.Id
|
||||
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId
|
||||
INNER JOIN Library l ON l.Id = lp.LibraryId
|
||||
WHERE l.Id IN @ids",
|
||||
new { ids = libraryIds }).Map(result => result.ToList());
|
||||
|
||||
await _dbConnection.ExecuteAsync(
|
||||
@"DELETE FROM MediaItem WHERE Id IN
|
||||
(SELECT m.Id FROM MediaItem m
|
||||
@@ -954,7 +978,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
WHERE l.Id IN @ids)",
|
||||
new { ids = libraryIds });
|
||||
|
||||
return movieIds.Append(showIds).Append(episodeIds).ToList();
|
||||
return movieIds.Append(showIds).Append(seasonIds).Append(episodeIds).ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +89,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.Include(i => i.MediaVersions)
|
||||
.ThenInclude(mv => mv.Streams)
|
||||
.Include(i => i.TraktListItems)
|
||||
.ThenInclude(tli => tli.TraktList)
|
||||
.OrderBy(i => i.MediaVersions.First().MediaFiles.First().Path)
|
||||
.SingleOrDefaultAsync(i => i.MediaVersions.First().MediaFiles.First().Path == path);
|
||||
|
||||
@@ -129,6 +131,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
.ThenInclude(mv => mv.Streams)
|
||||
.Include(i => i.LibraryPath)
|
||||
.ThenInclude(lp => lp.Library)
|
||||
.Include(i => i.TraktListItems)
|
||||
.ThenInclude(tli => tli.TraktList)
|
||||
.OrderBy(i => i.Key)
|
||||
.SingleOrDefaultAsync(i => i.Key == item.Key);
|
||||
|
||||
@@ -315,6 +319,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
.ThenInclude(mm => mm.Writers)
|
||||
.Include(m => m.MovieMetadata)
|
||||
.ThenInclude(mm => mm.Guids)
|
||||
.Include(m => m.TraktListItems)
|
||||
.ThenInclude(tli => tli.TraktList)
|
||||
.Filter(m => m.ItemId == movie.ItemId)
|
||||
.OrderBy(m => m.ItemId)
|
||||
.SingleOrDefaultAsync();
|
||||
@@ -570,6 +576,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
.Include(m => m.MovieMetadata)
|
||||
.ThenInclude(mm => mm.Guids)
|
||||
.Filter(m => m.ItemId == movie.ItemId)
|
||||
.Include(m => m.TraktListItems)
|
||||
.ThenInclude(tli => tli.TraktList)
|
||||
.OrderBy(m => m.ItemId)
|
||||
.SingleOrDefaultAsync();
|
||||
|
||||
@@ -766,6 +774,11 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
"INSERT INTO Writer (Name, MovieMetadataId) VALUES (@Name, @MetadataId)",
|
||||
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(
|
||||
TvContext dbContext,
|
||||
int libraryPathId,
|
||||
@@ -786,7 +799,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
},
|
||||
Streams = new List<MediaStream>()
|
||||
}
|
||||
}
|
||||
},
|
||||
TraktListItems = new List<TraktListItem>()
|
||||
};
|
||||
await dbContext.Movies.AddAsync(movie);
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
@@ -47,6 +47,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.Include(mv => mv.MediaVersions)
|
||||
.ThenInclude(mv => mv.Streams)
|
||||
.Include(mv => mv.TraktListItems)
|
||||
.ThenInclude(tli => tli.TraktList)
|
||||
.OrderBy(i => i.MediaVersions.First().MediaFiles.First().Path)
|
||||
.SingleOrDefaultAsync(i => i.MediaVersions.First().MediaFiles.First().Path == path);
|
||||
|
||||
@@ -188,7 +190,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
},
|
||||
Streams = new List<MediaStream>()
|
||||
}
|
||||
}
|
||||
},
|
||||
TraktListItems = new List<TraktListItem>()
|
||||
};
|
||||
|
||||
await dbContext.MusicVideos.AddAsync(musicVideo);
|
||||
|
||||
@@ -65,6 +65,16 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
.Include(mi => (mi as Episode).MediaVersions)
|
||||
.ThenInclude(em => em.Streams)
|
||||
.Include(mi => (mi as Episode).Season)
|
||||
.Include(mi => (mi as Season).SeasonMetadata)
|
||||
.ThenInclude(sm => sm.Genres)
|
||||
.Include(mi => (mi as Season).SeasonMetadata)
|
||||
.ThenInclude(sm => sm.Tags)
|
||||
.Include(mi => (mi as Season).SeasonMetadata)
|
||||
.ThenInclude(sm => sm.Studios)
|
||||
.Include(mi => (mi as Season).SeasonMetadata)
|
||||
.ThenInclude(sm => sm.Actors)
|
||||
.Include(mi => (mi as Season).Show)
|
||||
.ThenInclude(sm => sm.ShowMetadata)
|
||||
.Include(mi => (mi as Show).ShowMetadata)
|
||||
.ThenInclude(mm => mm.Genres)
|
||||
.Include(mi => (mi as Show).ShowMetadata)
|
||||
@@ -87,6 +97,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
.ThenInclude(mm => mm.Styles)
|
||||
.Include(mi => (mi as Artist).ArtistMetadata)
|
||||
.ThenInclude(mm => mm.Moods)
|
||||
.Include(mi => mi.TraktListItems)
|
||||
.ThenInclude(tli => tli.TraktList)
|
||||
.OrderBy(mi => mi.Id)
|
||||
.SingleOrDefaultAsync(mi => mi.Id == id)
|
||||
.Map(Optional);
|
||||
@@ -102,6 +114,15 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
WHERE MediaStreamKind = 2 AND S.ShowId = @ShowId",
|
||||
new { ShowId = show.Id }).Map(result => result.ToList());
|
||||
|
||||
public Task<List<string>> GetLanguagesForSeason(Season season) =>
|
||||
_dbConnection.QueryAsync<string>(
|
||||
@"SELECT DISTINCT Language
|
||||
FROM MediaStream
|
||||
INNER JOIN MediaVersion MV ON MediaStream.MediaVersionId = MV.Id
|
||||
INNER JOIN Episode E ON MV.EpisodeId = E.Id
|
||||
WHERE MediaStreamKind = 2 AND E.SeasonId = @SeasonId",
|
||||
new { SeasonId = season.Id }).Map(result => result.ToList());
|
||||
|
||||
public Task<List<string>> GetLanguagesForArtist(Artist artist) =>
|
||||
_dbConnection.QueryAsync<string>(
|
||||
@"SELECT DISTINCT Language
|
||||
|
||||
@@ -31,6 +31,12 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
new { ShowIds = showIds })
|
||||
.Map(c => c == showIds.Count);
|
||||
|
||||
public Task<bool> AllSeasonsExist(List<int> seasonIds) =>
|
||||
_dbConnection.QuerySingleAsync<int>(
|
||||
"SELECT COUNT(*) FROM Season WHERE Id in @SeasonIds",
|
||||
new { SeasonIds = seasonIds })
|
||||
.Map(c => c == seasonIds.Count);
|
||||
|
||||
public Task<bool> AllEpisodesExist(List<int> episodeIds) =>
|
||||
_dbConnection.QuerySingleAsync<int>(
|
||||
"SELECT COUNT(*) FROM Episode WHERE Id in @EpisodeIds",
|
||||
@@ -80,6 +86,23 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<List<SeasonMetadata>> GetSeasonsForCards(List<int> ids)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
return await dbContext.SeasonMetadata
|
||||
.AsNoTracking()
|
||||
.Filter(s => ids.Contains(s.SeasonId))
|
||||
.Include(s => s.Season.Show)
|
||||
.ThenInclude(s => s.ShowMetadata)
|
||||
.Include(sm => sm.Artwork)
|
||||
.ToListAsync()
|
||||
.Map(
|
||||
list => list
|
||||
.OrderBy(s => s.Season.Show.ShowMetadata.HeadOrNone().Match(sm => sm.SortTitle, () => string.Empty))
|
||||
.ThenBy(s => s.Season.SeasonNumber)
|
||||
.ToList());
|
||||
}
|
||||
|
||||
public async Task<List<EpisodeMetadata>> GetEpisodesForCards(List<int> ids)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
@@ -221,6 +244,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
.ThenInclude(sm => sm.Guids)
|
||||
.Include(s => s.LibraryPath)
|
||||
.ThenInclude(lp => lp.Library)
|
||||
.Include(s => s.TraktListItems)
|
||||
.ThenInclude(tli => tli.TraktList)
|
||||
.OrderBy(s => s.Id)
|
||||
.SingleOrDefaultAsync(s => s.Id == id)
|
||||
.Map(Optional);
|
||||
@@ -247,7 +272,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
{
|
||||
LibraryPathId = libraryPathId,
|
||||
ShowMetadata = new List<ShowMetadata> { metadata },
|
||||
Seasons = new List<Season>()
|
||||
Seasons = new List<Season>(),
|
||||
TraktListItems = new List<TraktListItem>()
|
||||
};
|
||||
|
||||
await dbContext.Shows.AddAsync(show);
|
||||
@@ -271,6 +297,10 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
.ThenInclude(sm => sm.Artwork)
|
||||
.Include(s => s.SeasonMetadata)
|
||||
.ThenInclude(sm => sm.Guids)
|
||||
.Include(s => s.LibraryPath)
|
||||
.ThenInclude(lp => lp.Library)
|
||||
.Include(s => s.TraktListItems)
|
||||
.ThenInclude(tli => tli.TraktList)
|
||||
.OrderBy(s => s.ShowId)
|
||||
.ThenBy(s => s.SeasonNumber)
|
||||
.SingleOrDefaultAsync(s => s.ShowId == show.Id && s.SeasonNumber == seasonNumber);
|
||||
@@ -311,6 +341,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
.Include(i => i.LibraryPath)
|
||||
.ThenInclude(lp => lp.Library)
|
||||
.Include(i => i.Season)
|
||||
.Include(i => i.TraktListItems)
|
||||
.ThenInclude(tli => tli.TraktList)
|
||||
.OrderBy(i => i.MediaVersions.First().MediaFiles.First().Path)
|
||||
.SingleOrDefaultAsync(i => i.MediaVersions.First().MediaFiles.First().Path == path);
|
||||
|
||||
@@ -414,6 +446,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
.ThenInclude(sm => sm.Guids)
|
||||
.Include(i => i.LibraryPath)
|
||||
.ThenInclude(lp => lp.Library)
|
||||
.Include(i => i.TraktListItems)
|
||||
.ThenInclude(tli => tli.TraktList)
|
||||
.OrderBy(i => i.Key)
|
||||
.SingleOrDefaultAsync(i => i.Key == item.Key);
|
||||
|
||||
@@ -432,6 +466,10 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
.ThenInclude(sm => sm.Artwork)
|
||||
.Include(i => i.SeasonMetadata)
|
||||
.ThenInclude(sm => sm.Guids)
|
||||
.Include(s => s.LibraryPath)
|
||||
.ThenInclude(l => l.Library)
|
||||
.Include(s => s.TraktListItems)
|
||||
.ThenInclude(tli => tli.TraktList)
|
||||
.OrderBy(i => i.Key)
|
||||
.SingleOrDefaultAsync(i => i.Key == item.Key);
|
||||
|
||||
@@ -469,6 +507,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
.Include(i => i.LibraryPath)
|
||||
.ThenInclude(lp => lp.Library)
|
||||
.Include(e => e.Season)
|
||||
.Include(e => e.TraktListItems)
|
||||
.ThenInclude(tli => tli.TraktList)
|
||||
.OrderBy(i => i.Key)
|
||||
.SingleOrDefaultAsync(i => i.Key == item.Key);
|
||||
|
||||
@@ -528,6 +568,11 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
"INSERT INTO Writer (Name, EpisodeMetadataId) VALUES (@Name, @MetadataId)",
|
||||
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)
|
||||
{
|
||||
IEnumerable<int> ids = await _dbConnection.QueryAsync<int>(
|
||||
@@ -669,10 +714,15 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
DateAdded = DateTime.UtcNow,
|
||||
Guids = new List<MetadataGuid>()
|
||||
}
|
||||
}
|
||||
},
|
||||
TraktListItems = new List<TraktListItem>()
|
||||
};
|
||||
await dbContext.Seasons.AddAsync(season);
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
await dbContext.Entry(season).Reference(s => s.LibraryPath).LoadAsync();
|
||||
await dbContext.Entry(season.LibraryPath).Reference(lp => lp.Library).LoadAsync();
|
||||
|
||||
return season;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -724,7 +774,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
},
|
||||
Streams = new List<MediaStream>()
|
||||
}
|
||||
}
|
||||
},
|
||||
TraktListItems = new List<TraktListItem>()
|
||||
};
|
||||
await dbContext.Episodes.AddAsync(episode);
|
||||
await dbContext.SaveChangesAsync();
|
||||
@@ -772,6 +823,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
await dbContext.PlexSeasons.AddAsync(item);
|
||||
await dbContext.SaveChangesAsync();
|
||||
await dbContext.Entry(item).Reference(i => i.LibraryPath).LoadAsync();
|
||||
await dbContext.Entry(item.LibraryPath).Reference(lp => lp.Library).LoadAsync();
|
||||
return item;
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -42,6 +42,7 @@ namespace ErsatzTV.Infrastructure.Data
|
||||
public DbSet<Show> Shows { get; set; }
|
||||
public DbSet<ShowMetadata> ShowMetadata { get; set; }
|
||||
public DbSet<Season> Seasons { get; set; }
|
||||
public DbSet<SeasonMetadata> SeasonMetadata { get; set; }
|
||||
public DbSet<Episode> Episodes { get; set; }
|
||||
public DbSet<EpisodeMetadata> EpisodeMetadata { get; set; }
|
||||
public DbSet<PlexMovie> PlexMovies { get; set; }
|
||||
@@ -68,6 +69,7 @@ namespace ErsatzTV.Infrastructure.Data
|
||||
public DbSet<FFmpegProfile> FFmpegProfiles { get; set; }
|
||||
public DbSet<Resolution> Resolutions { get; set; }
|
||||
public DbSet<LanguageCode> LanguageCodes { get; set; }
|
||||
public DbSet<TraktList> TraktLists { get; set; }
|
||||
|
||||
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) =>
|
||||
optionsBuilder.UseLoggerFactory(_loggerFactory);
|
||||
|
||||
@@ -211,7 +211,8 @@ namespace ErsatzTV.Infrastructure.Emby
|
||||
ItemId = item.Id,
|
||||
Etag = item.Etag,
|
||||
MediaVersions = new List<MediaVersion> { version },
|
||||
MovieMetadata = new List<MovieMetadata> { metadata }
|
||||
MovieMetadata = new List<MovieMetadata> { metadata },
|
||||
TraktListItems = new List<TraktListItem>()
|
||||
};
|
||||
|
||||
return movie;
|
||||
@@ -335,7 +336,8 @@ namespace ErsatzTV.Infrastructure.Emby
|
||||
{
|
||||
ItemId = item.Id,
|
||||
Etag = item.Etag,
|
||||
ShowMetadata = new List<ShowMetadata> { metadata }
|
||||
ShowMetadata = new List<ShowMetadata> { metadata },
|
||||
TraktListItems = new List<TraktListItem>()
|
||||
};
|
||||
|
||||
return show;
|
||||
@@ -461,7 +463,8 @@ namespace ErsatzTV.Infrastructure.Emby
|
||||
{
|
||||
ItemId = item.Id,
|
||||
Etag = item.Etag,
|
||||
SeasonMetadata = new List<SeasonMetadata> { metadata }
|
||||
SeasonMetadata = new List<SeasonMetadata> { metadata },
|
||||
TraktListItems = new List<TraktListItem>()
|
||||
};
|
||||
|
||||
if (item.IndexNumber.HasValue)
|
||||
@@ -509,7 +512,8 @@ namespace ErsatzTV.Infrastructure.Emby
|
||||
ItemId = item.Id,
|
||||
Etag = item.Etag,
|
||||
MediaVersions = new List<MediaVersion> { version },
|
||||
EpisodeMetadata = new List<EpisodeMetadata> { metadata }
|
||||
EpisodeMetadata = new List<EpisodeMetadata> { metadata },
|
||||
TraktListItems = new List<TraktListItem>()
|
||||
};
|
||||
|
||||
return episode;
|
||||
|
||||
@@ -22,12 +22,13 @@
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="5.0.10" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="16.10.56">
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.0.63">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Refit" Version="6.0.94" />
|
||||
<PackageReference Include="SixLabors.ImageSharp" Version="1.0.3" />
|
||||
<PackageReference Include="Refit.Newtonsoft.Json" Version="6.0.94" />
|
||||
<PackageReference Include="SixLabors.ImageSharp" Version="1.0.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -35,9 +35,7 @@ namespace ErsatzTV.Infrastructure.Health.Checks
|
||||
{
|
||||
var paths = episodes.SelectMany(e => e.MediaVersions.Map(mv => mv.MediaFiles))
|
||||
.Flatten()
|
||||
.Map(f => Optional<string>(Path.GetDirectoryName(f.Path)))
|
||||
.Sequence()
|
||||
.Flatten()
|
||||
.Bind(f => Optional<string>(Path.GetDirectoryName(f.Path)))
|
||||
.Distinct()
|
||||
.Take(5)
|
||||
.ToList();
|
||||
|
||||
@@ -109,6 +109,9 @@ namespace ErsatzTV.Infrastructure.Health.Checks
|
||||
case "nvenc":
|
||||
result.Add(HardwareAccelerationKind.Nvenc);
|
||||
break;
|
||||
case "cuda":
|
||||
result.Add(HardwareAccelerationKind.Nvenc);
|
||||
break;
|
||||
case "qsv":
|
||||
// qsv is only supported on windows
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
|
||||
@@ -35,9 +35,7 @@ namespace ErsatzTV.Infrastructure.Health.Checks
|
||||
{
|
||||
var paths = movies.SelectMany(e => e.MediaVersions.Map(mv => mv.MediaFiles))
|
||||
.Flatten()
|
||||
.Map(f => Optional<string>(Path.GetDirectoryName(f.Path)))
|
||||
.Sequence()
|
||||
.Flatten()
|
||||
.Bind(f => Optional<string>(Path.GetDirectoryName(f.Path)))
|
||||
.Distinct()
|
||||
.Take(5)
|
||||
.ToList();
|
||||
|
||||
@@ -264,7 +264,8 @@ namespace ErsatzTV.Infrastructure.Jellyfin
|
||||
ItemId = item.Id,
|
||||
Etag = item.Etag,
|
||||
MediaVersions = new List<MediaVersion> { version },
|
||||
MovieMetadata = new List<MovieMetadata> { metadata }
|
||||
MovieMetadata = new List<MovieMetadata> { metadata },
|
||||
TraktListItems = new List<TraktListItem>()
|
||||
};
|
||||
|
||||
return movie;
|
||||
@@ -393,7 +394,8 @@ namespace ErsatzTV.Infrastructure.Jellyfin
|
||||
{
|
||||
ItemId = item.Id,
|
||||
Etag = item.Etag,
|
||||
ShowMetadata = new List<ShowMetadata> { metadata }
|
||||
ShowMetadata = new List<ShowMetadata> { metadata },
|
||||
TraktListItems = new List<TraktListItem>()
|
||||
};
|
||||
|
||||
return show;
|
||||
@@ -524,7 +526,8 @@ namespace ErsatzTV.Infrastructure.Jellyfin
|
||||
{
|
||||
ItemId = item.Id,
|
||||
Etag = item.Etag,
|
||||
SeasonMetadata = new List<SeasonMetadata> { metadata }
|
||||
SeasonMetadata = new List<SeasonMetadata> { metadata },
|
||||
TraktListItems = new List<TraktListItem>()
|
||||
};
|
||||
|
||||
if (item.IndexNumber.HasValue)
|
||||
@@ -578,7 +581,8 @@ namespace ErsatzTV.Infrastructure.Jellyfin
|
||||
ItemId = item.Id,
|
||||
Etag = item.Etag,
|
||||
MediaVersions = new List<MediaVersion> { version },
|
||||
EpisodeMetadata = new List<EpisodeMetadata> { metadata }
|
||||
EpisodeMetadata = new List<EpisodeMetadata> { metadata },
|
||||
TraktListItems = new List<TraktListItem>()
|
||||
};
|
||||
|
||||
return episode;
|
||||
|
||||
@@ -9,6 +9,7 @@ namespace ErsatzTV.Infrastructure.Locking
|
||||
private readonly ConcurrentDictionary<int, byte> _lockedLibraries;
|
||||
private readonly ConcurrentDictionary<Type, byte> _lockedRemoteMediaSourceTypes;
|
||||
private bool _plex;
|
||||
private bool _trakt;
|
||||
|
||||
public EntityLocker()
|
||||
{
|
||||
@@ -19,6 +20,7 @@ namespace ErsatzTV.Infrastructure.Locking
|
||||
public event EventHandler OnLibraryChanged;
|
||||
public event EventHandler OnPlexChanged;
|
||||
public event EventHandler<Type> OnRemoteMediaSourceChanged;
|
||||
public event EventHandler OnTraktChanged;
|
||||
|
||||
public bool LockLibrary(int libraryId)
|
||||
{
|
||||
@@ -100,5 +102,31 @@ namespace ErsatzTV.Infrastructure.Locking
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool LockTrakt()
|
||||
{
|
||||
if (!_trakt)
|
||||
{
|
||||
_trakt = true;
|
||||
OnTraktChanged?.Invoke(this, EventArgs.Empty);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool UnlockTrakt()
|
||||
{
|
||||
if (_trakt)
|
||||
{
|
||||
_trakt = false;
|
||||
OnTraktChanged?.Invoke(this, EventArgs.Empty);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool IsTraktLocked() => _trakt;
|
||||
}
|
||||
}
|
||||
|
||||
+3283
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,108 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Migrations
|
||||
{
|
||||
public partial class Add_TraktList : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "TraktList",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
TraktId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
User = table.Column<string>(type: "TEXT", nullable: true),
|
||||
List = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Name = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Description = table.Column<string>(type: "TEXT", nullable: true),
|
||||
ItemCount = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_TraktList", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "TraktListItem",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
TraktListId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Kind = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
TraktId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Rank = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Title = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Year = table.Column<int>(type: "INTEGER", nullable: true),
|
||||
Season = table.Column<int>(type: "INTEGER", nullable: true),
|
||||
Episode = table.Column<int>(type: "INTEGER", nullable: true),
|
||||
MediaItemId = table.Column<int>(type: "INTEGER", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_TraktListItem", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_TraktListItem_MediaItem_MediaItemId",
|
||||
column: x => x.MediaItemId,
|
||||
principalTable: "MediaItem",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_TraktListItem_TraktList_TraktListId",
|
||||
column: x => x.TraktListId,
|
||||
principalTable: "TraktList",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "TraktListItemGuid",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
Guid = table.Column<string>(type: "TEXT", nullable: true),
|
||||
TraktListItemId = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_TraktListItemGuid", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_TraktListItemGuid_TraktListItem_TraktListItemId",
|
||||
column: x => x.TraktListItemId,
|
||||
principalTable: "TraktListItem",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_TraktListItem_MediaItemId",
|
||||
table: "TraktListItem",
|
||||
column: "MediaItemId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_TraktListItem_TraktListId",
|
||||
table: "TraktListItem",
|
||||
column: "TraktListId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_TraktListItemGuid_TraktListItemId",
|
||||
table: "TraktListItemGuid",
|
||||
column: "TraktListItemId");
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "TraktListItemGuid");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "TraktListItem");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "TraktList");
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+3284
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Migrations
|
||||
{
|
||||
public partial class Add_TraktListItemDeleteSetNull : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_TraktListItem_MediaItem_MediaItemId",
|
||||
table: "TraktListItem");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_TraktListItem_MediaItem_MediaItemId",
|
||||
table: "TraktListItem",
|
||||
column: "MediaItemId",
|
||||
principalTable: "MediaItem",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_TraktListItem_MediaItem_MediaItemId",
|
||||
table: "TraktListItem");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_TraktListItem_MediaItem_MediaItemId",
|
||||
table: "TraktListItem",
|
||||
column: "MediaItemId",
|
||||
principalTable: "MediaItem",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1507,6 +1507,96 @@ namespace ErsatzTV.Infrastructure.Migrations
|
||||
b.ToTable("Tag");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ErsatzTV.Core.Domain.TraktList", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("ItemCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("List")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("TraktId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("User")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("TraktList");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ErsatzTV.Core.Domain.TraktListItem", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("Episode")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Kind")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("MediaItemId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Rank")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("Season")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("TraktId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("TraktListId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("Year")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("MediaItemId");
|
||||
|
||||
b.HasIndex("TraktListId");
|
||||
|
||||
b.ToTable("TraktListItem");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ErsatzTV.Core.Domain.TraktListItemGuid", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Guid")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("TraktListItemId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TraktListItemId");
|
||||
|
||||
b.ToTable("TraktListItemGuid");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ErsatzTV.Core.Domain.Writer", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@@ -2596,6 +2686,35 @@ namespace ErsatzTV.Infrastructure.Migrations
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ErsatzTV.Core.Domain.TraktListItem", b =>
|
||||
{
|
||||
b.HasOne("ErsatzTV.Core.Domain.MediaItem", "MediaItem")
|
||||
.WithMany("TraktListItems")
|
||||
.HasForeignKey("MediaItemId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("ErsatzTV.Core.Domain.TraktList", "TraktList")
|
||||
.WithMany("Items")
|
||||
.HasForeignKey("TraktListId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("MediaItem");
|
||||
|
||||
b.Navigation("TraktList");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ErsatzTV.Core.Domain.TraktListItemGuid", b =>
|
||||
{
|
||||
b.HasOne("ErsatzTV.Core.Domain.TraktListItem", "TraktListItem")
|
||||
.WithMany("Guids")
|
||||
.HasForeignKey("TraktListItemId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("TraktListItem");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ErsatzTV.Core.Domain.Writer", b =>
|
||||
{
|
||||
b.HasOne("ErsatzTV.Core.Domain.EpisodeMetadata", null)
|
||||
@@ -2979,6 +3098,8 @@ namespace ErsatzTV.Infrastructure.Migrations
|
||||
modelBuilder.Entity("ErsatzTV.Core.Domain.MediaItem", b =>
|
||||
{
|
||||
b.Navigation("CollectionItems");
|
||||
|
||||
b.Navigation("TraktListItems");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ErsatzTV.Core.Domain.MediaSource", b =>
|
||||
@@ -3083,6 +3204,16 @@ namespace ErsatzTV.Infrastructure.Migrations
|
||||
b.Navigation("MultiCollectionSmartItems");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ErsatzTV.Core.Domain.TraktList", b =>
|
||||
{
|
||||
b.Navigation("Items");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ErsatzTV.Core.Domain.TraktListItem", b =>
|
||||
{
|
||||
b.Navigation("Guids");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ErsatzTV.Core.Domain.Artist", b =>
|
||||
{
|
||||
b.Navigation("ArtistMetadata");
|
||||
|
||||
@@ -335,7 +335,8 @@ namespace ErsatzTV.Infrastructure.Plex
|
||||
{
|
||||
Key = response.Key,
|
||||
MovieMetadata = new List<MovieMetadata> { metadata },
|
||||
MediaVersions = new List<MediaVersion> { version }
|
||||
MediaVersions = new List<MediaVersion> { version },
|
||||
TraktListItems = new List<TraktListItem>()
|
||||
};
|
||||
|
||||
return movie;
|
||||
@@ -507,7 +508,8 @@ namespace ErsatzTV.Infrastructure.Plex
|
||||
var show = new PlexShow
|
||||
{
|
||||
Key = response.Key,
|
||||
ShowMetadata = new List<ShowMetadata> { metadata }
|
||||
ShowMetadata = new List<ShowMetadata> { metadata },
|
||||
TraktListItems = new List<TraktListItem>()
|
||||
};
|
||||
|
||||
return show;
|
||||
@@ -661,7 +663,8 @@ namespace ErsatzTV.Infrastructure.Plex
|
||||
{
|
||||
Key = response.Key,
|
||||
SeasonNumber = response.Index,
|
||||
SeasonMetadata = new List<SeasonMetadata> { metadata }
|
||||
SeasonMetadata = new List<SeasonMetadata> { metadata },
|
||||
TraktListItems = new List<TraktListItem>()
|
||||
};
|
||||
|
||||
return season;
|
||||
@@ -700,7 +703,8 @@ namespace ErsatzTV.Infrastructure.Plex
|
||||
{
|
||||
Key = response.Key,
|
||||
EpisodeMetadata = new List<EpisodeMetadata> { metadata },
|
||||
MediaVersions = new List<MediaVersion> { version }
|
||||
MediaVersions = new List<MediaVersion> { version },
|
||||
TraktListItems = new List<TraktListItem>()
|
||||
};
|
||||
|
||||
return episode;
|
||||
@@ -797,6 +801,13 @@ namespace ErsatzTV.Infrastructure.Plex
|
||||
{
|
||||
return guid;
|
||||
}
|
||||
|
||||
if (guid.StartsWith("com.plexapp.agents.imdb"))
|
||||
{
|
||||
string strip1 = guid.Replace("com.plexapp.agents.imdb://", string.Empty);
|
||||
string strip2 = strip1.Split("?").Head();
|
||||
return $"imdb://{strip2}";
|
||||
}
|
||||
|
||||
if (guid.StartsWith("com.plexapp.agents.thetvdb"))
|
||||
{
|
||||
|
||||
@@ -51,9 +51,11 @@ namespace ErsatzTV.Infrastructure.Search
|
||||
private const string ContentRatingField = "content_rating";
|
||||
private const string DirectorField = "director";
|
||||
private const string WriterField = "writer";
|
||||
private const string TraktListField = "trakt_list";
|
||||
|
||||
public const string MovieType = "movie";
|
||||
public const string ShowType = "show";
|
||||
public const string SeasonType = "season";
|
||||
public const string ArtistType = "artist";
|
||||
public const string MusicVideoType = "music_video";
|
||||
public const string EpisodeType = "episode";
|
||||
@@ -72,7 +74,7 @@ namespace ErsatzTV.Infrastructure.Search
|
||||
_initialized = false;
|
||||
}
|
||||
|
||||
public int Version => 15;
|
||||
public int Version => 16;
|
||||
|
||||
public Task<bool> Initialize(ILocalFileSystem localFileSystem)
|
||||
{
|
||||
@@ -106,6 +108,9 @@ namespace ErsatzTV.Infrastructure.Search
|
||||
case Show show:
|
||||
await UpdateShow(searchRepository, show);
|
||||
break;
|
||||
case Season season:
|
||||
await UpdateSeason(searchRepository, season);
|
||||
break;
|
||||
case Artist artist:
|
||||
await UpdateArtist(searchRepository, artist);
|
||||
break;
|
||||
@@ -200,6 +205,9 @@ namespace ErsatzTV.Infrastructure.Search
|
||||
case Show show:
|
||||
await UpdateShow(searchRepository, show);
|
||||
break;
|
||||
case Season season:
|
||||
await UpdateSeason(searchRepository, season);
|
||||
break;
|
||||
case Artist artist:
|
||||
await UpdateArtist(searchRepository, artist);
|
||||
break;
|
||||
@@ -339,6 +347,11 @@ namespace ErsatzTV.Infrastructure.Search
|
||||
doc.Add(new TextField(WriterField, writer.Name, Field.Store.NO));
|
||||
}
|
||||
|
||||
foreach (TraktListItem item in movie.TraktListItems)
|
||||
{
|
||||
doc.Add(new StringField(TraktListField, item.TraktList.TraktId.ToString(), Field.Store.NO));
|
||||
}
|
||||
|
||||
_writer.UpdateDocument(new Term(IdField, movie.Id.ToString()), doc);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -449,6 +462,11 @@ namespace ErsatzTV.Infrastructure.Search
|
||||
{
|
||||
doc.Add(new TextField(ActorField, actor.Name, Field.Store.NO));
|
||||
}
|
||||
|
||||
foreach (TraktListItem item in show.TraktListItems)
|
||||
{
|
||||
doc.Add(new StringField(TraktListField, item.TraktList.TraktId.ToString(), Field.Store.NO));
|
||||
}
|
||||
|
||||
_writer.UpdateDocument(new Term(IdField, show.Id.ToString()), doc);
|
||||
}
|
||||
@@ -459,6 +477,71 @@ namespace ErsatzTV.Infrastructure.Search
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task UpdateSeason(ISearchRepository searchRepository, Season season)
|
||||
{
|
||||
Option<SeasonMetadata> maybeMetadata = season.SeasonMetadata.HeadOrNone();
|
||||
Option<ShowMetadata> maybeShowMetadata = season.Show.ShowMetadata.HeadOrNone();
|
||||
if (maybeMetadata.IsSome && maybeShowMetadata.IsSome)
|
||||
{
|
||||
SeasonMetadata metadata = maybeMetadata.ValueUnsafe();
|
||||
ShowMetadata showMetadata = maybeShowMetadata.ValueUnsafe();
|
||||
|
||||
try
|
||||
{
|
||||
var seasonTitle = $"{showMetadata.Title} - S{season.SeasonNumber}";
|
||||
string sortTitle = $"{showMetadata.SortTitle}_{season.SeasonNumber:0000}"
|
||||
.ToLowerInvariant();
|
||||
string titleAndYear = $"{showMetadata.Title}_{showMetadata.Year}_{season.SeasonNumber}"
|
||||
.ToLowerInvariant();
|
||||
|
||||
var doc = new Document
|
||||
{
|
||||
new StringField(IdField, season.Id.ToString(), Field.Store.YES),
|
||||
new StringField(TypeField, SeasonType, Field.Store.YES),
|
||||
new TextField(TitleField, seasonTitle, Field.Store.NO),
|
||||
new StringField(SortTitleField, sortTitle, Field.Store.NO),
|
||||
new TextField(LibraryNameField, season.LibraryPath.Library.Name, Field.Store.NO),
|
||||
new StringField(LibraryIdField, season.LibraryPath.Library.Id.ToString(), Field.Store.NO),
|
||||
new StringField(TitleAndYearField, titleAndYear, Field.Store.NO),
|
||||
new StringField(JumpLetterField, GetJumpLetter(showMetadata), Field.Store.YES)
|
||||
};
|
||||
|
||||
List<string> languages = await searchRepository.GetLanguagesForSeason(season);
|
||||
await AddLanguages(searchRepository, doc, languages);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(showMetadata.ContentRating))
|
||||
{
|
||||
foreach (string contentRating in (showMetadata.ContentRating ?? string.Empty).Split("/")
|
||||
.Map(x => x.Trim()).Where(x => !string.IsNullOrWhiteSpace(x)))
|
||||
{
|
||||
doc.Add(new StringField(ContentRatingField, contentRating, Field.Store.NO));
|
||||
}
|
||||
}
|
||||
|
||||
if (metadata.ReleaseDate.HasValue)
|
||||
{
|
||||
doc.Add(
|
||||
new StringField(
|
||||
ReleaseDateField,
|
||||
metadata.ReleaseDate.Value.ToString("yyyyMMdd"),
|
||||
Field.Store.NO));
|
||||
}
|
||||
|
||||
foreach (TraktListItem item in season.TraktListItems)
|
||||
{
|
||||
doc.Add(new StringField(TraktListField, item.TraktList.TraktId.ToString(), Field.Store.NO));
|
||||
}
|
||||
|
||||
_writer.UpdateDocument(new Term(IdField, season.Id.ToString()), doc);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
metadata.Season = null;
|
||||
_logger.LogWarning(ex, "Error indexing show with metadata {@Metadata}", metadata);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task UpdateArtist(ISearchRepository searchRepository, Artist artist)
|
||||
{
|
||||
@@ -644,6 +727,11 @@ namespace ErsatzTV.Infrastructure.Search
|
||||
doc.Add(new TextField(WriterField, writer.Name, Field.Store.NO));
|
||||
}
|
||||
|
||||
foreach (TraktListItem item in episode.TraktListItems)
|
||||
{
|
||||
doc.Add(new StringField(TraktListField, item.TraktList.TraktId.ToString(), Field.Store.NO));
|
||||
}
|
||||
|
||||
_writer.UpdateDocument(new Term(IdField, episode.Id.ToString()), doc);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -681,7 +769,7 @@ namespace ErsatzTV.Infrastructure.Search
|
||||
.ToLowerInvariant(),
|
||||
_ => $"{metadata.Title}_{metadata.Year}".ToLowerInvariant()
|
||||
};
|
||||
|
||||
|
||||
private static string GetJumpLetter(Metadata metadata)
|
||||
{
|
||||
char c = metadata.SortTitle.ToLowerInvariant().Head();
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Infrastructure.Trakt.Models;
|
||||
using Refit;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Trakt
|
||||
{
|
||||
[Headers("Accept: application/json", "trakt-api-version: 2")]
|
||||
public interface ITraktApi
|
||||
{
|
||||
[Get("/users/{user}/lists/{list}")]
|
||||
Task<TraktListResponse> GetUserList(
|
||||
[Header("trakt-api-key")]
|
||||
string clientId,
|
||||
string user,
|
||||
string list);
|
||||
|
||||
[Get("/users/{user}/lists/{list}/items")]
|
||||
Task<List<TraktListItemResponse>> GetUserListItems(
|
||||
[Header("trakt-api-key")]
|
||||
string clientId,
|
||||
string user,
|
||||
string list);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace ErsatzTV.Infrastructure.Trakt.Models
|
||||
{
|
||||
public class TraktListIds
|
||||
{
|
||||
public int Trakt { get; set; }
|
||||
public string Slug { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace ErsatzTV.Infrastructure.Trakt.Models
|
||||
{
|
||||
public class TraktListItemEpisode
|
||||
{
|
||||
public int Season { get; set; }
|
||||
public int Number { get; set; }
|
||||
public string Title { get; set; }
|
||||
public TraktListItemIds Ids { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace ErsatzTV.Infrastructure.Trakt.Models
|
||||
{
|
||||
public class TraktListItemIds
|
||||
{
|
||||
public int Trakt { get; set; }
|
||||
public int? Tvdb { get; set; }
|
||||
public string Imdb { get; set; }
|
||||
public int? Tmdb { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace ErsatzTV.Infrastructure.Trakt.Models
|
||||
{
|
||||
public class TraktListItemMovie
|
||||
{
|
||||
public string Title { get; set; }
|
||||
public int? Year { get; set; }
|
||||
public TraktListItemIds Ids { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace ErsatzTV.Infrastructure.Trakt.Models
|
||||
{
|
||||
public class TraktListItemResponse
|
||||
{
|
||||
public int Rank { get; set; }
|
||||
public int Id { get; set; }
|
||||
public string Type { get; set; }
|
||||
public TraktListItemMovie Movie { get; set; }
|
||||
public TraktListItemShow Show { get; set; }
|
||||
public TraktListItemSeason Season { get; set; }
|
||||
public TraktListItemEpisode Episode { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace ErsatzTV.Infrastructure.Trakt.Models
|
||||
{
|
||||
public class TraktListItemSeason
|
||||
{
|
||||
public int Number { get; set; }
|
||||
public TraktListItemIds Ids { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace ErsatzTV.Infrastructure.Trakt.Models
|
||||
{
|
||||
public class TraktListItemShow
|
||||
{
|
||||
public string Title { get; set; }
|
||||
public int Year { get; set; }
|
||||
public TraktListItemIds Ids { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Trakt.Models
|
||||
{
|
||||
public class TraktListResponse
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public string Description { get; set; }
|
||||
[JsonProperty("item_count")]
|
||||
public int ItemCount { get; set; }
|
||||
public TraktListIds Ids { get; set; }
|
||||
public TraktUser User { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace ErsatzTV.Infrastructure.Trakt.Models
|
||||
{
|
||||
public class TraktUser
|
||||
{
|
||||
public string Username { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using Newtonsoft.Json.Serialization;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Trakt
|
||||
{
|
||||
public class DeliminatorSeparatedPropertyNamesContractResolver : DefaultContractResolver
|
||||
{
|
||||
readonly string separator;
|
||||
|
||||
protected DeliminatorSeparatedPropertyNamesContractResolver(char separator)
|
||||
{
|
||||
this.separator = separator.ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
protected override string ResolvePropertyName(string propertyName)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
var currentWord = new StringBuilder();
|
||||
|
||||
foreach (var c in propertyName.ToCharArray())
|
||||
{
|
||||
if (Char.IsUpper(c) && currentWord.Length > 0)
|
||||
{
|
||||
parts.Add(currentWord.ToString());
|
||||
currentWord.Clear();
|
||||
}
|
||||
|
||||
currentWord.Append(char.ToLower(c));
|
||||
}
|
||||
|
||||
if (currentWord.Length > 0)
|
||||
{
|
||||
parts.Add(currentWord.ToString());
|
||||
}
|
||||
|
||||
return String.Join(separator, parts.ToArray());
|
||||
}
|
||||
}
|
||||
|
||||
public class SnakeCasePropertyNamesContractResolver : DeliminatorSeparatedPropertyNamesContractResolver
|
||||
{
|
||||
public SnakeCasePropertyNamesContractResolver() : base('_') { }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Trakt;
|
||||
using ErsatzTV.Core.Trakt;
|
||||
using ErsatzTV.Infrastructure.Trakt.Models;
|
||||
using LanguageExt;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Newtonsoft.Json;
|
||||
using Refit;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Trakt
|
||||
{
|
||||
public class TraktApiClient : ITraktApiClient
|
||||
{
|
||||
private readonly ITraktApi _traktApi;
|
||||
private readonly IOptions<TraktConfiguration> _traktConfiguration;
|
||||
private readonly ILogger<TraktApiClient> _logger;
|
||||
|
||||
public TraktApiClient(
|
||||
ITraktApi traktApi,
|
||||
IOptions<TraktConfiguration> traktConfiguration,
|
||||
ILogger<TraktApiClient> logger)
|
||||
{
|
||||
_traktApi = traktApi;
|
||||
_traktConfiguration = traktConfiguration;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, TraktList>> GetUserList(string user, string list)
|
||||
{
|
||||
try
|
||||
{
|
||||
TraktListResponse response = await JsonService().GetUserList(
|
||||
_traktConfiguration.Value.ClientId,
|
||||
user,
|
||||
list);
|
||||
|
||||
return new TraktList
|
||||
{
|
||||
TraktId = response.Ids.Trakt,
|
||||
User = response.User.Username,
|
||||
List = response.Ids.Slug,
|
||||
Name = response.Name,
|
||||
Description = response.Description,
|
||||
ItemCount = response.ItemCount,
|
||||
Items = new List<TraktListItem>()
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error getting trakt list");
|
||||
return BaseError.New(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static ITraktApi JsonService()
|
||||
{
|
||||
return RestService.For<ITraktApi>(
|
||||
"https://api.trakt.tv",
|
||||
new RefitSettings
|
||||
{
|
||||
ContentSerializer = new NewtonsoftJsonContentSerializer(
|
||||
new JsonSerializerSettings {
|
||||
ContractResolver = new SnakeCasePropertyNamesContractResolver()
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, List<TraktListItemWithGuids>>> GetUserListItems(string user, string list)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = new List<TraktListItemWithGuids>();
|
||||
|
||||
List<TraktListItemResponse> apiItems = await _traktApi.GetUserListItems(
|
||||
_traktConfiguration.Value.ClientId,
|
||||
user,
|
||||
list);
|
||||
|
||||
foreach (TraktListItemResponse apiItem in apiItems)
|
||||
{
|
||||
TraktListItemWithGuids item = apiItem.Type.ToLowerInvariant() switch
|
||||
{
|
||||
"movie" => new TraktListItemWithGuids(
|
||||
apiItem.Id,
|
||||
apiItem.Rank,
|
||||
$"{apiItem.Movie.Title} ({apiItem.Movie.Year})",
|
||||
apiItem.Movie.Title,
|
||||
apiItem.Movie.Year,
|
||||
0,
|
||||
0,
|
||||
TraktListItemKind.Movie,
|
||||
GuidsFromIds(apiItem.Movie.Ids)),
|
||||
"show" => new TraktListItemWithGuids(
|
||||
apiItem.Id,
|
||||
apiItem.Rank,
|
||||
$"{apiItem.Show.Title} ({apiItem.Show.Year})",
|
||||
apiItem.Show.Title,
|
||||
apiItem.Show.Year,
|
||||
0,
|
||||
0,
|
||||
TraktListItemKind.Show,
|
||||
GuidsFromIds(apiItem.Show.Ids)),
|
||||
"season" => new TraktListItemWithGuids(
|
||||
apiItem.Id,
|
||||
apiItem.Rank,
|
||||
$"{apiItem.Show.Title} ({apiItem.Show.Year}) S{apiItem.Season.Number:00}",
|
||||
apiItem.Show.Title,
|
||||
apiItem.Show.Year,
|
||||
apiItem.Season.Number,
|
||||
0,
|
||||
TraktListItemKind.Season,
|
||||
GuidsFromIds(apiItem.Season.Ids)),
|
||||
"episode" => new TraktListItemWithGuids(
|
||||
apiItem.Id,
|
||||
apiItem.Rank,
|
||||
$"{apiItem.Show.Title} ({apiItem.Show.Year}) S{apiItem.Episode.Season:00}E{apiItem.Episode.Number:00}",
|
||||
apiItem.Show.Title,
|
||||
apiItem.Show.Year,
|
||||
apiItem.Episode.Season,
|
||||
apiItem.Episode.Number,
|
||||
TraktListItemKind.Episode,
|
||||
GuidsFromIds(apiItem.Episode.Ids)),
|
||||
_ => null
|
||||
};
|
||||
|
||||
if (item != null)
|
||||
{
|
||||
result.Add(item);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error getting trakt list items");
|
||||
return BaseError.New(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static List<string> GuidsFromIds(TraktListItemIds ids)
|
||||
{
|
||||
var result = new List<string>();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(ids.Imdb))
|
||||
{
|
||||
result.Add($"imdb://{ids.Imdb}");
|
||||
}
|
||||
|
||||
if (ids.Tmdb.HasValue)
|
||||
{
|
||||
result.Add($"tmdb://{ids.Tmdb.Value}");
|
||||
}
|
||||
|
||||
if (ids.Tvdb.HasValue)
|
||||
{
|
||||
result.Add($"tvdb://{ids.Tvdb.Value}");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,7 @@
|
||||
<s:Boolean x:Key="/Default/UserDictionary/Words/=setsar/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/UserDictionary/Words/=showtitle/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/UserDictionary/Words/=strm/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/UserDictionary/Words/=Trakt/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/UserDictionary/Words/=tvdb/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/UserDictionary/Words/=tvshow/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/UserDictionary/Words/=uniqueid/@EntryIndexedValue">True</s:Boolean>
|
||||
|
||||
@@ -30,11 +30,11 @@
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="16.10.56">
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.0.63">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="MudBlazor" Version="5.1.4" />
|
||||
<PackageReference Include="MudBlazor" Version="5.1.5" />
|
||||
<PackageReference Include="NaturalSort.Extension" Version="3.1.0" />
|
||||
<PackageReference Include="PPioli.FluentValidation.Blazor" Version="5.0.0" />
|
||||
<PackageReference Include="Refit.HttpClientFactory" Version="6.0.94" />
|
||||
|
||||
@@ -30,11 +30,10 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<div style="display: flex; flex-direction: row;">
|
||||
<div style="display: flex; flex-direction: row; align-items: center">
|
||||
<MudText Typo="Typo.h4">@_data.Name</MudText>
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Edit"
|
||||
Link="@($"/media/collections/{Id}/edit")"
|
||||
Style="margin-bottom: auto; margin-top: auto;"/>
|
||||
Link="@($"/media/collections/{Id}/edit")"/>
|
||||
</div>
|
||||
@if (_data.MovieCards.Any())
|
||||
{
|
||||
@@ -242,7 +241,7 @@
|
||||
|
||||
maybeResult.Match(
|
||||
result => _data = result,
|
||||
error => _navigationManager.NavigateTo("404"));
|
||||
_ => _navigationManager.NavigateTo("404"));
|
||||
}
|
||||
|
||||
private IOrderedEnumerable<MovieCardViewModel> OrderMovies(List<MovieCardViewModel> movies)
|
||||
|
||||
@@ -78,9 +78,7 @@ namespace ErsatzTV.Pages
|
||||
int finish = sorted.IndexOf(card);
|
||||
if (start > finish)
|
||||
{
|
||||
int temp = start;
|
||||
start = finish;
|
||||
finish = temp;
|
||||
(start, finish) = (finish, start);
|
||||
}
|
||||
|
||||
for (int i = start; i < finish; i++)
|
||||
@@ -97,6 +95,7 @@ namespace ErsatzTV.Pages
|
||||
protected Task AddSelectionToCollection() => AddItemsToCollection(
|
||||
_selectedItems.OfType<MovieCardViewModel>().Map(m => m.MovieId).ToList(),
|
||||
_selectedItems.OfType<TelevisionShowCardViewModel>().Map(s => s.TelevisionShowId).ToList(),
|
||||
_selectedItems.OfType<TelevisionSeasonCardViewModel>().Map(s => s.TelevisionSeasonId).ToList(),
|
||||
_selectedItems.OfType<TelevisionEpisodeCardViewModel>().Map(e => e.EpisodeId).ToList(),
|
||||
_selectedItems.OfType<ArtistCardViewModel>().Map(a => a.ArtistId).ToList(),
|
||||
_selectedItems.OfType<MusicVideoCardViewModel>().Map(mv => mv.MusicVideoId).ToList());
|
||||
@@ -104,12 +103,14 @@ namespace ErsatzTV.Pages
|
||||
protected async Task AddItemsToCollection(
|
||||
List<int> movieIds,
|
||||
List<int> showIds,
|
||||
List<int> seasonIds,
|
||||
List<int> episodeIds,
|
||||
List<int> artistIds,
|
||||
List<int> musicVideoIds,
|
||||
string entityName = "selected items")
|
||||
{
|
||||
int count = movieIds.Count + showIds.Count + episodeIds.Count + artistIds.Count + musicVideoIds.Count;
|
||||
int count = movieIds.Count + showIds.Count + seasonIds.Count + episodeIds.Count + artistIds.Count +
|
||||
musicVideoIds.Count;
|
||||
|
||||
var parameters = new DialogParameters
|
||||
{ { "EntityType", count.ToString() }, { "EntityName", entityName } };
|
||||
@@ -123,6 +124,7 @@ namespace ErsatzTV.Pages
|
||||
collection.Id,
|
||||
movieIds,
|
||||
showIds,
|
||||
seasonIds,
|
||||
episodeIds,
|
||||
artistIds,
|
||||
musicVideoIds);
|
||||
|
||||
@@ -44,6 +44,11 @@
|
||||
<MudLink Class="ml-4" Href="@(_navigationManager.Uri.Split("#").Head() + "#shows")" Style="margin-bottom: auto; margin-top: auto">@_shows.Count Shows</MudLink>
|
||||
}
|
||||
|
||||
if (_seasons?.Count > 0)
|
||||
{
|
||||
<MudLink Class="ml-4" Href="@(_navigationManager.Uri.Split("#").Head() + "#seasons")" Style="margin-bottom: auto; margin-top: auto">@_seasons.Count Seasons</MudLink>
|
||||
}
|
||||
|
||||
if (_episodes?.Count > 0)
|
||||
{
|
||||
<MudLink Class="ml-4" Href="@(_navigationManager.Uri.Split("#").Head() + "#episodes")" Style="margin-bottom: auto; margin-top: auto">@_episodes.Count Episodes</MudLink>
|
||||
@@ -133,6 +138,33 @@
|
||||
}
|
||||
</MudContainer>
|
||||
}
|
||||
|
||||
@if (_seasons?.Count > 0)
|
||||
{
|
||||
<div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
|
||||
<MudText Typo="Typo.h4"
|
||||
Style="scroll-margin-top: 160px"
|
||||
UserAttributes="@(new Dictionary<string, object> { { "id", "seasons" } })">
|
||||
Seasons
|
||||
</MudText>
|
||||
@if (_seasons.Count > 50)
|
||||
{
|
||||
<MudLink Href="@GetSeasonsLink()" Class="ml-4">See All >></MudLink>
|
||||
}
|
||||
</div>
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
|
||||
@foreach (TelevisionSeasonCardViewModel card in _seasons.Cards.OrderBy(s => s.SortTitle))
|
||||
{
|
||||
<MediaCard Data="@card"
|
||||
Link="@($"/media/tv/seasons/{card.TelevisionSeasonId}")"
|
||||
AddToCollectionClicked="@AddToCollection"
|
||||
SelectClicked="@(e => SelectClicked(card, e))"
|
||||
IsSelected="@IsSelected(card)"
|
||||
IsSelectMode="@IsSelectMode()"/>
|
||||
}
|
||||
</MudContainer>
|
||||
}
|
||||
|
||||
@if (_episodes?.Count > 0)
|
||||
{
|
||||
@@ -223,6 +255,7 @@
|
||||
private string _query;
|
||||
private MovieCardResultsViewModel _movies;
|
||||
private TelevisionShowCardResultsViewModel _shows;
|
||||
private TelevisionSeasonCardResultsViewModel _seasons;
|
||||
private TelevisionEpisodeCardResultsViewModel _episodes;
|
||||
private MusicVideoCardResultsViewModel _musicVideos;
|
||||
private ArtistCardResultsViewModel _artists;
|
||||
@@ -234,6 +267,7 @@
|
||||
{
|
||||
_movies = await Mediator.Send(new QuerySearchIndexMovies($"type:movie AND ({_query})", 1, 50));
|
||||
_shows = await Mediator.Send(new QuerySearchIndexShows($"type:show AND ({_query})", 1, 50));
|
||||
_seasons = await Mediator.Send(new QuerySearchIndexSeasons($"type:season AND ({_query})", 1, 50));
|
||||
_episodes = await Mediator.Send(new QuerySearchIndexEpisodes($"type:episode AND ({_query})", 1, 50));
|
||||
_musicVideos = await Mediator.Send(new QuerySearchIndexMusicVideos($"type:music_video AND ({_query})", 1, 50));
|
||||
_artists = await Mediator.Send(new QuerySearchIndexArtists($"type:artist AND ({_query})", 1, 50));
|
||||
@@ -246,6 +280,7 @@
|
||||
{
|
||||
return _movies.Cards.OrderBy(m => m.SortTitle)
|
||||
.Append<MediaCardViewModel>(_shows.Cards.OrderBy(s => s.SortTitle))
|
||||
.Append(_seasons.Cards.OrderBy(s => s.SortTitle))
|
||||
.Append(_episodes.Cards.OrderBy(ep => ep.SortTitle))
|
||||
.Append(_artists.Cards.OrderBy(a => a.SortTitle))
|
||||
.Append(_musicVideos.Cards.OrderBy(mv => mv.SortTitle))
|
||||
@@ -384,6 +419,17 @@
|
||||
}
|
||||
return uri;
|
||||
}
|
||||
|
||||
private string GetSeasonsLink()
|
||||
{
|
||||
var uri = "/media/tv/seasons/page/1";
|
||||
if (!string.IsNullOrWhiteSpace(_query))
|
||||
{
|
||||
(string key, string value) = _query.EncodeQuery();
|
||||
uri = $"{uri}?{key}={value}";
|
||||
}
|
||||
return uri;
|
||||
}
|
||||
|
||||
private string GetEpisodesLink()
|
||||
{
|
||||
@@ -421,7 +467,14 @@
|
||||
private async Task AddAllToCollection(MouseEventArgs _)
|
||||
{
|
||||
SearchResultAllItemsViewModel results = await Mediator.Send(new QuerySearchIndexAllItems(_query));
|
||||
await AddItemsToCollection(results.MovieIds, results.ShowIds, results.EpisodeIds, results.ArtistIds, results.MusicVideoIds, "search results");
|
||||
await AddItemsToCollection(
|
||||
results.MovieIds,
|
||||
results.ShowIds,
|
||||
results.SeasonIds,
|
||||
results.EpisodeIds,
|
||||
results.ArtistIds,
|
||||
results.MusicVideoIds,
|
||||
"search results");
|
||||
}
|
||||
|
||||
private async Task SaveAsSmartCollection(MouseEventArgs _)
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
@page "/media/tv/seasons"
|
||||
@page "/media/tv/seasons/page/{PageNumber:int}"
|
||||
@using LanguageExt.UnsafeValueAccess
|
||||
@using ErsatzTV.Application.MediaCards
|
||||
@using ErsatzTV.Application.MediaCollections
|
||||
@using ErsatzTV.Application.MediaCollections.Commands
|
||||
@using ErsatzTV.Application.Search.Queries
|
||||
@using ErsatzTV.Extensions
|
||||
@using Unit = LanguageExt.Unit
|
||||
@inherits MultiSelectBase<TelevisionShowList>
|
||||
@inject NavigationManager _navigationManager
|
||||
@inject ChannelWriter<IBackgroundServiceRequest> _channel
|
||||
|
||||
<MudPaper Square="true" Style="display: flex; height: 64px; left: 240px; padding: 0; position: fixed; right: 0; z-index: 100;">
|
||||
<div style="display: flex; flex-direction: row; margin-bottom: auto; margin-top: auto; width: 100%" class="ml-6 mr-6">
|
||||
@if (IsSelectMode())
|
||||
{
|
||||
<MudText Typo="Typo.h6" Color="Color.Primary">@SelectionLabel()</MudText>
|
||||
<div style="margin-left: auto">
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Filled.Add"
|
||||
OnClick="@(_ => AddSelectionToCollection())">
|
||||
Add To Collection
|
||||
</MudButton>
|
||||
<MudButton Class="ml-3"
|
||||
Variant="Variant.Filled"
|
||||
Color="Color.Secondary"
|
||||
StartIcon="@Icons.Material.Filled.Check"
|
||||
OnClick="@(_ => ClearSelection())">
|
||||
Clear Selection
|
||||
</MudButton>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Style="margin-bottom: auto; margin-top: auto; width: 33%">@_query</MudText>
|
||||
<div style="max-width: 300px; width: 33%;">
|
||||
<MudPaper Style="align-items: center; display: flex; justify-content: center;">
|
||||
<MudIconButton Icon="@Icons.Material.Outlined.ChevronLeft"
|
||||
OnClick="@PrevPage"
|
||||
Disabled="@(PageNumber <= 1)">
|
||||
</MudIconButton>
|
||||
<MudText Style="flex-grow: 1"
|
||||
Align="Align.Center">
|
||||
@Math.Min((PageNumber - 1) * PageSize + 1, _data.Count)-@Math.Min(_data.Count, PageNumber * PageSize) of @_data.Count
|
||||
</MudText>
|
||||
<MudIconButton Icon="@Icons.Material.Outlined.ChevronRight"
|
||||
OnClick="@NextPage" Disabled="@(PageNumber * PageSize >= _data.Count)">
|
||||
</MudIconButton>
|
||||
</MudPaper>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</MudPaper>
|
||||
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8" Style="margin-top: 64px">
|
||||
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
|
||||
<FragmentLetterAnchor TCard="TelevisionSeasonCardViewModel" Cards="@_data.Cards">
|
||||
<MediaCard Data="@context"
|
||||
Link="@($"/media/tv/seasons/{context.TelevisionSeasonId}")"
|
||||
AddToCollectionClicked="@AddToCollection"
|
||||
SelectClicked="@(e => SelectClicked(context, e))"
|
||||
IsSelected="@IsSelected(context)"
|
||||
IsSelectMode="@IsSelectMode()"/>
|
||||
</FragmentLetterAnchor>
|
||||
</MudContainer>
|
||||
</MudContainer>
|
||||
@if (_data.PageMap.IsSome)
|
||||
{
|
||||
<LetterBar PageMap="@_data.PageMap.ValueUnsafe()"
|
||||
BaseUri="/media/tv/seasons"
|
||||
Query="@_query"/>
|
||||
}
|
||||
|
||||
@code {
|
||||
private static int PageSize => 100;
|
||||
|
||||
[Parameter]
|
||||
public int PageNumber { get; set; }
|
||||
|
||||
private TelevisionSeasonCardResultsViewModel _data;
|
||||
private string _query;
|
||||
|
||||
protected override Task OnParametersSetAsync()
|
||||
{
|
||||
if (PageNumber == 0)
|
||||
{
|
||||
PageNumber = 1;
|
||||
}
|
||||
|
||||
_query = _navigationManager.Uri.GetSearchQuery();
|
||||
return RefreshData();
|
||||
}
|
||||
|
||||
protected override async Task RefreshData()
|
||||
{
|
||||
string searchQuery = string.IsNullOrWhiteSpace(_query) ? "type:season" : $"type:season AND ({_query})";
|
||||
_data = await Mediator.Send(new QuerySearchIndexSeasons(searchQuery, PageNumber, PageSize));
|
||||
}
|
||||
|
||||
private void PrevPage()
|
||||
{
|
||||
var uri = $"/media/tv/seasons/page/{PageNumber - 1}";
|
||||
if (!string.IsNullOrWhiteSpace(_query))
|
||||
{
|
||||
(string key, string value) = _query.EncodeQuery();
|
||||
uri = $"{uri}?{key}={value}";
|
||||
}
|
||||
_navigationManager.NavigateTo(uri);
|
||||
}
|
||||
|
||||
private void NextPage()
|
||||
{
|
||||
var uri = $"/media/tv/seasons/page/{PageNumber + 1}";
|
||||
if (!string.IsNullOrWhiteSpace(_query))
|
||||
{
|
||||
(string key, string value) = _query.EncodeQuery();
|
||||
uri = $"{uri}?{key}={value}";
|
||||
}
|
||||
_navigationManager.NavigateTo(uri);
|
||||
}
|
||||
|
||||
private void SelectClicked(MediaCardViewModel card, MouseEventArgs e)
|
||||
{
|
||||
List<MediaCardViewModel> GetSortedItems()
|
||||
{
|
||||
return _data.Cards.OrderBy(m => m.SortTitle).ToList<MediaCardViewModel>();
|
||||
}
|
||||
|
||||
SelectClicked(GetSortedItems, card, e);
|
||||
}
|
||||
|
||||
private async Task AddToCollection(MediaCardViewModel card)
|
||||
{
|
||||
if (card is TelevisionSeasonCardViewModel season)
|
||||
{
|
||||
var parameters = new DialogParameters { { "EntityType", "season" }, { "EntityName", season.Title } };
|
||||
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
|
||||
|
||||
IDialogReference dialog = Dialog.Show<AddToCollectionDialog>("Add To Collection", parameters, options);
|
||||
DialogResult result = await dialog.Result;
|
||||
if (!result.Cancelled && result.Data is MediaCollectionViewModel collection)
|
||||
{
|
||||
var request = new AddSeasonToCollection(collection.Id, season.TelevisionSeasonId);
|
||||
Either<BaseError, Unit> addResult = await Mediator.Send(request);
|
||||
addResult.Match(
|
||||
Left: error =>
|
||||
{
|
||||
Snackbar.Add($"Unexpected error adding season to collection: {error.Value}");
|
||||
Logger.LogError("Unexpected error adding season to collection: {Error}", error.Value);
|
||||
},
|
||||
Right: _ => Snackbar.Add($"Added {season.Title} to collection {collection.Name}", Severity.Success));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user