Browse Source

better trakt lists (#384)

* better trakt list support

* update dependencies

* revert unneeded brackets
pull/385/head
Jason Dove 4 years ago committed by GitHub
parent
commit
e6446f9983
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 8
      CHANGELOG.md
  2. 2
      ErsatzTV.Application/ErsatzTV.Application.csproj
  3. 3
      ErsatzTV.Application/MediaCollections/Commands/AddTraktList.cs
  4. 80
      ErsatzTV.Application/MediaCollections/Commands/AddTraktListHandler.cs
  5. 12
      ErsatzTV.Application/MediaCollections/Commands/CreateMultiCollectionHandler.cs
  6. 9
      ErsatzTV.Application/MediaCollections/Commands/DeleteTraktList.cs
  7. 79
      ErsatzTV.Application/MediaCollections/Commands/DeleteTraktListHandler.cs
  8. 10
      ErsatzTV.Application/MediaCollections/Commands/MatchTraktListItems.cs
  9. 58
      ErsatzTV.Application/MediaCollections/Commands/MatchTraktListItemsHandler.cs
  10. 269
      ErsatzTV.Application/MediaCollections/Commands/SyncCollectionFromTraktListHandler.cs
  11. 324
      ErsatzTV.Application/MediaCollections/Commands/TraktCommandBase.cs
  12. 9
      ErsatzTV.Application/MediaCollections/Mapper.cs
  13. 6
      ErsatzTV.Application/MediaCollections/PagedTraktListsViewModel.cs
  14. 6
      ErsatzTV.Application/MediaCollections/Queries/GetPagedTraktLists.cs
  15. 47
      ErsatzTV.Application/MediaCollections/Queries/GetPagedTraktListsHandler.cs
  16. 4
      ErsatzTV.Application/MediaCollections/TraktListViewModel.cs
  17. 2
      ErsatzTV.Core.Tests/ErsatzTV.Core.Tests.csproj
  18. 16
      ErsatzTV.Core/Domain/Collection/TraktList.cs
  19. 31
      ErsatzTV.Core/Domain/Collection/TraktListItem.cs
  20. 10
      ErsatzTV.Core/Domain/Collection/TraktListItemGuid.cs
  21. 2
      ErsatzTV.Core/Domain/Collection/TraktListItemKind.cs
  22. 1
      ErsatzTV.Core/Domain/ConfigElementKey.cs
  23. 1
      ErsatzTV.Core/Domain/MediaItem/MediaItem.cs
  24. 2
      ErsatzTV.Core/ErsatzTV.Core.csproj
  25. 4
      ErsatzTV.Core/Interfaces/Locking/IEntityLocker.cs
  26. 6
      ErsatzTV.Core/Interfaces/Trakt/ITraktApiClient.cs
  27. 3
      ErsatzTV.Core/Trakt/TraktListItemWithGuids.cs
  28. 19
      ErsatzTV.Infrastructure/Data/Configurations/Collection/TraktListConfiguration.cs
  29. 21
      ErsatzTV.Infrastructure/Data/Configurations/Collection/TraktListItemConfiguration.cs
  30. 11
      ErsatzTV.Infrastructure/Data/Configurations/Collection/TraktListItemGuidConfiguration.cs
  31. 4
      ErsatzTV.Infrastructure/Data/Repositories/EmbyTelevisionRepository.cs
  32. 4
      ErsatzTV.Infrastructure/Data/Repositories/JellyfinTelevisionRepository.cs
  33. 11
      ErsatzTV.Infrastructure/Data/Repositories/MovieRepository.cs
  34. 5
      ErsatzTV.Infrastructure/Data/Repositories/MusicVideoRepository.cs
  35. 2
      ErsatzTV.Infrastructure/Data/Repositories/SearchRepository.cs
  36. 14
      ErsatzTV.Infrastructure/Data/Repositories/TelevisionRepository.cs
  37. 1
      ErsatzTV.Infrastructure/Data/TvContext.cs
  38. 12
      ErsatzTV.Infrastructure/Emby/EmbyApiClient.cs
  39. 3
      ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj
  40. 4
      ErsatzTV.Infrastructure/Health/Checks/EpisodeMetadataHealthCheck.cs
  41. 4
      ErsatzTV.Infrastructure/Health/Checks/MovieMetadataHealthCheck.cs
  42. 12
      ErsatzTV.Infrastructure/Jellyfin/JellyfinApiClient.cs
  43. 28
      ErsatzTV.Infrastructure/Locking/EntityLocker.cs
  44. 3283
      ErsatzTV.Infrastructure/Migrations/20210925032411_Add_TraktList.Designer.cs
  45. 108
      ErsatzTV.Infrastructure/Migrations/20210925032411_Add_TraktList.cs
  46. 130
      ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs
  47. 12
      ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs
  48. 16
      ErsatzTV.Infrastructure/Search/SearchIndex.cs
  49. 9
      ErsatzTV.Infrastructure/Trakt/ITraktApi.cs
  50. 8
      ErsatzTV.Infrastructure/Trakt/Models/TraktListIds.cs
  51. 2
      ErsatzTV.Infrastructure/Trakt/Models/TraktListItemResponse.cs
  52. 14
      ErsatzTV.Infrastructure/Trakt/Models/TraktListResponse.cs
  53. 7
      ErsatzTV.Infrastructure/Trakt/Models/TraktUser.cs
  54. 47
      ErsatzTV.Infrastructure/Trakt/SnakeCasePropertyNamesContractResolver.cs
  55. 59
      ErsatzTV.Infrastructure/Trakt/TraktApiClient.cs
  56. 2
      ErsatzTV/ErsatzTV.csproj
  57. 38
      ErsatzTV/Pages/CollectionItems.razor
  58. 149
      ErsatzTV/Pages/TraktLists.razor
  59. 22
      ErsatzTV/Services/SchedulerService.cs
  60. 10
      ErsatzTV/Services/WorkerService.cs
  61. 4
      ErsatzTV/Shared/AddTraktListDialog.razor
  62. 3
      ErsatzTV/Shared/MainLayout.razor

8
CHANGELOG.md

@ -5,9 +5,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). @@ -5,9 +5,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased]
### Added
- Add ability to sync Trakt lists to a collection
- This sync is one-time (manual button) and one-way (Trakt => ErsatzTV)
- After synchronization, the collection will *only* contain media items found in the Trakt list
- 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
### Fixed
- Fix local television scanner to properly update episode metadata when NFO files have been added/changed

