diff --git a/Shoko.Server/Providers/TraktTV/TraktConstants.cs b/Shoko.Server/Providers/TraktTV/TraktConstants.cs index 21d292c90..d690ff19e 100644 --- a/Shoko.Server/Providers/TraktTV/TraktConstants.cs +++ b/Shoko.Server/Providers/TraktTV/TraktConstants.cs @@ -14,6 +14,13 @@ public enum TraktSyncType HistoryRemove = 2 } +public enum TraktAuthTokenValidationResult +{ + Valid = 1, + Invalid = 2, + Unknown = 3 +} + public static class TraktStatusCodes { // http://docs.trakt.apiary.io/#introduction/status-codes @@ -34,6 +41,7 @@ public static class TraktStatusCodes public const int Conflict = 409; public const int Precondition_Failed = 412; + public const int Denied = 418; public const int Account_Limit_Exceeded = 420; public const int Account_Locked = 423; public const int Unprocessable_Entity = 422; diff --git a/Shoko.Server/Providers/TraktTV/TraktTVHelper.cs b/Shoko.Server/Providers/TraktTV/TraktTVHelper.cs index b0c2b9a13..056c4514e 100644 --- a/Shoko.Server/Providers/TraktTV/TraktTVHelper.cs +++ b/Shoko.Server/Providers/TraktTV/TraktTVHelper.cs @@ -8,6 +8,7 @@ using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Newtonsoft.Json; +using Newtonsoft.Json.Linq; using Shoko.Abstractions.Extensions; using Shoko.Abstractions.User.Services; using Shoko.Server.Models.Shoko; @@ -36,6 +37,34 @@ public TraktTVHelper(ILogger logger, ISettingsProvider settingsPr #region Helpers + private static bool IsInvalidGrantResponse(string response) + { + if (string.IsNullOrWhiteSpace(response)) + return false; + + try + { + var json = JObject.Parse(response); + return string.Equals( + (string?)json["error"], + "invalid_grant", + StringComparison.OrdinalIgnoreCase); + } + catch + { + return false; + } + } + + private static bool IsExpectedDeviceTokenPollingStatus(HttpStatusCode statusCode) + => (int)statusCode is + TraktStatusCodes.Awaiting_Auth or + TraktStatusCodes.Not_Found or + TraktStatusCodes.Conflict or + TraktStatusCodes.Token_Expired or + TraktStatusCodes.Denied or + TraktStatusCodes.Rate_Limit_Exceeded; + private int SendData(string uri, string json, string verb, Dictionary headers, ref string webResponse) { var ret = 400; @@ -59,29 +88,23 @@ private int SendData(string uri, string json, string verb, Dictionary BuildRequestHeaders() #region Authorization + public TraktAuthTokenValidationResult ValidateAuthToken() + { + var request = (HttpWebRequest)WebRequest.Create(TraktURIs.UserSettings); + + _logger.LogTrace("Trakt token validation\nuri: {Uri}", TraktURIs.UserSettings); + + request.KeepAlive = true; + request.Method = "GET"; + request.ContentLength = 0; + request.Timeout = 120000; + request.ContentType = "application/json"; + request.UserAgent = "JMM"; + foreach (var header in BuildRequestHeaders()) + { + request.Headers.Add(header.Key, header.Value); + } + + try + { + using var response = (HttpWebResponse)request.GetResponse(); + return (int)response.StatusCode == TraktStatusCodes.Success + ? TraktAuthTokenValidationResult.Valid + : TraktAuthTokenValidationResult.Unknown; + } + catch (WebException ex) when (ex.Response is HttpWebResponse response) + { + var statusCode = (int)response.StatusCode; + if (statusCode is TraktStatusCodes.Unauthorized or TraktStatusCodes.Forbidden) + { + _logger.LogWarning("Trakt auth token validation failed with {StatusCode}.", statusCode); + return TraktAuthTokenValidationResult.Invalid; + } + + _logger.LogError(ex, "Error validating Trakt auth token: {StatusCode}", statusCode); + return TraktAuthTokenValidationResult.Unknown; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error validating Trakt auth token"); + return TraktAuthTokenValidationResult.Unknown; + } + } + public bool RefreshAuthToken() { var settings = _settingsProvider.GetSettings(); + var shouldSaveSettings = false; + try { if (!settings.TraktTv.Enabled || @@ -218,6 +299,8 @@ public bool RefreshAuthToken() settings.TraktTv.AuthToken = string.Empty; settings.TraktTv.RefreshToken = string.Empty; settings.TraktTv.TokenExpirationDate = string.Empty; + settings.TraktTv.Enabled = false; + shouldSaveSettings = true; return false; } @@ -229,11 +312,11 @@ public bool RefreshAuthToken() var retData = string.Empty; TraktTVRateLimiter.Instance.EnsureRate(); var response = SendData(TraktURIs.Oauth, json, "POST", headers, ref retData); + if (response is TraktStatusCodes.Success or TraktStatusCodes.Success_Post) { var loginResponse = retData.FromJSON(); - // save the token to the config file to use for subsequent API calls settings.TraktTv.AuthToken = loginResponse.AccessToken; settings.TraktTv.RefreshToken = loginResponse.RefreshToken; @@ -242,28 +325,45 @@ public bool RefreshAuthToken() var expireDate = createdAt + validity; settings.TraktTv.TokenExpirationDate = expireDate.ToString(); + shouldSaveSettings = true; return true; } + if (IsInvalidGrantResponse(retData)) + { + _logger.LogWarning("Trakt refresh token is invalid, expired, or revoked. Disabling Trakt until it is re-authenticated."); + settings.TraktTv.Enabled = false; + } + else + { + _logger.LogWarning("Failed to refresh Trakt auth token. Response code: {ResponseCode}. Response data: {ResponseData}", response, retData); + } + settings.TraktTv.AuthToken = string.Empty; settings.TraktTv.RefreshToken = string.Empty; settings.TraktTv.TokenExpirationDate = string.Empty; + shouldSaveSettings = true; + + return false; } catch (Exception ex) { settings.TraktTv.AuthToken = string.Empty; settings.TraktTv.RefreshToken = string.Empty; settings.TraktTv.TokenExpirationDate = string.Empty; + shouldSaveSettings = true; _logger.LogError(ex, "Error in TraktTVHelper.RefreshAuthToken"); return false; } finally { - Utils.SettingsProvider.SaveSettings(); + if (shouldSaveSettings) + { + _settingsProvider.SaveSettings(); + } } - return false; } #endregion diff --git a/Shoko.Server/Providers/TraktTV/TraktURIs.cs b/Shoko.Server/Providers/TraktTV/TraktURIs.cs index 32a74d0bb..6053200ef 100644 --- a/Shoko.Server/Providers/TraktTV/TraktURIs.cs +++ b/Shoko.Server/Providers/TraktTV/TraktURIs.cs @@ -6,6 +6,7 @@ public static class TraktURIs public const string OAuthDeviceCode = TraktConstants.BaseAPIURL + @"/oauth/device/code"; public const string OAuthDeviceToken = TraktConstants.BaseAPIURL + @"/oauth/device/token"; + public const string UserSettings = TraktConstants.BaseAPIURL + @"/users/settings"; // add to history (mark as watched) // used for movies, series, episodes diff --git a/Shoko.Server/Scheduling/Jobs/Trakt/CheckTraktTokenJob.cs b/Shoko.Server/Scheduling/Jobs/Trakt/CheckTraktTokenJob.cs index 5e05df209..8babb3d27 100644 --- a/Shoko.Server/Scheduling/Jobs/Trakt/CheckTraktTokenJob.cs +++ b/Shoko.Server/Scheduling/Jobs/Trakt/CheckTraktTokenJob.cs @@ -34,6 +34,24 @@ public override Task Process() return Task.CompletedTask; } + var validationResult = _traktHelper.ValidateAuthToken(); + if (validationResult == TraktAuthTokenValidationResult.Invalid) + { + _logger.LogInformation("Trakt auth token is no longer valid. Refreshing token."); + if (_traktHelper.RefreshAuthToken()) + { + var newExpirationDate = DateTimeOffset.FromUnixTimeSeconds(long.Parse(settings.TraktTv.TokenExpirationDate)).DateTime; + _logger.LogInformation("Trakt token refreshed successfully. New expiry date: {Date}", newExpirationDate); + } + + return Task.CompletedTask; + } + + if (validationResult == TraktAuthTokenValidationResult.Unknown) + { + _logger.LogWarning("Unable to validate Trakt auth token. Falling back to stored expiry date."); + } + // Convert the Unix timestamp to DateTime var expirationDate = DateTimeOffset.FromUnixTimeSeconds(long.Parse(settings.TraktTv.TokenExpirationDate)).DateTime; diff --git a/Shoko.Server/Scheduling/QuartzStartup.cs b/Shoko.Server/Scheduling/QuartzStartup.cs index 494f8b5d8..66a490a5b 100644 --- a/Shoko.Server/Scheduling/QuartzStartup.cs +++ b/Shoko.Server/Scheduling/QuartzStartup.cs @@ -29,13 +29,22 @@ public static class QuartzStartup { public static async Task ScheduleRecurringJobs(bool replace) { + var settings = Utils.SettingsProvider.GetSettings(); + // this needs to run immediately upon scheduling, so it replaces always. Others will run on other schedules // Also give it a high priority, since it affects Acquisition Filters // StartJobNow gives a priority of 10. We'll give it 20 to be even higher priority await ScheduleRecurringJob( triggerConfig: t => t.WithPriority(20).WithSimpleSchedule(tr => tr.WithIntervalInMinutes(30).RepeatForever()).StartNow(), replace: true, keepSchedule: false); - await ScheduleRecurringJob( - triggerConfig: t => t.WithPriority(20).WithSimpleSchedule(tr => tr.WithIntervalInMinutes(60).RepeatForever()).StartNow(), replace: true, keepSchedule: false); + if (settings.TraktTv.Enabled) + { + await ScheduleRecurringJob( + triggerConfig: t => t.WithPriority(20).WithSimpleSchedule(tr => tr.WithIntervalInMinutes(60).RepeatForever()).StartNow(), replace: true, keepSchedule: false); + } + else + { + await RemoveRecurringJob(); + } // TODO the other schedule-based jobs that are on timers } @@ -87,6 +96,19 @@ await scheduler.ScheduleJob(JobBuilder.Create().UsingJobData(jobConfig).WithG } } + private static async Task RemoveRecurringJob() where T : class, IJob + { + var groupName = typeof(T).GetCustomAttribute()?.GroupName; + var jobKey = JobKeyBuilder.Create().WithGroup(groupName).Build(); + var scheduler = await Utils.ServiceContainer.GetRequiredService().GetScheduler(); + + using var _ = await QuartzExtensions.SchedulerLock.WriterLockAsync(); + if (await scheduler.CheckExists(jobKey)) + { + await scheduler.DeleteJob(jobKey); + } + } + internal static void AddQuartz(this IServiceCollection services, ISystemService systemService) { // this lets us inject the shoko JobFactory explicitly, instead of only IJobFactory