feat: Added the ability for the Watchlist to automatically refresh the users token. This will reduce the need for the user to log in

This commit is contained in:
tidusjar 2025-07-11 22:19:10 +01:00
commit 067c029f42
5 changed files with 101 additions and 1 deletions

View file

@ -29,5 +29,6 @@ namespace Ombi.Api.Plex
Task<PlexAddWrapper> AddUser(string emailAddress, string serverId, string authToken, int[] libs); Task<PlexAddWrapper> AddUser(string emailAddress, string serverId, string authToken, int[] libs);
Task<PlexWatchlistContainer> GetWatchlist(string plexToken, CancellationToken cancellationToken); Task<PlexWatchlistContainer> GetWatchlist(string plexToken, CancellationToken cancellationToken);
Task<PlexWatchlistMetadataContainer> GetWatchlistMetadata(string ratingKey, string plexToken, CancellationToken cancellationToken); Task<PlexWatchlistMetadataContainer> GetWatchlistMetadata(string ratingKey, string plexToken, CancellationToken cancellationToken);
Task<bool> Ping(string authToken, CancellationToken cancellationToken = default);
} }
} }

View file

@ -320,6 +320,30 @@ namespace Ombi.Api.Plex
return result; return result;
} }
/// <summary>
/// Pings the Plex API to validate if a token is still valid
/// </summary>
/// <param name="authToken">The authentication token to validate</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>True if the token is valid, false otherwise</returns>
public async Task<bool> Ping(string authToken, CancellationToken cancellationToken = default)
{
try
{
var request = new Request("api/v2/ping", "https://plex.tv/", HttpMethod.Get);
await AddHeaders(request, authToken);
// We don't need to parse the response, just check if the request succeeds
await Api.Request(request, cancellationToken);
return true;
}
catch
{
// If the request fails (401, 403, etc.), the token is invalid
return false;
}
}
/// <summary> /// <summary>
/// Adds the required headers and also the authorization header /// Adds the required headers and also the authorization header

View file

@ -107,6 +107,7 @@ namespace Ombi.DependencyInjection
services.AddTransient<IMusicSender, MusicSender>(); services.AddTransient<IMusicSender, MusicSender>();
services.AddTransient<IMassEmailSender, MassEmailSender>(); services.AddTransient<IMassEmailSender, MassEmailSender>();
services.AddTransient<IPlexOAuthManager, PlexOAuthManager>(); services.AddTransient<IPlexOAuthManager, PlexOAuthManager>();
services.AddTransient<IPlexTokenKeepAliveService, PlexTokenKeepAliveService>();
services.AddTransient<IVoteEngine, VoteEngine>(); services.AddTransient<IVoteEngine, VoteEngine>();
services.AddTransient<IDemoMovieSearchEngine, DemoMovieSearchEngine>(); services.AddTransient<IDemoMovieSearchEngine, DemoMovieSearchEngine>();
services.AddTransient<IDemoTvSearchEngine, DemoTvSearchEngine>(); services.AddTransient<IDemoTvSearchEngine, DemoTvSearchEngine>();

View file