2
ErsatzTV.Application/ErsatzTV.Application.csproj

@ -13,7 +13,7 @@ @@ -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>

3
ErsatzTV.Application/MediaCollections/Commands/SyncCollectionFromTraktList.cs → ErsatzTV.Application/MediaCollections/Commands/AddTraktList.cs

@ -5,6 +5,5 @@ using Unit = LanguageExt.Unit; @@ -5,6 +5,5 @@ using Unit = LanguageExt.Unit;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public record SyncCollectionFromTraktList
(int CollectionId, string TraktListUrl) : IRequest<Either<BaseError, Unit>>;
public record AddTraktList(string TraktListUrl) : IRequest<Either<BaseError, Unit>>, IBackgroundServiceRequest;
}

80
ErsatzTV.Application/MediaCollections/Commands/AddTraktListHandler.cs

@ -0,0 +1,80 @@ @@ -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);
}
}

12
ErsatzTV.Application/MediaCollections/Commands/CreateMultiCollectionHandler.cs

@ -56,7 +56,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands @@ -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 @@ -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 @@ -89,10 +87,8 @@ namespace ErsatzTV.Application.MediaCollections.Commands
});
}
return None;
return Option<MultiCollectionSmartItem>.None;
})
.Sequence()
.Flatten()
.ToList()
});

9
ErsatzTV.Application/MediaCollections/Commands/DeleteTraktList.cs

@ -0,0 +1,9 @@ @@ -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;
}

79
ErsatzTV.Application/MediaCollections/Commands/DeleteTraktListHandler.cs

@ -0,0 +1,79 @@ @@ -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;
}
}
}

10
ErsatzTV.Application/MediaCollections/Commands/MatchTraktListItems.cs

@ -0,0 +1,10 @@ @@ -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;
}

58
ErsatzTV.Application/MediaCollections/Commands/MatchTraktListItemsHandler.cs

@ -0,0 +1,58 @@ @@ -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();
}
}
}
}
}

269
ErsatzTV.Application/MediaCollections/Commands/SyncCollectionFromTraktListHandler.cs

