mirror of https://github.com/ErsatzTV/ErsatzTV.git
Browse Source
* better trakt list support * update dependencies * revert unneeded bracketspull/385/head
62 changed files with 4723 additions and 359 deletions
@ -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); |
||||||
|
} |
||||||
|
} |
||||||
@ -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(); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -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; |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
@ -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; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,6 @@ |
|||||||
|
using System.Collections.Generic; |
||||||
|
|
||||||
|
namespace ErsatzTV.Application.MediaCollections |
||||||
|
{ |
||||||
|
public record PagedTraktListsViewModel(int TotalCount, List<TraktListViewModel> Page); |
||||||
|
} |
||||||
@ -0,0 +1,6 @@ |
|||||||
|
using MediatR; |
||||||
|
|
||||||
|
namespace ErsatzTV.Application.MediaCollections.Queries |
||||||
|
{ |
||||||
|
public record GetPagedTraktLists(int PageNum, int PageSize) : IRequest<PagedTraktListsViewModel>; |
||||||
|
} |
||||||
@ -0,0 +1,47 @@ |
|||||||
|
using System.Collections.Generic; |
||||||
|
using System.Data; |
||||||
|
using System.Linq; |
||||||
|
using System.Threading; |
||||||
|
using System.Threading.Tasks; |
||||||
|
using Dapper; |
||||||
|
using ErsatzTV.Infrastructure.Data; |
||||||
|
using LanguageExt; |
||||||
|
using MediatR; |
||||||
|
using Microsoft.EntityFrameworkCore; |
||||||
|
using static ErsatzTV.Application.MediaCollections.Mapper; |
||||||
|
|
||||||
|
namespace ErsatzTV.Application.MediaCollections.Queries |
||||||
|
{ |
||||||
|
public class GetPagedTraktListsHandler : IRequestHandler<GetPagedTraktLists, PagedTraktListsViewModel> |
||||||
|
{ |
||||||
|
private readonly IDbConnection _dbConnection; |
||||||
|
private readonly IDbContextFactory<TvContext> _dbContextFactory; |
||||||
|
|
||||||
|
public GetPagedTraktListsHandler(IDbContextFactory<TvContext> dbContextFactory, IDbConnection dbConnection) |
||||||
|
{ |
||||||
|
_dbContextFactory = dbContextFactory; |
||||||
|
_dbConnection = dbConnection; |
||||||
|
} |
||||||
|
|
||||||
|
public async Task<PagedTraktListsViewModel> Handle( |
||||||
|
GetPagedTraktLists request, |
||||||
|
CancellationToken cancellationToken) |
||||||
|
{ |
||||||
|
int count = await _dbConnection.QuerySingleAsync<int>(@"SELECT COUNT (*) FROM TraktList"); |
||||||
|
|
||||||
|
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); |
||||||
|
List<TraktListViewModel> page = await dbContext.TraktLists.FromSqlRaw( |
||||||
|
@"SELECT * FROM TraktList
|
||||||
|
ORDER BY Name |
||||||
|
COLLATE NOCASE |
||||||
|
LIMIT {0} OFFSET {1}",
|
||||||
|
request.PageSize, |
||||||
|
request.PageNum * request.PageSize) |
||||||
|
.Include(l => l.Items) |
||||||
|
.ToListAsync(cancellationToken) |
||||||
|
.Map(list => list.Map(ProjectToViewModel).ToList()); |
||||||
|
|
||||||
|
return new PagedTraktListsViewModel(count, page); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,4 @@ |
|||||||
|
namespace ErsatzTV.Application.MediaCollections |
||||||
|
{ |
||||||
|
public record TraktListViewModel(int Id, int TraktId, string Slug, string Name, int ItemCount, int MatchCount); |
||||||
|
} |
||||||
@ -0,0 +1,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; } |
||||||
|
} |
||||||
|
} |
||||||
@ -1,4 +1,4 @@ |
|||||||
namespace ErsatzTV.Core.Trakt |
namespace ErsatzTV.Core.Domain |
||||||
{ |
{ |
||||||
public enum TraktListItemKind |
public enum TraktListItemKind |
||||||
{ |
{ |
||||||
@ -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,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); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -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"); |
||||||
|
} |
||||||
|
} |
||||||
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"); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,8 @@ |
|||||||
|
namespace ErsatzTV.Infrastructure.Trakt.Models |
||||||
|
{ |
||||||
|
public class TraktListIds |
||||||
|
{ |
||||||
|
public int Trakt { get; set; } |
||||||
|
public string Slug { get; set; } |
||||||
|
} |
||||||
|
} |
||||||
@ -1,6 +1,6 @@ |
|||||||
namespace ErsatzTV.Infrastructure.Trakt.Models |
namespace ErsatzTV.Infrastructure.Trakt.Models |
||||||
{ |
{ |
||||||
public class TraktListItem |
public class TraktListItemResponse |
||||||
{ |
{ |
||||||
public int Rank { get; set; } |
public int Rank { get; set; } |
||||||
public int Id { get; set; } |
public int Id { 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,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)); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
Loading…
Reference in new issue