From 067c029f42e9fd853d060fdb2093013b15ac14c0 Mon Sep 17 00:00:00 2001 From: tidusjar Date: Fri, 11 Jul 2025 22:19:10 +0100 Subject: [PATCH] feat: Added the ability for the Watchlist to automatically refresh the users token. This will reduce the need for the user to log in --- src/Ombi.Api.Plex/IPlexApi.cs | 1 + src/Ombi.Api.Plex/PlexApi.cs | 24 +++++++++++ src/Ombi.DependencyInjection/IocExtensions.cs | 1 + .../PlexWatchlistImportTests.cs | 41 +++++++++++++++++++ .../Jobs/Plex/PlexWatchlistImport.cs | 35 +++++++++++++++- 5 files changed, 101 insertions(+), 1 deletion(-) diff --git a/src/Ombi.Api.Plex/IPlexApi.cs b/src/Ombi.Api.Plex/IPlexApi.cs index 6632da875..be6a61c16 100644 --- a/src/Ombi.Api.Plex/IPlexApi.cs +++ b/src/Ombi.Api.Plex/IPlexApi.cs @@ -29,5 +29,6 @@ namespace Ombi.Api.Plex Task AddUser(string emailAddress, string serverId, string authToken, int[] libs); Task GetWatchlist(string plexToken, CancellationToken cancellationToken); Task GetWatchlistMetadata(string ratingKey, string plexToken, CancellationToken cancellationToken); + Task Ping(string authToken, CancellationToken cancellationToken = default); } } \ No newline at end of file diff --git a/src/Ombi.Api.Plex/PlexApi.cs b/src/Ombi.Api.Plex/PlexApi.cs index cc0d13aaa..2d2f0271d 100644 --- a/src/Ombi.Api.Plex/PlexApi.cs +++ b/src/Ombi.Api.Plex/PlexApi.cs @@ -320,6 +320,30 @@ namespace Ombi.Api.Plex return result; } + /// + /// Pings the Plex API to validate if a token is still valid + /// + /// The authentication token to validate + /// Cancellation token + /// True if the token is valid, false otherwise + public async Task 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; + } + } + /// /// Adds the required headers and also the authorization header diff --git a/src/Ombi.DependencyInjection/IocExtensions.cs b/src/Ombi.DependencyInjection/IocExtensions.cs index caceb9b0e..027717bbe 100644 --- a/src/Ombi.DependencyInjection/IocExtensions.cs +++ b/src/Ombi.DependencyInjection/IocExtensions.cs @@ -107,6 +107,7 @@ namespace Ombi.DependencyInjection services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); diff --git a/src/Ombi.Schedule.Tests/PlexWatchlistImportTests.cs b/src/Ombi.Schedule.Tests/PlexWatchlistImportTests.cs index b1fec6d1b..87a1d5e28 100644 --- a/src/Ombi.Schedule.Tests/PlexWatchlistImportTests.cs +++ b/src/Ombi.Schedule.Tests/PlexWatchlistImportTests.cs @@ -24,6 +24,7 @@ using Ombi.Notifications.Models; using Ombi.Core.Notifications; using Ombi.Helpers; using Ombi.Core; +using Ombi.Core.Authentication; namespace Ombi.Schedule.Tests { @@ -43,6 +44,8 @@ namespace Ombi.Schedule.Tests _mocker.Use(um); _context = _mocker.GetMock(); _context.Setup(x => x.CancellationToken).Returns(CancellationToken.None); + // Mock the keep-alive service to return true by default + _mocker.Use(Mock.Of(s => s.KeepTokenAliveAsync(It.IsAny(), It.IsAny()) == Task.FromResult(true))); _subject = _mocker.CreateInstance(); _mocker.Setup, IQueryable>(x => x.GetAll()).Returns(new List().AsQueryable().BuildMock()); _mocker.Setup(x => x.Notify(It.IsAny())); @@ -838,5 +841,43 @@ namespace Ombi.Schedule.Tests // Assert _mocker.Verify(x => x.Notify(It.IsAny()), Times.Never); } + + [Test] + public async Task SkipsUserIfTokenKeepAliveFails() + { + // Arrange: Set up the keep-alive service to return false (token invalid/expired) + var keepAliveMock = new Mock(); + keepAliveMock.Setup(x => x.KeepTokenAliveAsync(It.IsAny(), It.IsAny())).ReturnsAsync(false); + _mocker.Use(keepAliveMock.Object); + _subject = _mocker.CreateInstance(); + _mocker.Setup, Task>(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(), It.IsAny()), Times.Once); + _mocker.Verify(x => x.GetWatchlist(It.IsAny(), It.IsAny()), Times.Never); + _mocker.Verify(x => x.Notify(It.IsAny()), Times.Never); // or Times.Once if notification is expected + } + [Test] + public async Task CallsKeepAliveForEachPlexUser() + { + // Arrange: Multiple Plex users + var users = new List + { + 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(); + keepAliveMock.Setup(x => x.KeepTokenAliveAsync(It.IsAny(), It.IsAny())).ReturnsAsync(true); + _mocker.Use(keepAliveMock.Object); + _subject = _mocker.CreateInstance(); + _mocker.Setup, Task>(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(), It.IsAny()), Times.Exactly(users.Count)); + } } } diff --git a/src/Ombi.Schedule/Jobs/Plex/PlexWatchlistImport.cs b/src/Ombi.Schedule/Jobs/Plex/PlexWatchlistImport.cs index 947e54406..61151a56f 100644 --- a/src/Ombi.Schedule/Jobs/Plex/PlexWatchlistImport.cs +++ b/src/Ombi.Schedule/Jobs/Plex/PlexWatchlistImport.cs @@ -27,6 +27,7 @@ using Ombi.Core.Notifications; using Microsoft.AspNetCore.Identity; using Ombi.Store.Repository.Requests; using Ombi.Core; +using Ombi.Core.Authentication; namespace Ombi.Schedule.Jobs.Plex { @@ -43,11 +44,12 @@ namespace Ombi.Schedule.Jobs.Plex private readonly IRepository _userError; private readonly IMovieDbApi _movieDbApi; private readonly INotificationHelper _notificationHelper; + private readonly IPlexTokenKeepAliveService _tokenKeepAliveService; public PlexWatchlistImport(IPlexApi plexApi, ISettingsService settings, OmbiUserManager ombiUserManager, IMovieRequestEngine movieRequestEngine, ITvRequestEngine tvRequestEngine, INotificationHubService notificationHubService, ILogger logger, IExternalRepository watchlistRepo, IRepository userError, - IMovieDbApi movieDbApi, INotificationHelper notificationHelper) + IMovieDbApi movieDbApi, INotificationHelper notificationHelper, IPlexTokenKeepAliveService tokenKeepAliveService) { _plexApi = plexApi; _settings = settings; @@ -60,6 +62,7 @@ namespace Ombi.Schedule.Jobs.Plex _userError = userError; _movieDbApi = movieDbApi; _notificationHelper = notificationHelper; + _tokenKeepAliveService = tokenKeepAliveService; } 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}"); + + // 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 + { + { "UserName", user.UserName } + } + }; + await _notificationHelper.Notify(notificationModel); + } + continue; + } + var watchlist = await _plexApi.GetWatchlist(user.MediaServerToken, context?.CancellationToken ?? CancellationToken.None); if (watchlist?.AuthError ?? false) {