@ -1,269 +0,0 @@ @@ -1,269 +0,0 @@
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using ErsatzTV.Application.Playouts.Commands;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Trakt;
using ErsatzTV.Core.Trakt;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using LanguageExt;
using MediatR;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using static LanguageExt.Prelude;
using Unit = LanguageExt.Unit;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public class
SyncCollectionFromTraktListHandler : IRequestHandler<SyncCollectionFromTraktList, Either<BaseError, Unit>>
{
private readonly ITraktApiClient _traktApiClient;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly IMediaCollectionRepository _mediaCollectionRepository;
private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
private readonly ILogger<SyncCollectionFromTraktListHandler> _logger;
public SyncCollectionFromTraktListHandler(
ITraktApiClient traktApiClient,
IDbContextFactory<TvContext> dbContextFactory,
IMediaCollectionRepository mediaCollectionRepository,
ChannelWriter<IBackgroundServiceRequest> channel,
ILogger<SyncCollectionFromTraktListHandler> logger)
{
_traktApiClient = traktApiClient;
_dbContextFactory = dbContextFactory;
_mediaCollectionRepository = mediaCollectionRepository;
_channel = channel;
_logger = logger;
}
public async Task<Either<BaseError, Unit>> Handle(
SyncCollectionFromTraktList request,
CancellationToken cancellationToken)
{
// validate and parse user/list from URL
const string PATTERN = @"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;
Either<BaseError, List<TraktListItemWithGuids>> maybeItems =
await _traktApiClient.GetUserListItems(user, list);
return await maybeItems.Match(
async items => await SyncCollectionFromItems(request.CollectionId, items),
error => Task.FromResult(Left<BaseError, Unit>(error)));
}
return BaseError.New("Invalid Trakt List URL");
}
private async Task<Either<BaseError, Unit>> SyncCollectionFromItems(int collectionId, List<TraktListItemWithGuids> items)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
Option<Collection> maybeCollection = await dbContext.Collections
.Include(c => c.MediaItems)
.SelectOneAsync(c => c.Id, c => c.Id == collectionId);
foreach (Collection collection in maybeCollection)
{
var movieIds = new System.Collections.Generic.HashSet<int>();
foreach (TraktListItemWithGuids item in items.Filter(i => i.Kind == TraktListItemKind.Movie))
{
foreach (int movieId in await IdentifyMovie(dbContext, item))
{
movieIds.Add(movieId);
}
}
var showIds = new System.Collections.Generic.HashSet<int>();
foreach (TraktListItemWithGuids item in items.Filter(i => i.Kind == TraktListItemKind.Show))
{
foreach (int showId in await IdentifyShow(dbContext, item))
{
showIds.Add(showId);
}
}
var seasonIds = new System.Collections.Generic.HashSet<int>();
foreach (TraktListItemWithGuids item in items.Filter(i => i.Kind == TraktListItemKind.Season))
{
foreach (int seasonId in await IdentifySeason(dbContext, item))
{
seasonIds.Add(seasonId);
}
}
var episodeIds = new System.Collections.Generic.HashSet<int>();
foreach (TraktListItemWithGuids item in items.Filter(i => i.Kind == TraktListItemKind.Episode))
{
foreach (int episodeId in await IdentifyEpisode(dbContext, item))
{
episodeIds.Add(episodeId);
}
}
var allIds = movieIds
.Append(showIds)
.Append(seasonIds)
.Append(episodeIds)
.ToList();
collection.MediaItems.RemoveAll(mi => !allIds.Contains(mi.Id));
List<MediaItem> toAdd = await dbContext.MediaItems
.Filter(mi => allIds.Contains(mi.Id))
.ToListAsync();
collection.MediaItems.AddRange(toAdd);
if (await dbContext.SaveChangesAsync() > 0)
{
// rebuild all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository.PlayoutIdsUsingCollection(collectionId))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, true));
}
}
}
return Unit.Default;
}
private async Task<Option<int>> IdentifyMovie(TvContext dbContext, TraktListItemWithGuids item)
{
Option<int> maybeMovieByGuid = await dbContext.MovieMetadata
.Filter(mm => mm.Guids.Any(g => item.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, TraktListItemWithGuids item)
{
Option<int> maybeShowByGuid = await dbContext.ShowMetadata
.Filter(sm => sm.Guids.Any(g => item.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, TraktListItemWithGuids item)
{
Option<int> maybeSeasonByGuid = await dbContext.SeasonMetadata
.Filter(sm => sm.Guids.Any(g => item.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, TraktListItemWithGuids item)
{
Option<int> maybeEpisodeByGuid = await dbContext.EpisodeMetadata
.Filter(sm => sm.Guids.Any(g => item.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;
}
}
}

324
ErsatzTV.Application/MediaCollections/Commands/TraktCommandBase.cs

@ -0,0 +1,324 @@ @@ -0,0 +1,324 @@
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();
// TODO: do we need to update?
list.Items.RemoveAll(toRemove.Contains);
list.Items.AddRange(toAdd.Map(a => ProjectItem(list, a)));
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)
{
// TODO: 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;
}
}
}

9
ErsatzTV.Application/MediaCollections/Mapper.cs

@ -19,6 +19,15 @@ namespace ErsatzTV.Application.MediaCollections @@ -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,

6
ErsatzTV.Application/MediaCollections/PagedTraktListsViewModel.cs

@ -0,0 +1,6 @@ @@ -0,0 +1,6 @@
using System.Collections.Generic;
namespace ErsatzTV.Application.MediaCollections
{
public record PagedTraktListsViewModel(int TotalCount, List<TraktListViewModel> Page);
}

6
ErsatzTV.Application/MediaCollections/Queries/GetPagedTraktLists.cs

@ -0,0 +1,6 @@ @@ -0,0 +1,6 @@
using MediatR;
namespace ErsatzTV.Application.MediaCollections.Queries
{
public record GetPagedTraktLists(int PageNum, int PageSize) : IRequest<PagedTraktListsViewModel>;
}

47
ErsatzTV.Application/MediaCollections/Queries/GetPagedTraktListsHandler.cs

@ -0,0 +1,47 @@ @@ -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);
}
}
}