@ -24,6 +24,7 @@ using Ombi.Notifications.Models;
using Ombi.Core.Notifications; using Ombi.Core.Notifications;
using Ombi.Helpers; using Ombi.Helpers;
using Ombi.Core; using Ombi.Core;
using Ombi.Core.Authentication;
namespace Ombi.Schedule.Tests namespace Ombi.Schedule.Tests
{ {
@ -43,6 +44,8 @@ namespace Ombi.Schedule.Tests
_mocker.Use(um); _mocker.Use(um);
_context = _mocker.GetMock<IJobExecutionContext>(); _context = _mocker.GetMock<IJobExecutionContext>();
_context.Setup(x => x.CancellationToken).Returns(CancellationToken.None); _context.Setup(x => x.CancellationToken).Returns(CancellationToken.None);
// Mock the keep-alive service to return true by default
_mocker.Use<IPlexTokenKeepAliveService>(Mock.Of<IPlexTokenKeepAliveService>(s => s.KeepTokenAliveAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()) == Task.FromResult(true)));
_subject = _mocker.CreateInstance<PlexWatchlistImport>(); _subject = _mocker.CreateInstance<PlexWatchlistImport>();
_mocker.Setup<IRepository<PlexWatchlistUserError>, IQueryable<PlexWatchlistUserError>>(x => x.GetAll()).Returns(new List<PlexWatchlistUserError>().AsQueryable().BuildMock()); _mocker.Setup<IRepository<PlexWatchlistUserError>, IQueryable<PlexWatchlistUserError>>(x => x.GetAll()).Returns(new List<PlexWatchlistUserError>().AsQueryable().BuildMock());
_mocker.Setup<INotificationHelper>(x => x.Notify(It.IsAny<NotificationOptions>())); _mocker.Setup<INotificationHelper>(x => x.Notify(It.IsAny<NotificationOptions>()));
@ -838,5 +841,43 @@ namespace Ombi.Schedule.Tests
// Assert // Assert
_mocker.Verify<INotificationHelper>(x => x.Notify(It.IsAny<NotificationOptions>()), Times.Never); _mocker.Verify<INotificationHelper>(x => x.Notify(It.IsAny<NotificationOptions>()), Times.Never);
} }
[Test]
public async Task SkipsUserIfTokenKeepAliveFails()
{
// Arrange: Set up the keep-alive service to return false (token invalid/expired)
var keepAliveMock = new Mock<IPlexTokenKeepAliveService>();
keepAliveMock.Setup(x => x.KeepTokenAliveAsync(It.IsAny<string>(), It.IsAny<CancellationToken>())).ReturnsAsync(false);
_mocker.Use(keepAliveMock.Object);
_subject = _mocker.CreateInstance<PlexWatchlistImport>();
_mocker.Setup<ISettingsService<PlexSettings>, Task<PlexSettings>>(x => x.GetSettingsAsync()).ReturnsAsync(new PlexSettings { Enable = true, EnableWatchlistImport = true });
// Act
await _subject.Execute(_context.Object);
// Assert: Should not attempt to import watchlist if keep-alive fails
keepAliveMock.Verify(x => x.KeepTokenAliveAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Once);
_mocker.Verify<IPlexApi>(x => x.GetWatchlist(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
_mocker.Verify<INotificationHelper>(x => x.Notify(It.IsAny<NotificationOptions>()), Times.Never); // or Times.Once if notification is expected
}
[Test]
public async Task CallsKeepAliveForEachPlexUser()
{
// Arrange: Multiple Plex users
var users = new List<OmbiUser>
{
new OmbiUser { Id = "abc1", UserType = UserType.PlexUser, MediaServerToken = "abc1", UserName = "abc1", NormalizedUserName = "ABC1" },
new OmbiUser { Id = "abc2", UserType = UserType.PlexUser, MediaServerToken = "abc2", UserName = "abc2", NormalizedUserName = "ABC2" },
};
var um = MockHelper.MockUserManager(users);
_mocker.Use(um);
var keepAliveMock = new Mock<IPlexTokenKeepAliveService>();
keepAliveMock.Setup(x => x.KeepTokenAliveAsync(It.IsAny<string>(), It.IsAny<CancellationToken>())).ReturnsAsync(true);
_mocker.Use(keepAliveMock.Object);
_subject = _mocker.CreateInstance<PlexWatchlistImport>();
_mocker.Setup<ISettingsService<PlexSettings>, Task<PlexSettings>>(x => x.GetSettingsAsync()).ReturnsAsync(new PlexSettings { Enable = true, EnableWatchlistImport = true });
// Act
await _subject.Execute(_context.Object);
// Assert: KeepAlive should be called for each user
keepAliveMock.Verify(x => x.KeepTokenAliveAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Exactly(users.Count));
}
} }
} }

View file

