Properly handle pause and resume events

This commit is contained in:
Shadowghost 2022-04-16 15:42:10 +02:00
parent caef90021b
commit 0985b9455c
3 changed files with 348 additions and 214 deletions

View File

@ -0,0 +1,21 @@
namespace Trakt.Helpers
{
/// <summary>
/// Enum MediaStatus.
/// </summary>
public enum MediaStatus
{
/// <summary>
/// The watching state.
/// </summary>
Watching,
/// <summary>
/// The paused state.
/// </summary>
Paused,
/// <summary>
/// The stopped state.
/// </summary>
Stop
}
}

View File

@ -1,8 +0,0 @@
namespace Trakt;
public enum MediaStatus
{
Watching,
Paused,
Stop
}

View File

@ -1,4 +1,5 @@
using System; using System;
using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Net.Http; using System.Net.Http;
using System.Threading.Tasks; using System.Threading.Tasks;
@ -15,263 +16,379 @@ using Microsoft.Extensions.Logging;
using Trakt.Api; using Trakt.Api;
using Trakt.Helpers; using Trakt.Helpers;
namespace Trakt; namespace Trakt
/// <summary>
/// All communication between the server and the plugins server instance should occur in this class.
/// </summary>
public class ServerMediator : IServerEntryPoint
{ {
private readonly ISessionManager _sessionManager;
private readonly ILibraryManager _libraryManager;
private readonly ILogger<ServerMediator> _logger;
private readonly UserDataManagerEventsHelper _userDataManagerEventsHelper;
private readonly IUserDataManager _userDataManager;
private TraktApi _traktApi;
private LibraryManagerEventsHelper _libraryManagerEventsHelper;
/// <summary> /// <summary>
/// /// All communication between the server and the plugins server instance should occur in this class.
/// </summary> /// </summary>
/// <param name="sessionManager"> </param> public class ServerMediator : IServerEntryPoint, IDisposable
/// <param name="userDataManager"></param>
/// <param name="libraryManager"> </param>
/// <param name="logger"></param>
/// <param name="httpClient"></param>
/// <param name="appHost"></param>
/// <param name="fileSystem"></param>
public ServerMediator(
ISessionManager sessionManager,
IUserDataManager userDataManager,
ILibraryManager libraryManager,
ILoggerFactory loggerFactory,
IHttpClientFactory httpClientFactory,
IServerApplicationHost appHost,
IFileSystem fileSystem)
{ {
_sessionManager = sessionManager; private readonly ISessionManager _sessionManager;
_libraryManager = libraryManager; private readonly ILibraryManager _libraryManager;
_userDataManager = userDataManager; private readonly ILogger<ServerMediator> _logger;
private readonly UserDataManagerEventsHelper _userDataManagerEventsHelper;
private readonly IUserDataManager _userDataManager;
private TraktApi _traktApi;
private LibraryManagerEventsHelper _libraryManagerEventsHelper;
_logger = loggerFactory.CreateLogger<ServerMediator>(); private Dictionary<string, bool> _playbackPause;
_traktApi = new TraktApi(loggerFactory.CreateLogger<TraktApi>(), httpClientFactory, appHost, userDataManager, fileSystem); /// <summary>
_libraryManagerEventsHelper = new LibraryManagerEventsHelper(loggerFactory.CreateLogger<LibraryManagerEventsHelper>(), _traktApi); /// Processes server events.
_userDataManagerEventsHelper = new UserDataManagerEventsHelper(loggerFactory.CreateLogger<UserDataManagerEventsHelper>(), _traktApi); /// </summary>
} /// <param name="sessionManager">The <see cref="ISessionManager"/>.</param>
/// <param name="userDataManager">The <see cref="IUserDataManager"/>.</param>
/// <summary> /// <param name="libraryManager">The <see cref="ILibraryManager"/>.</param>
/// /// <param name="loggerFactory">The <see cref="ILoggerFactory"/>.</param>
/// </summary> /// <param name="httpClientFactory">The <see cref="IHttpClientFactory"/>.</param>
/// <param name="sender"></param> /// <param name="appHost">The <see cref="IServerApplicationHost"/>.</param>
/// <param name="e"></param> /// <param name="fileSystem">The <see cref="IFileSystem"/>.</param>
private void OnUserDataSaved(object sender, UserDataSaveEventArgs e) public ServerMediator(
{ ISessionManager sessionManager,
// ignore change events for any reason other than manually toggling played. IUserDataManager userDataManager,
if (e.SaveReason != UserDataSaveReason.TogglePlayed) ILibraryManager libraryManager,
ILoggerFactory loggerFactory,
IHttpClientFactory httpClientFactory,
IServerApplicationHost appHost,
IFileSystem fileSystem)
{ {
return; _sessionManager = sessionManager;
_libraryManager = libraryManager;
_userDataManager = userDataManager;
_logger = loggerFactory.CreateLogger<ServerMediator>();
_playbackPause = new Dictionary<string, bool>();
_traktApi = new TraktApi(loggerFactory.CreateLogger<TraktApi>(), httpClientFactory, appHost, userDataManager, fileSystem);
_libraryManagerEventsHelper = new LibraryManagerEventsHelper(loggerFactory.CreateLogger<LibraryManagerEventsHelper>(), _traktApi);
_userDataManagerEventsHelper = new UserDataManagerEventsHelper(loggerFactory.CreateLogger<UserDataManagerEventsHelper>(), _traktApi);
} }
if (e.Item != null) /// <summary>
/// User data was saved.
/// </summary>
/// <param name="sender">The sending entity.</param>
/// <param name="userDataSaveEventArgs">The <see cref="UserDataSaveEventArgs"/>.</param>
private void OnUserDataSaved(object sender, UserDataSaveEventArgs userDataSaveEventArgs)
{ {
// determine if user has trakt credentials // Ignore change events for any reason other than manually toggling played.
var traktUser = UserHelper.GetTraktUser(e.UserId); if (userDataSaveEventArgs.SaveReason != UserDataSaveReason.TogglePlayed)
// Can't progress
if (traktUser == null || !_traktApi.CanSync(e.Item, traktUser))
{ {
return; return;
} }
if (!traktUser.PostSetWatched && !traktUser.PostSetUnwatched) if (userDataSaveEventArgs.Item != null)
{
// Determine if user has trakt.tv credentials
var traktUser = UserHelper.GetTraktUser(userDataSaveEventArgs.UserId);
// Can't progress if user has no trakt.tv credentials
if (traktUser == null || !_traktApi.CanSync(userDataSaveEventArgs.Item, traktUser))
{
return;
}
if (!traktUser.PostSetWatched && !traktUser.PostSetUnwatched)
{
// User doesn't want to post any status changes at all
return;
}
// We have a user who wants to post updates and the item is in a trakt.tv monitored location
_userDataManagerEventsHelper.ProcessUserDataSaveEventArgs(userDataSaveEventArgs, traktUser);
}
}
/// <summary>
/// Run observer tasks for observed events.
/// </summary>
public Task RunAsync()
{
_userDataManager.UserDataSaved += OnUserDataSaved;
_sessionManager.PlaybackStart += KernelPlaybackStart;
_sessionManager.PlaybackProgress += KernelPlaybackProgress;
_sessionManager.PlaybackStopped += KernelPlaybackStopped;
_libraryManager.ItemAdded += LibraryManagerItemAdded;
_libraryManager.ItemRemoved += LibraryManagerItemRemoved;
return Task.CompletedTask;
}
/// <summary>
/// Library item was removed.
/// Let trakt.tv know which item was removed from the user's library.
/// </summary>
/// <param name="sender">The sending entity.</param>
/// <param name="itemChangeEventArgs">The <see cref="ItemChangeEventArgs"/>.</param>
private void LibraryManagerItemRemoved(object sender, ItemChangeEventArgs itemChangeEventArgs)
{
if (!(itemChangeEventArgs.Item is Movie) && !(itemChangeEventArgs.Item is Episode) && !(itemChangeEventArgs.Item is Series))
{ {
// User doesn't want to post any status changes at all.
return; return;
} }
// We have a user who wants to post updates and the item is in a trakt monitored location. if (itemChangeEventArgs.Item.LocationType == LocationType.Virtual)
_userDataManagerEventsHelper.ProcessUserDataSaveEventArgs(e, traktUser); {
} return;
} }
/// <inheritdoc /> _libraryManagerEventsHelper.QueueItem(itemChangeEventArgs.Item, EventType.Remove);
public Task RunAsync()
{
_userDataManager.UserDataSaved += OnUserDataSaved;
_sessionManager.PlaybackStart += KernelPlaybackStart;
_sessionManager.PlaybackStopped += KernelPlaybackStopped;
_libraryManager.ItemAdded += LibraryManagerItemAdded;
_libraryManager.ItemRemoved += LibraryManagerItemRemoved;
return Task.CompletedTask;
}
/// <summary>
///
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
private void LibraryManagerItemRemoved(object sender, ItemChangeEventArgs e)
{
if (!(e.Item is Movie) && !(e.Item is Episode) && !(e.Item is Series))
{
return;
} }
if (e.Item.LocationType == LocationType.Virtual) /// <summary>
/// Library item was added.
/// Let trakt.tv know which item was added to the user's library.
/// </summary>
/// <param name="sender">The sending entity.</param>
/// <param name="itemChangeEventArgs">The <see cref="ItemChangeEventArgs"/>.</param>
private void LibraryManagerItemAdded(object sender, ItemChangeEventArgs itemChangeEventArgs)
{ {
return; // Don't do anything if it's not a supported media type
if (!(itemChangeEventArgs.Item is Movie) && !(itemChangeEventArgs.Item is Episode) && !(itemChangeEventArgs.Item is Series))
{
return;
}
if (itemChangeEventArgs.Item.LocationType == LocationType.Virtual)
{
return;
}
_libraryManagerEventsHelper.QueueItem(itemChangeEventArgs.Item, EventType.Add);
} }
_libraryManagerEventsHelper.QueueItem(e.Item, EventType.Remove); /// <summary>
} /// Media playback has startet.
/// Let trakt.tv know that the user has started playback.
/// <summary> /// </summary>
/// /// <param name="sender">The sending entity.</param>
/// </summary> /// <param name="playbackProgressEventArgs">The <see cref="PlaybackProgressEventArgs"/>.</param>
/// <param name="sender"></param> private async void KernelPlaybackStart(object sender, PlaybackProgressEventArgs playbackProgressEventArgs)
/// <param name="e"></param>
private void LibraryManagerItemAdded(object sender, ItemChangeEventArgs e)
{
// Don't do anything if it's not a supported media type
if (!(e.Item is Movie) && !(e.Item is Episode) && !(e.Item is Series))
{ {
return; _logger.LogDebug("Playback started");
}
if (e.Item.LocationType == LocationType.Virtual) if (playbackProgressEventArgs.Users == null || !playbackProgressEventArgs.Users.Any() || playbackProgressEventArgs.Item == null)
{
return;
}
_libraryManagerEventsHelper.QueueItem(e.Item, EventType.Add);
}
/// <summary>
/// Let Trakt.tv know the user has started to watch something
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
private async void KernelPlaybackStart(object sender, PlaybackProgressEventArgs e)
{
try
{
_logger.LogInformation("Playback Started");
if (e.Users == null || !e.Users.Any() || e.Item == null)
{ {
_logger.LogError("Event details incomplete. Cannot process current media"); _logger.LogError("Event details incomplete. Cannot process current media");
return; return;
} }
foreach (var user in e.Users) foreach (var user in playbackProgressEventArgs.Users)
{
// Since Jellyfin supports user profiles we need to do a user lookup every time something starts
var traktUser = UserHelper.GetTraktUser(user);
if (traktUser == null)
{
_logger.LogDebug("Could not match user {User} with any stored trakt.tv credentials.", user.Username);
continue;
}
if (!traktUser.Scrobble)
{
_logger.LogDebug("User {User} disabled scrobbling to trakt.", user.Username);
continue;
}
if (!_traktApi.CanSync(playbackProgressEventArgs.Item, traktUser))
{
_logger.LogDebug("Syncing playback for {Item} is forbidden for user {User}.", playbackProgressEventArgs.Item.Name, user.Username);
continue;
}
var video = playbackProgressEventArgs.Item as Video;
var progressPercent = video.RunTimeTicks.HasValue && video.RunTimeTicks != 0 ?
(float)(playbackProgressEventArgs.PlaybackPositionTicks ?? 0) / video.RunTimeTicks.Value * 100.0f : 0.0f;
_logger.LogDebug("User {User} started watching item {Item}.", user.Username, playbackProgressEventArgs.Item.Name);
try
{
if (video is Movie movie)
{
_logger.LogDebug("Sending movie playback status update to trakt.tv for user {User}.", user.Username);
await _traktApi.SendMovieStatusUpdateAsync(
movie,
MediaStatus.Watching,
traktUser,
progressPercent).ConfigureAwait(false);
_playbackPause[traktUser.LinkedMbUserId] = false;
}
else if (video is Episode episode)
{
_logger.LogDebug("Sending episode playback status update to trakt.tv for user {User}.", user.Username);
await _traktApi.SendEpisodeStatusUpdateAsync(
episode,
MediaStatus.Watching,
traktUser,
progressPercent).ConfigureAwait(false);
_playbackPause[traktUser.LinkedMbUserId] = false;
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Exception occurred while sending a playback status update to trakt.tv for user {User}.", user.Username);
}
}
}
/// <summary>
/// Media playback has progressed.
/// Let trakt.tv know that the user has progressed in playback.
/// </summary>
/// <param name="sender">The sending entity.</param>
/// <param name="playbackProgressEventArgs">The <see cref="PlaybackProgressEventArgs"/>.</param>
private async void KernelPlaybackProgress(object sender, PlaybackProgressEventArgs playbackProgressEventArgs)
{
_logger.LogDebug("Playback progressed");
if (playbackProgressEventArgs.Users == null || !playbackProgressEventArgs.Users.Any() || playbackProgressEventArgs.Item == null)
{
_logger.LogError("Event details incomplete. Cannot process current media");
return;
}
foreach (var user in playbackProgressEventArgs.Users)
{ {
// Since Emby is user profile friendly, I'm going to need to do a user lookup every time something starts // Since Emby is user profile friendly, I'm going to need to do a user lookup every time something starts
var traktUser = UserHelper.GetTraktUser(user); var traktUser = UserHelper.GetTraktUser(user);
if (traktUser == null) if (traktUser == null)
{ {
_logger.LogInformation("Could not match user with any stored credentials"); _logger.LogDebug("Could not match user {User} with any stored trakt.tv credentials.", user.Username);
continue; continue;
} }
if (!traktUser.Scrobble) if (!traktUser.Scrobble)
{ {
_logger.LogDebug("User {User} disabled scrobbling to trakt.", user.Username);
continue; continue;
} }
if (!_traktApi.CanSync(e.Item, traktUser)) if (!_traktApi.CanSync(playbackProgressEventArgs.Item, traktUser))
{ {
_logger.LogDebug("Syncing playback for {Item} is forbidden for user {User}.", playbackProgressEventArgs.Item.Name, user.Username);
continue; continue;
} }
_logger.LogDebug("{UseId} appears to be monitoring {Path}", traktUser.LinkedMbUserId, e.Item.Path); var video = playbackProgressEventArgs.Item as Video;
var video = e.Item as Video;
var progressPercent = video.RunTimeTicks.HasValue && video.RunTimeTicks != 0 ? var progressPercent = video.RunTimeTicks.HasValue && video.RunTimeTicks != 0 ?
(float)(e.PlaybackPositionTicks ?? 0) / video.RunTimeTicks.Value * 100.0f : 0.0f; (float)(playbackProgressEventArgs.PlaybackPositionTicks ?? 0) / video.RunTimeTicks.Value * 100.0f : 0.0f;
_logger.LogDebug("User {User} progressed watching item {Item}.", user.Username, playbackProgressEventArgs.Item.Name);
try try
{ {
if (video is Movie movie) if (playbackProgressEventArgs.IsPaused)
{ {
_logger.LogDebug("Send movie status update"); _logger.LogDebug("Playback paused");
await _traktApi.SendMovieStatusUpdateAsync(
movie, if (video is Movie movie)
MediaStatus.Watching, {
traktUser, _logger.LogDebug("Sending movie playback status update to trakt.tv for user {User}.", user.Username);
progressPercent).ConfigureAwait(false); await _traktApi.SendMovieStatusUpdateAsync(
movie,
MediaStatus.Paused,
traktUser,
progressPercent).ConfigureAwait(false);
_playbackPause[traktUser.LinkedMbUserId] = true;
}
else if (video is Episode episode)
{
_logger.LogDebug("Sending episode playback status update to trakt.tv for user {User}.", user.Username);
await _traktApi.SendEpisodeStatusUpdateAsync(
episode,
MediaStatus.Paused,
traktUser,
progressPercent).ConfigureAwait(false);
_playbackPause[traktUser.LinkedMbUserId] = true;
}
} }
else if (video is Episode episode) else
{ {
_logger.LogDebug("Send episode status update"); _playbackPause.TryGetValue(traktUser.LinkedMbUserId, out bool paused);
await _traktApi.SendEpisodeStatusUpdateAsync( if (paused)
episode, {
MediaStatus.Watching, _logger.LogDebug("Playback resumed");
traktUser,
progressPercent).ConfigureAwait(false); if (video is Movie movie)
{
_logger.LogDebug("Sending movie playback status update to trakt.tv for user {User}.", user.Username);
await _traktApi.SendMovieStatusUpdateAsync(
movie,
MediaStatus.Watching,
traktUser,
progressPercent).ConfigureAwait(false);
}
else if (video is Episode episode)
{
_logger.LogDebug("Sending episode playback status update to trakt.tv for user {User}.", user.Username);
await _traktApi.SendEpisodeStatusUpdateAsync(
episode,
MediaStatus.Watching,
traktUser,
progressPercent).ConfigureAwait(false);
}
_playbackPause[traktUser.LinkedMbUserId] = false;
}
} }
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Exception handled sending status update"); _logger.LogError(ex, "Exception occurred while sending a playback status update to trakt.tv for user {User}.", user.Username);
} }
} }
} }
catch (Exception ex)
{
_logger.LogError(ex, "Error sending watching status update");
}
}
/// <summary> /// <summary>
/// Media playback has stopped. Depending on playback progress, let Trakt.tv know the user has /// Media playback has stopped.
/// completed watching the item. /// Depending on playback progress, let trakt.tv know the user has completed watching the item.
/// </summary> /// </summary>
/// <param name="sender"></param> /// <param name="sender"></param>
/// <param name="e"></param> /// <param name="playbackStoppedEventArgs"></param>
private async void KernelPlaybackStopped(object sender, PlaybackStopEventArgs e) private async void KernelPlaybackStopped(object sender, PlaybackStopEventArgs playbackStoppedEventArgs)
{
if (e.Users == null || !e.Users.Any() || e.Item == null)
{ {
_logger.LogError("Event details incomplete. Cannot process current media"); if (playbackStoppedEventArgs.Users == null || !playbackStoppedEventArgs.Users.Any() || playbackStoppedEventArgs.Item == null)
return; {
} _logger.LogError("Event details incomplete. Cannot process current media");
return;
}
try _logger.LogInformation("Playback stopped");
{
_logger.LogInformation("Playback Stopped");
foreach (var user in e.Users) foreach (var user in playbackStoppedEventArgs.Users)
{ {
var traktUser = UserHelper.GetTraktUser(user); var traktUser = UserHelper.GetTraktUser(user);
if (traktUser == null) if (traktUser == null)
{ {
_logger.LogError("Could not match trakt user"); _logger.LogDebug("Could not match user {User} with any stored trakt.tv credentials.", user.Username);
continue; continue;
} }
if (!traktUser.Scrobble) if (!traktUser.Scrobble)
{ {
_logger.LogDebug("User {User} disabled scrobbling to trakt.", user.Username);
continue; continue;
} }
if (!_traktApi.CanSync(e.Item, traktUser)) if (!_traktApi.CanSync(playbackStoppedEventArgs.Item, traktUser))
{ {
_logger.LogDebug("Syncing playback for {Item} is forbidden for user {User}.", playbackStoppedEventArgs.Item.Name, user.Username);
continue; continue;
} }
var video = e.Item as Video; var video = playbackStoppedEventArgs.Item as Video;
if (e.PlayedToCompletion) try
{ {
_logger.LogInformation("Item is played. Scrobble"); if (playbackStoppedEventArgs.PlayedToCompletion)
try
{ {
_logger.LogDebug("User {User} completed watching item {Item}. Scrobbling.", user.Username, playbackStoppedEventArgs.Item.Name);
if (video is Movie movie) if (video is Movie movie)
{ {
_logger.LogDebug("Sending movie playback status update to trakt.tv for user {User}.", user.Username);
await _traktApi.SendMovieStatusUpdateAsync( await _traktApi.SendMovieStatusUpdateAsync(
movie, movie,
MediaStatus.Stop, MediaStatus.Stop,
@ -280,6 +397,7 @@ public class ServerMediator : IServerEntryPoint
} }
else if (video is Episode episode) else if (video is Episode episode)
{ {
_logger.LogDebug("Sending episode playback status update to trakt.tv for user {User}.", user.Username);
await _traktApi.SendEpisodeStatusUpdateAsync( await _traktApi.SendEpisodeStatusUpdateAsync(
episode, episode,
MediaStatus.Stop, MediaStatus.Stop,
@ -287,54 +405,57 @@ public class ServerMediator : IServerEntryPoint
100).ConfigureAwait(false); 100).ConfigureAwait(false);
} }
} }
catch (Exception ex)
{
_logger.LogError(ex, "Exception handled sending status update");
}
}
else
{
var progressPercent = video.RunTimeTicks.HasValue && video.RunTimeTicks != 0 ?
(float)(e.PlaybackPositionTicks ?? 0) / video.RunTimeTicks.Value * 100.0f : 0.0f;
_logger.LogInformation("Item Not fully played. Tell trakt.tv we are no longer watching but don't scrobble");
if (video is Movie movie)
{
await _traktApi.SendMovieStatusUpdateAsync(movie, MediaStatus.Stop, traktUser, progressPercent).ConfigureAwait(false);
}
else else
{ {
await _traktApi.SendEpisodeStatusUpdateAsync(video as Episode, MediaStatus.Stop, traktUser, progressPercent).ConfigureAwait(false); var progressPercent = video.RunTimeTicks.HasValue && video.RunTimeTicks != 0 ?
(float)(playbackStoppedEventArgs.PlaybackPositionTicks ?? 0) / video.RunTimeTicks.Value * 100.0f : 0.0f;
_logger.LogDebug("User {User} didn't watch item {Item} until the end. Not scrobbling but stopping playback and sending current playback position.", user.Username, playbackStoppedEventArgs.Item.Name);
if (video is Movie movie)
{
await _traktApi.SendMovieStatusUpdateAsync(movie, MediaStatus.Stop, traktUser, progressPercent).ConfigureAwait(false);
}
else
{
await _traktApi.SendEpisodeStatusUpdateAsync(video as Episode, MediaStatus.Stop, traktUser, progressPercent).ConfigureAwait(false);
}
} }
_playbackPause[traktUser.LinkedMbUserId] = false;
}
catch (Exception ex)
{
_logger.LogError(ex, "Exception occurred while sending a playback status update to trakt.tv for user {User}.", user.Username);
} }
} }
} }
catch (Exception ex)
/// <inheritdoc />
public void Dispose()
{ {
_logger.LogError(ex, "Error sending scrobble"); Dispose(true);
GC.SuppressFinalize(this);
} }
}
/// <inheritdoc /> /// <summary>
public void Dispose() /// Removes event subscriptions on dispose.
{ /// </summary>
Dispose(true); /// <param name="disposing"><see cref="bool"/> indicating if object is currently disposed</param>
GC.SuppressFinalize(this); protected virtual void Dispose(bool disposing)
}
protected virtual void Dispose(bool disposing)
{
if (disposing)
{ {
_userDataManager.UserDataSaved -= OnUserDataSaved; if (disposing)
_sessionManager.PlaybackStart -= KernelPlaybackStart; {
_sessionManager.PlaybackStopped -= KernelPlaybackStopped; _userDataManager.UserDataSaved -= OnUserDataSaved;
_libraryManager.ItemAdded -= LibraryManagerItemAdded; _sessionManager.PlaybackStart -= KernelPlaybackStart;
_libraryManager.ItemRemoved -= LibraryManagerItemRemoved; _sessionManager.PlaybackStopped -= KernelPlaybackStopped;
_traktApi = null; _libraryManager.ItemAdded -= LibraryManagerItemAdded;
_libraryManagerEventsHelper.Dispose(); _libraryManager.ItemRemoved -= LibraryManagerItemRemoved;
_libraryManagerEventsHelper = null; _traktApi = null;
_userDataManagerEventsHelper.Dispose(); _libraryManagerEventsHelper.Dispose();
_libraryManagerEventsHelper = null;
_userDataManagerEventsHelper.Dispose();
}
} }
} }
} }