4
ErsatzTV.Application/MediaCollections/TraktListViewModel.cs

@ -0,0 +1,4 @@ @@ -0,0 +1,4 @@
namespace ErsatzTV.Application.MediaCollections
{
public record TraktListViewModel(int Id, int TraktId, string Slug, string Name, int ItemCount, int MatchCount);
}

2
ErsatzTV.Core.Tests/ErsatzTV.Core.Tests.csproj

@ -14,7 +14,7 @@ @@ -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>

16
ErsatzTV.Core/Domain/Collection/TraktList.cs

@ -0,0 +1,16 @@ @@ -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; }
}
}

31
ErsatzTV.Core/Domain/Collection/TraktListItem.cs

@ -0,0 +1,31 @@ @@ -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}"
};
}
}

10
ErsatzTV.Core/Domain/Collection/TraktListItemGuid.cs

@ -0,0 +1,10 @@ @@ -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; }
}
}

2
ErsatzTV.Core/Trakt/TraktListItemKind.cs → ErsatzTV.Core/Domain/Collection/TraktListItemKind.cs

@ -1,4 +1,4 @@ @@ -1,4 +1,4 @@
namespace ErsatzTV.Core.Trakt
namespace ErsatzTV.Core.Domain
{
public enum TraktListItemKind
{

1
ErsatzTV.Core/Domain/ConfigElementKey.cs

@ -25,6 +25,7 @@ @@ -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");
}

1
ErsatzTV.Core/Domain/MediaItem/MediaItem.cs

@ -9,5 +9,6 @@ namespace ErsatzTV.Core.Domain @@ -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; }
}
}

2
ErsatzTV.Core/ErsatzTV.Core.csproj

@ -16,7 +16,7 @@ @@ -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>

4
ErsatzTV.Core/Interfaces/Locking/IEntityLocker.cs

@ -7,6 +7,7 @@ namespace ErsatzTV.Core.Interfaces.Locking @@ -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 @@ -16,5 +17,8 @@ namespace ErsatzTV.Core.Interfaces.Locking
bool IsRemoteMediaSourceLocked<TMediaSource>();
bool LockRemoteMediaSource<TMediaSource>();
bool UnlockRemoteMediaSource<TMediaSource>();
bool IsTraktLocked();
bool LockTrakt();
bool UnlockTrakt();
}
}

6
ErsatzTV.Core/Interfaces/Trakt/ITraktApiClient.cs

@ -1,5 +1,6 @@ @@ -1,5 +1,6 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Trakt;
using LanguageExt;
@ -7,8 +8,7 @@ namespace ErsatzTV.Core.Interfaces.Trakt @@ -7,8 +8,7 @@ namespace ErsatzTV.Core.Interfaces.Trakt
{
public interface ITraktApiClient
{
Task<Either<BaseError, List<TraktListItemWithGuids>>> GetUserListItems(
string user,
string list);
Task<Either<BaseError, TraktList>> GetUserList(string user, string list);
Task<Either<BaseError, List<TraktListItemWithGuids>>> GetUserListItems(string user, string list);
}
}

3
ErsatzTV.Core/Trakt/TraktListItemWithGuids.cs

@ -1,8 +1,11 @@ @@ -1,8 +1,11 @@
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,

19
ErsatzTV.Infrastructure/Data/Configurations/Collection/TraktListConfiguration.cs

@ -0,0 +1,19 @@ @@ -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);
}
}
}

21
ErsatzTV.Infrastructure/Data/Configurations/Collection/TraktListItemConfiguration.cs

@ -0,0 +1,21 @@ @@ -0,0 +1,21 @@
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);
builder.HasMany(i => i.Guids)
.WithOne(g => g.TraktListItem)
.HasForeignKey(g => g.TraktListItemId);
}
}
}

11
ErsatzTV.Infrastructure/Data/Configurations/Collection/TraktListItemGuidConfiguration.cs

@ -0,0 +1,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");
}
}

4
ErsatzTV.Infrastructure/Data/Repositories/EmbyTelevisionRepository.cs

@ -90,6 +90,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -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();
@ -432,6 +434,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -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();

4
ErsatzTV.Infrastructure/Data/Repositories/JellyfinTelevisionRepository.cs

@ -90,6 +90,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -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();
@ -432,6 +434,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -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();

11
ErsatzTV.Infrastructure/Data/Repositories/MovieRepository.cs

@ -89,6 +89,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -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 @@ -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 @@ -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 @@ -570,6 +576,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Include(m => m.MovieMetadata)
.ThenInclude(mm => mm.Guids)
.Filter(m => m.ItemId == movie.ItemId)
.Include(m => m.TraktListItems)
.ThenInclude(tli => tli.TraktList)
.OrderBy(m => m.ItemId)
.SingleOrDefaultAsync();
@ -786,7 +794,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -786,7 +794,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
},
Streams = new List<MediaStream>()
}
}
},
TraktListItems = new List<TraktListItem>()
};
await dbContext.Movies.AddAsync(movie);
await dbContext.SaveChangesAsync();

