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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -0,0 +1,6 @@
|
||||
using System.Collections.Generic; |
||||
|
||||
namespace ErsatzTV.Application.MediaCollections |
||||
{ |
||||
public record PagedTraktListsViewModel(int TotalCount, List<TraktListViewModel> Page); |
||||
} |
@ -0,0 +1,6 @@
@@ -0,0 +1,6 @@
|
||||
using MediatR; |
||||
|
||||
namespace ErsatzTV.Application.MediaCollections.Queries |
||||
{ |
||||
public record GetPagedTraktLists(int PageNum, int PageSize) : IRequest<PagedTraktListsViewModel>; |
||||
} |
@ -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); |
||||
} |
||||
} |
||||
} |
@ -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); |
||||
} |
@ -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; } |
||||
} |
||||
} |
@ -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}" |
||||
}; |
||||
} |
||||
} |
@ -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; } |
||||
} |
||||
} |
@ -1,4 +1,4 @@
@@ -1,4 +1,4 @@
|
||||
namespace ErsatzTV.Core.Trakt |
||||
namespace ErsatzTV.Core.Domain |
||||
{ |
||||
public enum TraktListItemKind |
||||
{ |
@ -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); |
||||
} |
||||
} |
||||
} |
@ -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); |
||||
} |
||||
} |
||||
} |
@ -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"); |
||||
} |
||||
} |
File diff suppressed because it is too large
Load Diff
@ -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"); |
||||
} |
||||
} |
||||
} |
@ -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; } |
||||
} |
||||
} |
@ -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; } |
@ -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; } |
||||
} |
||||
} |
@ -0,0 +1,7 @@
@@ -0,0 +1,7 @@
|
||||
namespace ErsatzTV.Infrastructure.Trakt.Models |
||||
{ |
||||
public class TraktUser |
||||
{ |
||||
public string Username { get; set; } |
||||
} |
||||
} |
@ -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('_') { } |
||||
} |
||||
} |
@ -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)); |
||||
} |
||||
} |
||||
} |
||||
|
||||
} |
Loading…
Reference in new issue