sync trakt list to collection (#381)
* sync trakt list to collection * move trakt client id
This commit is contained in:
@@ -4,6 +4,11 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
|
||||
## [Unreleased]
|
||||
### Added
|
||||
- Add ability to sync Trakt lists to a collection
|
||||
- This sync is one-time (manual button) and one-way (Trakt => ErsatzTV)
|
||||
- After synchronization, the collection will *only* contain media items found in the Trakt list
|
||||
|
||||
### Fixed
|
||||
- Fix local television scanner to properly update episode metadata when NFO files have been added/changed
|
||||
- Properly detect ffmpeg nvenc (cuda) support in Hardware Acceleration health check
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
using ErsatzTV.Core;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Unit = LanguageExt.Unit;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections.Commands
|
||||
{
|
||||
public record SyncCollectionFromTraktList
|
||||
(int CollectionId, string TraktListUrl) : IRequest<Either<BaseError, Unit>>;
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
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,14 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Trakt;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Trakt
|
||||
{
|
||||
public interface ITraktApiClient
|
||||
{
|
||||
Task<Either<BaseError, List<TraktListItemWithGuids>>> GetUserListItems(
|
||||
string user,
|
||||
string list);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace ErsatzTV.Core.Trakt
|
||||
{
|
||||
public class TraktConfiguration
|
||||
{
|
||||
public string ClientId { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace ErsatzTV.Core.Trakt
|
||||
{
|
||||
public enum TraktListItemKind
|
||||
{
|
||||
Movie,
|
||||
Show,
|
||||
Season,
|
||||
Episode
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ErsatzTV.Core.Trakt
|
||||
{
|
||||
public record TraktListItemWithGuids(
|
||||
string DisplayTitle,
|
||||
string Title,
|
||||
int Year,
|
||||
int Season,
|
||||
int Episode,
|
||||
TraktListItemKind Kind,
|
||||
List<string> Guids);
|
||||
}
|
||||
@@ -42,6 +42,7 @@ namespace ErsatzTV.Infrastructure.Data
|
||||
public DbSet<Show> Shows { get; set; }
|
||||
public DbSet<ShowMetadata> ShowMetadata { get; set; }
|
||||
public DbSet<Season> Seasons { get; set; }
|
||||
public DbSet<SeasonMetadata> SeasonMetadata { get; set; }
|
||||
public DbSet<Episode> Episodes { get; set; }
|
||||
public DbSet<EpisodeMetadata> EpisodeMetadata { get; set; }
|
||||
public DbSet<PlexMovie> PlexMovies { get; set; }
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Infrastructure.Trakt.Models;
|
||||
using Refit;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Trakt
|
||||
{
|
||||
[Headers("Accept: application/json", "trakt-api-version: 2")]
|
||||
public interface ITraktApi
|
||||
{
|
||||
[Get("/users/{user}/lists/{list}/items")]
|
||||
Task<List<TraktListItem>> GetUserListItems(
|
||||
[Header("trakt-api-key")]
|
||||
string clientId,
|
||||
string user,
|
||||
string list);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace ErsatzTV.Infrastructure.Trakt.Models
|
||||
{
|
||||
public class TraktListItem
|
||||
{
|
||||
public int Rank { get; set; }
|
||||
public int Id { get; set; }
|
||||
public string Type { get; set; }
|
||||
public TraktListItemMovie Movie { get; set; }
|
||||
public TraktListItemShow Show { get; set; }
|
||||
public TraktListItemSeason Season { get; set; }
|
||||
public TraktListItemEpisode Episode { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace ErsatzTV.Infrastructure.Trakt.Models
|
||||
{
|
||||
public class TraktListItemEpisode
|
||||
{
|
||||
public int Season { get; set; }
|
||||
public int Number { get; set; }
|
||||
public string Title { get; set; }
|
||||
public TraktListItemIds Ids { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace ErsatzTV.Infrastructure.Trakt.Models
|
||||
{
|
||||
public class TraktListItemIds
|
||||
{
|
||||
public int Trakt { get; set; }
|
||||
public int? Tvdb { get; set; }
|
||||
public string Imdb { get; set; }
|
||||
public int? Tmdb { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace ErsatzTV.Infrastructure.Trakt.Models
|
||||
{
|
||||
public class TraktListItemMovie
|
||||
{
|
||||
public string Title { get; set; }
|
||||
public int Year { get; set; }
|
||||
public TraktListItemIds Ids { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace ErsatzTV.Infrastructure.Trakt.Models
|
||||
{
|
||||
public class TraktListItemSeason
|
||||
{
|
||||
public int Number { get; set; }
|
||||
public TraktListItemIds Ids { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace ErsatzTV.Infrastructure.Trakt.Models
|
||||
{
|
||||
public class TraktListItemShow
|
||||
{
|
||||
public string Title { get; set; }
|
||||
public int Year { get; set; }
|
||||
public TraktListItemIds Ids { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Interfaces.Trakt;
|
||||
using ErsatzTV.Core.Trakt;
|
||||
using ErsatzTV.Infrastructure.Trakt.Models;
|
||||
using LanguageExt;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Trakt
|
||||
{
|
||||
public class TraktApiClient : ITraktApiClient
|
||||
{
|
||||
private readonly ITraktApi _traktApi;
|
||||
private readonly IOptions<TraktConfiguration> _traktConfiguration;
|
||||
private readonly ILogger<TraktApiClient> _logger;
|
||||
|
||||
public TraktApiClient(
|
||||
ITraktApi traktApi,
|
||||
IOptions<TraktConfiguration> traktConfiguration,
|
||||
ILogger<TraktApiClient> logger)
|
||||
{
|
||||
_traktApi = traktApi;
|
||||
_traktConfiguration = traktConfiguration;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, List<TraktListItemWithGuids>>> GetUserListItems(
|
||||
string user,
|
||||
string list)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = new List<TraktListItemWithGuids>();
|
||||
|
||||
List<TraktListItem> apiItems = await _traktApi.GetUserListItems(
|
||||
_traktConfiguration.Value.ClientId,
|
||||
user,
|
||||
list);
|
||||
|
||||
foreach (TraktListItem apiItem in apiItems)
|
||||
{
|
||||
TraktListItemWithGuids item = apiItem.Type.ToLowerInvariant() switch
|
||||
{
|
||||
"movie" => new TraktListItemWithGuids(
|
||||
$"{apiItem.Movie.Title} ({apiItem.Movie.Year})",
|
||||
apiItem.Movie.Title,
|
||||
apiItem.Movie.Year,
|
||||
0,
|
||||
0,
|
||||
TraktListItemKind.Movie,
|
||||
GuidsFromIds(apiItem.Movie.Ids)),
|
||||
"show" => new TraktListItemWithGuids(
|
||||
$"{apiItem.Show.Title} ({apiItem.Show.Year})",
|
||||
apiItem.Show.Title,
|
||||
apiItem.Show.Year,
|
||||
0,
|
||||
0,
|
||||
TraktListItemKind.Show,
|
||||
GuidsFromIds(apiItem.Show.Ids)),
|
||||
"season" => new TraktListItemWithGuids(
|
||||
$"{apiItem.Show.Title} ({apiItem.Show.Year}) S{apiItem.Season.Number:00}",
|
||||
apiItem.Show.Title,
|
||||
apiItem.Show.Year,
|
||||
apiItem.Season.Number,
|
||||
0,
|
||||
TraktListItemKind.Season,
|
||||
GuidsFromIds(apiItem.Season.Ids)),
|
||||
"episode" => new TraktListItemWithGuids(
|
||||
$"{apiItem.Show.Title} ({apiItem.Show.Year}) S{apiItem.Episode.Season:00}E{apiItem.Episode.Number:00}",
|
||||
apiItem.Show.Title,
|
||||
apiItem.Show.Year,
|
||||
apiItem.Episode.Season,
|
||||
apiItem.Episode.Number,
|
||||
TraktListItemKind.Episode,
|
||||
GuidsFromIds(apiItem.Episode.Ids)),
|
||||
_ => null
|
||||
};
|
||||
|
||||
if (item != null)
|
||||
{
|
||||
result.Add(item);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error getting trakt list items");
|
||||
return BaseError.New(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static List<string> GuidsFromIds(TraktListItemIds ids)
|
||||
{
|
||||
var result = new List<string>();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(ids.Imdb))
|
||||
{
|
||||
result.Add($"imdb://{ids.Imdb}");
|
||||
}
|
||||
|
||||
if (ids.Tmdb.HasValue)
|
||||
{
|
||||
result.Add($"tmdb://{ids.Tmdb.Value}");
|
||||
}
|
||||
|
||||
if (ids.Tvdb.HasValue)
|
||||
{
|
||||
result.Add($"tvdb://{ids.Tvdb.Value}");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,7 @@
|
||||
<s:Boolean x:Key="/Default/UserDictionary/Words/=setsar/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/UserDictionary/Words/=showtitle/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/UserDictionary/Words/=strm/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/UserDictionary/Words/=Trakt/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/UserDictionary/Words/=tvdb/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/UserDictionary/Words/=tvshow/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/UserDictionary/Words/=uniqueid/@EntryIndexedValue">True</s:Boolean>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
@using ErsatzTV.Application.MediaCards
|
||||
@using ErsatzTV.Application.MediaCards.Queries
|
||||
@using ErsatzTV.Application.MediaCollections.Commands
|
||||
@using Unit = LanguageExt.Unit
|
||||
@inherits MultiSelectBase<CollectionItems>
|
||||
@inject NavigationManager _navigationManager
|
||||
@inject ChannelWriter<IBackgroundServiceRequest> _channel
|
||||
@@ -30,11 +31,19 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<div style="display: flex; flex-direction: row;">
|
||||
<div style="display: flex; flex-direction: row; align-items: center">
|
||||
<MudText Typo="Typo.h4">@_data.Name</MudText>
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Edit"
|
||||
Link="@($"/media/collections/{Id}/edit")"
|
||||
Style="margin-bottom: auto; margin-top: auto;"/>
|
||||
Link="@($"/media/collections/{Id}/edit")"/>
|
||||
<MudTooltip Text="Sync From Trakt List">
|
||||
<MudButton Class="ml-3"
|
||||
Variant="Variant.Filled"
|
||||
Color="Color.Secondary"
|
||||
StartIcon="@Icons.Material.Filled.Download"
|
||||
OnClick="@(_ => SyncFromTraktList())">
|
||||
Trakt
|
||||
</MudButton>
|
||||
</MudTooltip>
|
||||
</div>
|
||||
@if (_data.MovieCards.Any())
|
||||
{
|
||||
@@ -387,4 +396,30 @@
|
||||
await Mediator.Send(request);
|
||||
}
|
||||
|
||||
private async Task SyncFromTraktList()
|
||||
{
|
||||
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.Small };
|
||||
IDialogReference dialog = Dialog.Show<SyncFromTraktListDialog>("Sync From Trakt List", options);
|
||||
DialogResult result = await dialog.Result;
|
||||
if (!result.Cancelled && result.Data is string url)
|
||||
{
|
||||
Either<BaseError, Unit> syncResult = await Mediator.Send(new SyncCollectionFromTraktList(Id, url));
|
||||
syncResult.Match(
|
||||
Left: error =>
|
||||
{
|
||||
Snackbar.Add($"Unexpected error synchronizing from Trakt: {error.Value}");
|
||||
Logger.LogError("Unexpected error synchronizing from Trakt: {Error}", error.Value);
|
||||
},
|
||||
Right: _ =>
|
||||
{
|
||||
Snackbar.Add("Successfully synchronized collection from Trakt", Severity.Success);
|
||||
});
|
||||
|
||||
if (syncResult.IsRight)
|
||||
{
|
||||
await RefreshData();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
@using Microsoft.Extensions.Caching.Memory
|
||||
@inject IMediator _mediator
|
||||
@inject IMemoryCache _memoryCache
|
||||
@inject ISnackbar _snackbar
|
||||
@inject ILogger<SyncFromTraktListDialog> _logger
|
||||
|
||||
<MudDialog>
|
||||
<DialogContent>
|
||||
<EditForm Model="@_dummyModel" OnSubmit="@(_ => Submit())">
|
||||
<MudContainer Class="mb-6">
|
||||
<MudText>
|
||||
Enter Trakt List URL
|
||||
</MudText>
|
||||
</MudContainer>
|
||||
<MudTextFieldString Label="Trakt List URL"
|
||||
Placeholder="https://trakt.tv/users/username/lists/list-name"
|
||||
@bind-Text="@_traktListUrl"
|
||||
Style="min-width: 400px"
|
||||
Class="mb-6 mx-4">
|
||||
</MudTextFieldString>
|
||||
</EditForm>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="Cancel" ButtonType="ButtonType.Reset">Cancel</MudButton>
|
||||
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="Submit">
|
||||
Sync
|
||||
</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
|
||||
@code {
|
||||
|
||||
[CascadingParameter]
|
||||
MudDialogInstance MudDialog { get; set; }
|
||||
|
||||
private string _traktListUrl;
|
||||
|
||||
private record DummyModel;
|
||||
|
||||
private readonly DummyModel _dummyModel = new();
|
||||
|
||||
private bool CanSubmit() => !string.IsNullOrWhiteSpace(_traktListUrl);
|
||||
|
||||
private void Submit()
|
||||
{
|
||||
if (!CanSubmit())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
MudDialog.Close(DialogResult.Ok(_traktListUrl));
|
||||
}
|
||||
|
||||
private void Cancel(MouseEventArgs e)
|
||||
{
|
||||
// this is gross, but [enter] seems to sometimes trigger cancel instead of submit
|
||||
if (e.Detail == 0)
|
||||
{
|
||||
Submit();
|
||||
}
|
||||
else
|
||||
{
|
||||
MudDialog.Cancel();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -26,11 +26,13 @@ using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Runtime;
|
||||
using ErsatzTV.Core.Interfaces.Scheduling;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Interfaces.Trakt;
|
||||
using ErsatzTV.Core.Jellyfin;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
using ErsatzTV.Core.Metadata.Nfo;
|
||||
using ErsatzTV.Core.Plex;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using ErsatzTV.Core.Trakt;
|
||||
using ErsatzTV.Formatters;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Data.Repositories;
|
||||
@@ -44,6 +46,7 @@ using ErsatzTV.Infrastructure.Locking;
|
||||
using ErsatzTV.Infrastructure.Plex;
|
||||
using ErsatzTV.Infrastructure.Runtime;
|
||||
using ErsatzTV.Infrastructure.Search;
|
||||
using ErsatzTV.Infrastructure.Trakt;
|
||||
using ErsatzTV.Serialization;
|
||||
using ErsatzTV.Services;
|
||||
using ErsatzTV.Services.RunOnce;
|
||||
@@ -170,6 +173,11 @@ namespace ErsatzTV
|
||||
services.AddRefitClient<IPlexTvApi>()
|
||||
.ConfigureHttpClient(c => c.BaseAddress = new Uri("https://plex.tv/api/v2"));
|
||||
|
||||
services.AddRefitClient<ITraktApi>()
|
||||
.ConfigureHttpClient(c => c.BaseAddress = new Uri("https://api.trakt.tv"));
|
||||
|
||||
services.Configure<TraktConfiguration>(Configuration.GetSection("Trakt"));
|
||||
|
||||
CustomServices(services);
|
||||
}
|
||||
|
||||
@@ -195,6 +203,7 @@ namespace ErsatzTV
|
||||
services.AddSingleton<FFmpegPlaybackSettingsCalculator>();
|
||||
services.AddSingleton<IPlexSecretStore, PlexSecretStore>();
|
||||
services.AddSingleton<IPlexTvApiClient, PlexTvApiClient>(); // TODO: does this need to be singleton?
|
||||
services.AddSingleton<ITraktApiClient, TraktApiClient>();
|
||||
services.AddSingleton<IEntityLocker, EntityLocker>();
|
||||
services.AddSingleton<ISearchIndex, SearchIndex>();
|
||||
AddChannel<IBackgroundServiceRequest>(services);
|
||||
|
||||
@@ -31,5 +31,8 @@
|
||||
"Url": "http://+:8409"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Trakt": {
|
||||
"ClientId": "5a4e78338b7f177cf980d6a8881dd5a52dee680746864e9e442b42d5f4d4ac82"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user