5
ErsatzTV.Infrastructure/Data/Repositories/MusicVideoRepository.cs

@ -47,6 +47,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -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 @@ -188,7 +190,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
},
Streams = new List<MediaStream>()
}
}
},
TraktListItems = new List<TraktListItem>()
};
await dbContext.MusicVideos.AddAsync(musicVideo);

2
ErsatzTV.Infrastructure/Data/Repositories/SearchRepository.cs

@ -87,6 +87,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -87,6 +87,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);

14
ErsatzTV.Infrastructure/Data/Repositories/TelevisionRepository.cs

@ -221,6 +221,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -221,6 +221,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 +249,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -247,7 +249,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);
@ -311,6 +314,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -311,6 +314,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 +419,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -414,6 +419,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);
@ -469,6 +476,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -469,6 +476,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);
@ -724,7 +733,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -724,7 +733,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
},
Streams = new List<MediaStream>()
}
}
},
TraktListItems = new List<TraktListItem>()
};
await dbContext.Episodes.AddAsync(episode);
await dbContext.SaveChangesAsync();

1
ErsatzTV.Infrastructure/Data/TvContext.cs

@ -69,6 +69,7 @@ namespace ErsatzTV.Infrastructure.Data @@ -69,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);

12
ErsatzTV.Infrastructure/Emby/EmbyApiClient.cs

@ -211,7 +211,8 @@ namespace ErsatzTV.Infrastructure.Emby @@ -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 @@ -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 @@ -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 @@ -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;

3
ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj

@ -22,11 +22,12 @@ @@ -22,11 +22,12 @@
<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="Refit.Newtonsoft.Json" Version="6.0.94" />
<PackageReference Include="SixLabors.ImageSharp" Version="1.0.3" />
</ItemGroup>

4
ErsatzTV.Infrastructure/Health/Checks/EpisodeMetadataHealthCheck.cs

@ -35,9 +35,7 @@ namespace ErsatzTV.Infrastructure.Health.Checks @@ -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();

4
ErsatzTV.Infrastructure/Health/Checks/MovieMetadataHealthCheck.cs

@ -35,9 +35,7 @@ namespace ErsatzTV.Infrastructure.Health.Checks @@ -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();

12
ErsatzTV.Infrastructure/Jellyfin/JellyfinApiClient.cs

@ -264,7 +264,8 @@ namespace ErsatzTV.Infrastructure.Jellyfin @@ -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 @@ -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 @@ -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 @@ -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;

28
ErsatzTV.Infrastructure/Locking/EntityLocker.cs

@ -9,6 +9,7 @@ namespace ErsatzTV.Infrastructure.Locking @@ -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 @@ -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 @@ -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
ErsatzTV.Infrastructure/Migrations/20210925032411_Add_TraktList.Designer.cs generated

File diff suppressed because it is too large Load Diff

108
ErsatzTV.Infrastructure/Migrations/20210925032411_Add_TraktList.cs

@ -0,0 +1,108 @@ @@ -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");
}
}
}

130
ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs

@ -1507,6 +1507,96 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -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,34 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -2596,6 +2686,34 @@ 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");
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 +3097,8 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -2979,6 +3097,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 +3203,16 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -3083,6 +3203,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");

12
ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs

@ -335,7 +335,8 @@ namespace ErsatzTV.Infrastructure.Plex @@ -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 @@ -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 @@ -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 @@ -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;

16
ErsatzTV.Infrastructure/Search/SearchIndex.cs

@ -51,6 +51,7 @@ namespace ErsatzTV.Infrastructure.Search @@ -51,6 +51,7 @@ 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";
@ -339,6 +340,11 @@ namespace ErsatzTV.Infrastructure.Search @@ -339,6 +340,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 +455,11 @@ namespace ErsatzTV.Infrastructure.Search @@ -449,6 +455,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);
}
@ -644,6 +655,11 @@ namespace ErsatzTV.Infrastructure.Search @@ -644,6 +655,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)

9
ErsatzTV.Infrastructure/Trakt/ITraktApi.cs