@ -27,6 +27,7 @@ using Ombi.Core.Notifications;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Ombi.Store.Repository.Requests; using Ombi.Store.Repository.Requests;
using Ombi.Core; using Ombi.Core;
using Ombi.Core.Authentication;
namespace Ombi.Schedule.Jobs.Plex namespace Ombi.Schedule.Jobs.Plex
{ {
@ -43,11 +44,12 @@ namespace Ombi.Schedule.Jobs.Plex
private readonly IRepository<PlexWatchlistUserError> _userError; private readonly IRepository<PlexWatchlistUserError> _userError;
private readonly IMovieDbApi _movieDbApi; private readonly IMovieDbApi _movieDbApi;
private readonly INotificationHelper _notificationHelper; private readonly INotificationHelper _notificationHelper;
private readonly IPlexTokenKeepAliveService _tokenKeepAliveService;
public PlexWatchlistImport(IPlexApi plexApi, ISettingsService<PlexSettings> settings, OmbiUserManager ombiUserManager, public PlexWatchlistImport(IPlexApi plexApi, ISettingsService<PlexSettings> settings, OmbiUserManager ombiUserManager,
IMovieRequestEngine movieRequestEngine, ITvRequestEngine tvRequestEngine, INotificationHubService notificationHubService, IMovieRequestEngine movieRequestEngine, ITvRequestEngine tvRequestEngine, INotificationHubService notificationHubService,
ILogger<PlexWatchlistImport> logger, IExternalRepository<PlexWatchlistHistory> watchlistRepo, IRepository<PlexWatchlistUserError> userError, ILogger<PlexWatchlistImport> logger, IExternalRepository<PlexWatchlistHistory> watchlistRepo, IRepository<PlexWatchlistUserError> userError,
IMovieDbApi movieDbApi, INotificationHelper notificationHelper) IMovieDbApi movieDbApi, INotificationHelper notificationHelper, IPlexTokenKeepAliveService tokenKeepAliveService)
{ {
_plexApi = plexApi; _plexApi = plexApi;
_settings = settings; _settings = settings;
@ -60,6 +62,7 @@ namespace Ombi.Schedule.Jobs.Plex
_userError = userError; _userError = userError;
_movieDbApi = movieDbApi; _movieDbApi = movieDbApi;
_notificationHelper = notificationHelper; _notificationHelper = notificationHelper;
_tokenKeepAliveService = tokenKeepAliveService;
} }
public async Task Execute(IJobExecutionContext context) public async Task Execute(IJobExecutionContext context)
@ -97,6 +100,36 @@ namespace Ombi.Schedule.Jobs.Plex
} }
_logger.LogDebug($"Starting Watchlist Import for {user.UserName} with token {user.MediaServerToken}"); _logger.LogDebug($"Starting Watchlist Import for {user.UserName} with token {user.MediaServerToken}");
// Keep the token alive before attempting watchlist import
var keepAliveSuccess = await _tokenKeepAliveService.KeepTokenAliveAsync(user.MediaServerToken, context?.CancellationToken ?? CancellationToken.None);
if (!keepAliveSuccess)
{
_logger.LogWarning($"Token for user '{user.UserName}' is invalid or expired (keep-alive failed). Recording error and skipping.");
await _userError.Add(new PlexWatchlistUserError
{
UserId = user.Id,
MediaServerToken = user.MediaServerToken,
});
// Send notification to user about token expiration
if (settings.NotifyOnWatchlistTokenExpiration && !string.IsNullOrEmpty(user.Email))
{
var notificationModel = new NotificationOptions
{
NotificationType = NotificationType.PlexWatchlistTokenExpired,
Recipient = user.Email,
DateTime = DateTime.Now,
Substitutes = new Dictionary<string, string>
{
{ "UserName", user.UserName }
}
};
await _notificationHelper.Notify(notificationModel);
}
continue;
}
var watchlist = await _plexApi.GetWatchlist(user.MediaServerToken, context?.CancellationToken ?? CancellationToken.None); var watchlist = await _plexApi.GetWatchlist(user.MediaServerToken, context?.CancellationToken ?? CancellationToken.None);
if (watchlist?.AuthError ?? false) if (watchlist?.AuthError ?? false)
{ {