@ -8,8 +8,15 @@ namespace ErsatzTV.Infrastructure.Trakt @@ -8,8 +8,15 @@ 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<TraktListItem>> GetUserListItems(
Task<List<TraktListItemResponse>> GetUserListItems(
[Header("trakt-api-key")]
string clientId,
string user,

8
ErsatzTV.Infrastructure/Trakt/Models/TraktListIds.cs

@ -0,0 +1,8 @@ @@ -0,0 +1,8 @@
namespace ErsatzTV.Infrastructure.Trakt.Models
{
public class TraktListIds
{
public int Trakt { get; set; }
public string Slug { get; set; }
}
}

2
ErsatzTV.Infrastructure/Trakt/Models/TraktListItem.cs → ErsatzTV.Infrastructure/Trakt/Models/TraktListItemResponse.cs

@ -1,6 +1,6 @@ @@ -1,6 +1,6 @@
namespace ErsatzTV.Infrastructure.Trakt.Models
{
public class TraktListItem
public class TraktListItemResponse
{
public int Rank { get; set; }
public int Id { get; set; }

14
ErsatzTV.Infrastructure/Trakt/Models/TraktListResponse.cs

@ -0,0 +1,14 @@ @@ -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; }
}
}

7
ErsatzTV.Infrastructure/Trakt/Models/TraktUser.cs

@ -0,0 +1,7 @@ @@ -0,0 +1,7 @@
namespace ErsatzTV.Infrastructure.Trakt.Models
{
public class TraktUser
{
public string Username { get; set; }
}
}

47
ErsatzTV.Infrastructure/Trakt/SnakeCasePropertyNamesContractResolver.cs

@ -0,0 +1,47 @@ @@ -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('_') { }
}
}

59
ErsatzTV.Infrastructure/Trakt/TraktApiClient.cs

@ -2,12 +2,15 @@ @@ -2,12 +2,15 @@
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
{
@ -27,24 +30,64 @@ namespace ErsatzTV.Infrastructure.Trakt @@ -27,24 +30,64 @@ namespace ErsatzTV.Infrastructure.Trakt
_logger = logger;
}
public async Task<Either<BaseError, List<TraktListItemWithGuids>>> GetUserListItems(
string user,
string list)
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<TraktListItem> apiItems = await _traktApi.GetUserListItems(
List<TraktListItemResponse> apiItems = await _traktApi.GetUserListItems(
_traktConfiguration.Value.ClientId,
user,
list);
foreach (TraktListItem apiItem in apiItems)
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,
@ -53,6 +96,8 @@ namespace ErsatzTV.Infrastructure.Trakt @@ -53,6 +96,8 @@ namespace ErsatzTV.Infrastructure.Trakt
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,
@ -61,6 +106,8 @@ namespace ErsatzTV.Infrastructure.Trakt @@ -61,6 +106,8 @@ namespace ErsatzTV.Infrastructure.Trakt
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,
@ -69,6 +116,8 @@ namespace ErsatzTV.Infrastructure.Trakt @@ -69,6 +116,8 @@ namespace ErsatzTV.Infrastructure.Trakt
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,

2
ErsatzTV/ErsatzTV.csproj

@ -30,7 +30,7 @@ @@ -30,7 +30,7 @@
<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>

38
ErsatzTV/Pages/CollectionItems.razor

@ -2,7 +2,6 @@ @@ -2,7 +2,6 @@
@using ErsatzTV.Application.MediaCards
@using ErsatzTV.Application.MediaCards.Queries
@using ErsatzTV.Application.MediaCollections.Commands
@using Unit = LanguageExt.Unit
@inherits MultiSelectBase<CollectionItems>
@inject NavigationManager _navigationManager
@inject ChannelWriter<IBackgroundServiceRequest> _channel
@ -35,15 +34,6 @@ @@ -35,15 +34,6 @@
<MudText Typo="Typo.h4">@_data.Name</MudText>
<MudIconButton Icon="@Icons.Material.Filled.Edit"
Link="@($"/media/collections/{Id}/edit")"/>
<MudTooltip Text="Sync From Trakt List">
<MudButton Class="ml-3"
Variant="Variant.Filled"
Color="Color.Secondary"
StartIcon="@Icons.Material.Filled.Download"
OnClick="@(_ => SyncFromTraktList())">
Trakt
</MudButton>
</MudTooltip>
</div>
@if (_data.MovieCards.Any())
{
@ -251,7 +241,7 @@ @@ -251,7 +241,7 @@
maybeResult.Match(
result => _data = result,
error => _navigationManager.NavigateTo("404"));
_ => _navigationManager.NavigateTo("404"));
}
private IOrderedEnumerable<MovieCardViewModel> OrderMovies(List<MovieCardViewModel> movies)
@ -396,30 +386,4 @@ @@ -396,30 +386,4 @@
await Mediator.Send(request);
}
private async Task SyncFromTraktList()
{
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.Small };
IDialogReference dialog = Dialog.Show<SyncFromTraktListDialog>("Sync From Trakt List", options);
DialogResult result = await dialog.Result;
if (!result.Cancelled && result.Data is string url)
{
Either<BaseError, Unit> syncResult = await Mediator.Send(new SyncCollectionFromTraktList(Id, url));
syncResult.Match(
Left: error =>
{
Snackbar.Add($"Unexpected error synchronizing from Trakt: {error.Value}");
Logger.LogError("Unexpected error synchronizing from Trakt: {Error}", error.Value);
},
Right: _ =>
{
Snackbar.Add("Successfully synchronized collection from Trakt", Severity.Success);
});
if (syncResult.IsRight)
{
await RefreshData();
}
}
}
}

149
ErsatzTV/Pages/TraktLists.razor

@ -0,0 +1,149 @@ @@ -0,0 +1,149 @@
@page "/media/trakt/lists"
@using ErsatzTV.Application.MediaCollections
@using ErsatzTV.Application.MediaCollections.Queries
@using ErsatzTV.Application.Configuration.Queries
@using ErsatzTV.Application.Configuration.Commands
@using ErsatzTV.Application.MediaCollections.Commands
@inject IDialogService _dialog
@inject IMediator _mediator
@inject ISnackbar _snackbar
@inject ILogger<TraktLists> _logger
@inject IEntityLocker _locker
@inject ChannelWriter<IBackgroundServiceRequest> _workerChannel
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
<div>
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
Disabled="@_locker.IsTraktLocked()"
OnClick="@(_ => AddTraktList())">
Add Trakt List
</MudButton>
</div>
<MudTable Class="mt-4"
Hover="true"
@bind-RowsPerPage="@_traktListsRowsPerPage"
ServerData="@(new Func<TableState, Task<TableData<TraktListViewModel>>>(ServerReloadTraktLists))"
Dense="true"
@ref="_traktListsTable">
<ToolBarContent>
<MudText Typo="Typo.h6">Trakt Lists</MudText>
</ToolBarContent>
<ColGroup>
<col/>
<col/>
<col/>
<col style="width: 180px;"/>
</ColGroup>
<HeaderContent>
<MudTh>Id</MudTh>
<MudTh>Name</MudTh>
<MudTh>Match Status</MudTh>
<MudTh/>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Id">@context.Slug</MudTd>
<MudTd DataLabel="Name">@context.Name</MudTd>
<MudTd DataLabel="Match Status">@context.MatchCount of @context.ItemCount</MudTd>
<MudTd>
<div style="align-items: center; display: flex;">
<MudTooltip Text="Search Trakt List">
<MudIconButton Icon="@Icons.Material.Filled.Search"
Disabled="@_locker.IsTraktLocked()"
Link="@($"/search?query=trakt_list%3a{context.TraktId}")">
</MudIconButton>
</MudTooltip>
<MudTooltip Text="Match Trakt List Items">
<MudIconButton Icon="@Icons.Material.Filled.Refresh"
Disabled="@_locker.IsTraktLocked()"
OnClick="@(_ => MatchListItems(context))">
</MudIconButton>
</MudTooltip>
<MudTooltip Text="Delete Trakt List">
<MudIconButton Icon="@Icons.Material.Filled.Delete"
Disabled="@_locker.IsTraktLocked()"
OnClick="@(_ => DeleteTraktList(context))">
</MudIconButton>
</MudTooltip>
</div>
</MudTd>
</RowTemplate>
<PagerContent>
<MudTablePager/>
</PagerContent>
</MudTable>
</MudContainer>
@code {
private MudTable<TraktListViewModel> _traktListsTable;
private int _traktListsRowsPerPage;
protected override void OnInitialized()
{
_locker.OnTraktChanged += LockChanged;
}
protected override async Task OnParametersSetAsync()
{
_traktListsRowsPerPage = await _mediator.Send(new GetConfigElementByKey(ConfigElementKey.TraktListsPageSize))
.Map(maybeRows => maybeRows.Match(ce => int.TryParse(ce.Value, out int rows) ? rows : 10, () => 10));
}
private void LockChanged(object sender, EventArgs e) =>
InvokeAsync(async () =>
{
StateHasChanged();
if (!_locker.IsTraktLocked())
{
await _traktListsTable.ReloadServerData();
}
});
private async Task MatchListItems(TraktListViewModel traktList)
{
if (_locker.LockTrakt())
{
await _workerChannel.WriteAsync(new MatchTraktListItems(traktList.Id));
}
}
private async Task DeleteTraktList(TraktListViewModel traktList)
{
if (_locker.LockTrakt())
{
var parameters = new DialogParameters { { "EntityType", "Trakt List" }, { "EntityName", traktList.Name } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = _dialog.Show<DeleteDialog>("Delete Trakt List", parameters, options);
DialogResult result = await dialog.Result;
if (!result.Cancelled)
{
await _workerChannel.WriteAsync(new DeleteTraktList(traktList.Id));
}
}
}
private async Task<TableData<TraktListViewModel>> ServerReloadTraktLists(TableState state)
{
await _mediator.Send(new SaveConfigElementByKey(ConfigElementKey.TraktListsPageSize, state.PageSize.ToString()));
PagedTraktListsViewModel data = await _mediator.Send(new GetPagedTraktLists(state.Page, state.PageSize));
return new TableData<TraktListViewModel> { TotalItems = data.TotalCount, Items = data.Page };
}
private async Task AddTraktList()
{
if (_locker.LockTrakt())
{
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.Small };
IDialogReference dialog = _dialog.Show<AddTraktListDialog>("Add Trakt List", options);
DialogResult result = await dialog.Result;
if (!result.Cancelled && result.Data is string url)
{
await _workerChannel.WriteAsync(new AddTraktList(url));
}
}
}
}

22
ErsatzTV/Services/SchedulerService.cs

@ -6,6 +6,7 @@ using System.Threading.Channels; @@ -6,6 +6,7 @@ using System.Threading.Channels;
using System.Threading.Tasks;
using ErsatzTV.Application;
using ErsatzTV.Application.Maintenance.Commands;
using ErsatzTV.Application.MediaCollections.Commands;
using ErsatzTV.Application.MediaSources.Commands;
using ErsatzTV.Application.Playouts.Commands;
using ErsatzTV.Application.Plex.Commands;
@ -85,6 +86,7 @@ namespace ErsatzTV.Services @@ -85,6 +86,7 @@ namespace ErsatzTV.Services
await BuildPlayouts(cancellationToken);
await ScanLocalMediaSources(cancellationToken);
await ScanPlexMediaSources(cancellationToken);
await MatchTraktLists(cancellationToken);
}
catch (Exception ex)
{
@ -174,6 +176,26 @@ namespace ErsatzTV.Services @@ -174,6 +176,26 @@ namespace ErsatzTV.Services
}
}
private async Task MatchTraktLists(CancellationToken cancellationToken)
{
using IServiceScope scope = _serviceScopeFactory.CreateScope();
TvContext dbContext = scope.ServiceProvider.GetRequiredService<TvContext>();
List<TraktList> traktLists = await dbContext.TraktLists
.ToListAsync(cancellationToken);
if (traktLists.Any() && _entityLocker.LockTrakt())
{
TraktList last = traktLists.Last();
foreach (TraktList list in traktLists)
{
await _workerChannel.WriteAsync(
new MatchTraktListItems(list.Id, list == last),
cancellationToken);
}
}
}
private ValueTask RebuildSearchIndex(CancellationToken cancellationToken) =>
_workerChannel.WriteAsync(new RebuildSearchIndex(), cancellationToken);

10
ErsatzTV/Services/WorkerService.cs

@ -4,6 +4,7 @@ using System.Threading.Channels; @@ -4,6 +4,7 @@ using System.Threading.Channels;
using System.Threading.Tasks;
using ErsatzTV.Application;
using ErsatzTV.Application.Maintenance.Commands;
using ErsatzTV.Application.MediaCollections.Commands;
using ErsatzTV.Application.MediaSources.Commands;
using ErsatzTV.Application.Playouts.Commands;
using ErsatzTV.Application.Search.Commands;
@ -77,6 +78,15 @@ namespace ErsatzTV.Services @@ -77,6 +78,15 @@ namespace ErsatzTV.Services
_logger.LogInformation("Deleting orphaned artwork from the database");
await mediator.Send(deleteOrphanedArtwork, cancellationToken);
break;
case AddTraktList addTraktList:
await mediator.Send(addTraktList, cancellationToken);
break;
case DeleteTraktList deleteTraktList:
await mediator.Send(deleteTraktList, cancellationToken);
break;
case MatchTraktListItems matchTraktListItems:
await mediator.Send(matchTraktListItems, cancellationToken);
break;
}
}
catch (Exception ex)

4
ErsatzTV/Shared/SyncFromTraktListDialog.razor → ErsatzTV/Shared/AddTraktListDialog.razor

@ -2,7 +2,7 @@ @@ -2,7 +2,7 @@
@inject IMediator _mediator
@inject IMemoryCache _memoryCache
@inject ISnackbar _snackbar
@inject ILogger<SyncFromTraktListDialog> _logger
@inject ILogger<AddTraktListDialog> _logger
<MudDialog>
<DialogContent>
@ -23,7 +23,7 @@ @@ -23,7 +23,7 @@
<DialogActions>
<MudButton OnClick="Cancel" ButtonType="ButtonType.Reset">Cancel</MudButton>
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="Submit">
Sync
Add
</MudButton>
</DialogActions>
</MudDialog>

3
ErsatzTV/Shared/MainLayout.razor

@ -53,7 +53,10 @@ @@ -53,7 +53,10 @@
<MudNavLink Href="/media/tv/shows">TV Shows</MudNavLink>
<MudNavLink Href="/media/movies">Movies</MudNavLink>
<MudNavLink Href="/media/music/artists">Music</MudNavLink>
</MudNavGroup>
<MudNavGroup Title="Lists" Expanded="true">
<MudNavLink Href="/media/collections">Collections</MudNavLink>
<MudNavLink Href="/media/trakt/lists">Trakt Lists</MudNavLink>
</MudNavGroup>
<MudNavLink Href="/schedules">Schedules</MudNavLink>
<MudNavLink Href="/playouts">Playouts</MudNavLink>

Loading…
Cancel
Save