From 085b1db77ff7eeda82bab21292bf2cb5e1632493 Mon Sep 17 00:00:00 2001 From: Mark McDowall Date: Wed, 19 Jun 2024 15:50:36 -0700 Subject: [PATCH] New: Ability to select Plex Media Server from plex.tv (cherry picked from commit 4c622fd41289cd293a68a6a9f6b8da2a086edecb) Closes #10110 --- .../Components/Form/EnhancedSelectInput.js | 34 +++++---- .../Form/EnhancedSelectInputConnector.js | 9 ++- .../EditNotificationModalContentConnector.js | 10 +-- .../createSetProviderFieldValuesReducer.js | 25 +++++++ .../Store/Actions/Settings/notifications.js | 10 +++ .../Annotations/FieldDefinitionAttribute.cs | 21 +++++- .../NewznabCategoryFieldOptionsConverter.cs | 8 +- src/NzbDrone.Core/Localization/Core/en.json | 2 + .../Notifications/Plex/PlexTv/PlexTvProxy.cs | 29 ++++++++ .../Plex/PlexTv/PlexTvResource.cs | 32 ++++++++ .../Plex/PlexTv/PlexTvService.cs | 12 +++ .../Notifications/Plex/Server/PlexServer.cs | 74 +++++++++++++++++++ .../Plex/Server/PlexServerSettings.cs | 24 +++--- 13 files changed, 252 insertions(+), 38 deletions(-) create mode 100644 frontend/src/Store/Actions/Creators/Reducers/createSetProviderFieldValuesReducer.js create mode 100644 src/NzbDrone.Core/Notifications/Plex/PlexTv/PlexTvResource.cs diff --git a/frontend/src/Components/Form/EnhancedSelectInput.js b/frontend/src/Components/Form/EnhancedSelectInput.js index 18e764b66..d686665ca 100644 --- a/frontend/src/Components/Form/EnhancedSelectInput.js +++ b/frontend/src/Components/Form/EnhancedSelectInput.js @@ -271,26 +271,32 @@ class EnhancedSelectInput extends Component { this.setState({ isOpen: !this.state.isOpen }); }; - onSelect = (value) => { - if (Array.isArray(this.props.value)) { - let newValue = null; - const index = this.props.value.indexOf(value); + onSelect = (newValue) => { + const { name, value, values, onChange } = this.props; + const additionalProperties = values.find((v) => v.key === newValue)?.additionalProperties; + + if (Array.isArray(value)) { + let arrayValue = null; + const index = value.indexOf(newValue); + if (index === -1) { - newValue = this.props.values.map((v) => v.key).filter((v) => (v === value) || this.props.value.includes(v)); + arrayValue = values.map((v) => v.key).filter((v) => (v === newValue) || value.includes(v)); } else { - newValue = [...this.props.value]; - newValue.splice(index, 1); + arrayValue = [...value]; + arrayValue.splice(index, 1); } - this.props.onChange({ - name: this.props.name, - value: newValue + onChange({ + name, + value: arrayValue, + additionalProperties }); } else { this.setState({ isOpen: false }); - this.props.onChange({ - name: this.props.name, - value + onChange({ + name, + value: newValue, + additionalProperties }); } }; @@ -485,7 +491,7 @@ class EnhancedSelectInput extends Component { values.map((v, index) => { const hasParent = v.parentKey !== undefined; const depth = hasParent ? 1 : 0; - const parentSelected = hasParent && value.includes(v.parentKey); + const parentSelected = hasParent && Array.isArray(value) && value.includes(v.parentKey); return ( { - this.props.setNotificationFieldValue({ name, value }); + onFieldChange = ({ name, value, additionalProperties = {} }) => { + this.props.setNotificationFieldValues({ properties: { ...additionalProperties, [name]: value } }); }; onSavePress = () => { @@ -91,7 +91,7 @@ EditNotificationModalContentConnector.propTypes = { saveError: PropTypes.object, item: PropTypes.object.isRequired, setNotificationValue: PropTypes.func.isRequired, - setNotificationFieldValue: PropTypes.func.isRequired, + setNotificationFieldValues: PropTypes.func.isRequired, saveNotification: PropTypes.func.isRequired, testNotification: PropTypes.func.isRequired, toggleAdvancedSettings: PropTypes.func.isRequired, diff --git a/frontend/src/Store/Actions/Creators/Reducers/createSetProviderFieldValuesReducer.js b/frontend/src/Store/Actions/Creators/Reducers/createSetProviderFieldValuesReducer.js new file mode 100644 index 000000000..ee9afb597 --- /dev/null +++ b/frontend/src/Store/Actions/Creators/Reducers/createSetProviderFieldValuesReducer.js @@ -0,0 +1,25 @@ +import getSectionState from 'Utilities/State/getSectionState'; +import updateSectionState from 'Utilities/State/updateSectionState'; + +function createSetProviderFieldValuesReducer(section) { + return (state, { payload }) => { + if (section === payload.section) { + const { properties } = payload; + const newState = getSectionState(state, section); + newState.pendingChanges = Object.assign({}, newState.pendingChanges); + const fields = Object.assign({}, newState.pendingChanges.fields || {}); + + Object.keys(properties).forEach((name) => { + fields[name] = properties[name]; + }); + + newState.pendingChanges.fields = fields; + + return updateSectionState(state, section, newState); + } + + return state; + }; +} + +export default createSetProviderFieldValuesReducer; diff --git a/frontend/src/Store/Actions/Settings/notifications.js b/frontend/src/Store/Actions/Settings/notifications.js index 3bd0792aa..7e2a4796e 100644 --- a/frontend/src/Store/Actions/Settings/notifications.js +++ b/frontend/src/Store/Actions/Settings/notifications.js @@ -5,6 +5,7 @@ import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHand import createSaveProviderHandler, { createCancelSaveProviderHandler } from 'Store/Actions/Creators/createSaveProviderHandler'; import createTestProviderHandler, { createCancelTestProviderHandler } from 'Store/Actions/Creators/createTestProviderHandler'; import createSetProviderFieldValueReducer from 'Store/Actions/Creators/Reducers/createSetProviderFieldValueReducer'; +import createSetProviderFieldValuesReducer from 'Store/Actions/Creators/Reducers/createSetProviderFieldValuesReducer'; import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer'; import { createThunk } from 'Store/thunks'; import selectProviderSchema from 'Utilities/State/selectProviderSchema'; @@ -22,6 +23,7 @@ export const FETCH_NOTIFICATION_SCHEMA = 'settings/notifications/fetchNotificati export const SELECT_NOTIFICATION_SCHEMA = 'settings/notifications/selectNotificationSchema'; export const SET_NOTIFICATION_VALUE = 'settings/notifications/setNotificationValue'; export const SET_NOTIFICATION_FIELD_VALUE = 'settings/notifications/setNotificationFieldValue'; +export const SET_NOTIFICATION_FIELD_VALUES = 'settings/notifications/setNotificationFieldValues'; export const SAVE_NOTIFICATION = 'settings/notifications/saveNotification'; export const CANCEL_SAVE_NOTIFICATION = 'settings/notifications/cancelSaveNotification'; export const DELETE_NOTIFICATION = 'settings/notifications/deleteNotification'; @@ -55,6 +57,13 @@ export const setNotificationFieldValue = createAction(SET_NOTIFICATION_FIELD_VAL }; }); +export const setNotificationFieldValues = createAction(SET_NOTIFICATION_FIELD_VALUES, (payload) => { + return { + section, + ...payload + }; +}); + // // Details @@ -99,6 +108,7 @@ export default { reducers: { [SET_NOTIFICATION_VALUE]: createSetSettingValueReducer(section), [SET_NOTIFICATION_FIELD_VALUE]: createSetProviderFieldValueReducer(section), + [SET_NOTIFICATION_FIELD_VALUES]: createSetProviderFieldValuesReducer(section), [SELECT_NOTIFICATION_SCHEMA]: (state, { payload }) => { return selectProviderSchema(state, section, payload, (selectedSchema) => { diff --git a/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs b/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs index 5e43f8285..413bbdb25 100644 --- a/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs +++ b/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Runtime.CompilerServices; namespace NzbDrone.Core.Annotations @@ -58,13 +59,27 @@ public FieldTokenAttribute(TokenField field, string label = "", string token = " public string Value { get; set; } } - public class FieldSelectOption + public class FieldSelectOption + where T : struct { - public int Value { get; set; } + public T Value { get; set; } public string Name { get; set; } public int Order { get; set; } public string Hint { get; set; } - public int? ParentValue { get; set; } + public T? ParentValue { get; set; } + public bool? IsDisabled { get; set; } + public Dictionary AdditionalProperties { get; set; } + } + + public class FieldSelectStringOption + { + public string Value { get; set; } + public string Name { get; set; } + public int Order { get; set; } + public string Hint { get; set; } + public string ParentValue { get; set; } + public bool? IsDisabled { get; set; } + public Dictionary AdditionalProperties { get; set; } } public enum FieldType diff --git a/src/NzbDrone.Core/Indexers/Newznab/NewznabCategoryFieldOptionsConverter.cs b/src/NzbDrone.Core/Indexers/Newznab/NewznabCategoryFieldOptionsConverter.cs index 9572f1df2..6d1095ca7 100644 --- a/src/NzbDrone.Core/Indexers/Newznab/NewznabCategoryFieldOptionsConverter.cs +++ b/src/NzbDrone.Core/Indexers/Newznab/NewznabCategoryFieldOptionsConverter.cs @@ -6,7 +6,7 @@ namespace NzbDrone.Core.Indexers.Newznab { public static class NewznabCategoryFieldOptionsConverter { - public static List GetFieldSelectOptions(List categories) + public static List> GetFieldSelectOptions(List categories) { // Categories not relevant for Radarr var ignoreCategories = new[] { 1000, 3000, 4000, 6000, 7000 }; @@ -14,7 +14,7 @@ public static List GetFieldSelectOptions(List(); + var result = new List>(); if (categories == null) { @@ -39,7 +39,7 @@ public static List GetFieldSelectOptions(List !ignoreCategories.Contains(cat.Id)).OrderBy(cat => unimportantCategories.Contains(cat.Id)).ThenBy(cat => cat.Id)) { - result.Add(new FieldSelectOption + result.Add(new FieldSelectOption { Value = category.Id, Name = category.Name, @@ -50,7 +50,7 @@ public static List GetFieldSelectOptions(List cat.Id)) { - result.Add(new FieldSelectOption + result.Add(new FieldSelectOption { Value = subcat.Id, Name = subcat.Name, diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json index 05def6f6f..044801df8 100644 --- a/src/NzbDrone.Core/Localization/Core/en.json +++ b/src/NzbDrone.Core/Localization/Core/en.json @@ -1118,6 +1118,8 @@ "NotificationsNtfyValidationAuthorizationRequired": "Authorization is required", "NotificationsPlexSettingsAuthToken": "Auth Token", "NotificationsPlexSettingsAuthenticateWithPlexTv": "Authenticate with Plex.tv", + "NotificationsPlexSettingsServer": "Server", + "NotificationsPlexSettingsServerHelpText": "Select server from plex.tv account after authenticating", "NotificationsPlexValidationNoMovieLibraryFound": "At least one Movie library is required", "NotificationsPushBulletSettingSenderId": "Sender ID", "NotificationsPushBulletSettingSenderIdHelpText": "The device ID to send notifications from, use device_iden in the device's URL on pushbullet.com (leave blank to send from yourself)", diff --git a/src/NzbDrone.Core/Notifications/Plex/PlexTv/PlexTvProxy.cs b/src/NzbDrone.Core/Notifications/Plex/PlexTv/PlexTvProxy.cs index 39432f793..199ae0a59 100644 --- a/src/NzbDrone.Core/Notifications/Plex/PlexTv/PlexTvProxy.cs +++ b/src/NzbDrone.Core/Notifications/Plex/PlexTv/PlexTvProxy.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Net; using NLog; using NzbDrone.Common.EnvironmentInfo; @@ -12,6 +13,7 @@ public interface IPlexTvProxy { string GetAuthToken(string clientIdentifier, int pinId); bool Ping(string clientIdentifier, string authToken); + List GetResources(string clientIdentifier, string authToken); } public class PlexTvProxy : IPlexTvProxy @@ -61,6 +63,33 @@ public bool Ping(string clientIdentifier, string authToken) return false; } + public List GetResources(string clientIdentifier, string authToken) + { + try + { + // Allows us to tell plex.tv that we're still active and tokens should not be expired. + + var request = BuildRequest(clientIdentifier); + + request.ResourceUrl = "/api/v2/resources"; + request.AddQueryParam("includeHttps", 1); + request.AddQueryParam("clientID", clientIdentifier); + request.AddQueryParam("X-Plex-Token", authToken); + + if (Json.TryDeserialize>(ProcessRequest(request), out var response)) + { + return response; + } + } + catch (Exception e) + { + // Catch all exceptions and log at trace, this information could be interesting in debugging, but expired tokens will be handled elsewhere. + _logger.Trace(e, "Unable to ping plex.tv"); + } + + return new List(); + } + private HttpRequestBuilder BuildRequest(string clientIdentifier) { var requestBuilder = new HttpRequestBuilder("https://plex.tv") diff --git a/src/NzbDrone.Core/Notifications/Plex/PlexTv/PlexTvResource.cs b/src/NzbDrone.Core/Notifications/Plex/PlexTv/PlexTvResource.cs new file mode 100644 index 000000000..36e8e1314 --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Plex/PlexTv/PlexTvResource.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json; +using NzbDrone.Common.Extensions; + +namespace NzbDrone.Core.Notifications.Plex.PlexTv +{ + public class PlexTvResource + { + public string Name { get; set; } + public bool Owned { get; set; } + + public List Connections { get; set; } + + [JsonProperty("provides")] + public string ProvidesRaw { get; set; } + + [JsonIgnore] + public List Provides => ProvidesRaw.Split(",").ToList(); + } + + public class PlexTvResourceConnection + { + public string Uri { get; set; } + public string Protocol { get; set; } + public string Address { get; set; } + public int Port { get; set; } + public bool Local { get; set; } + public string Host => Uri.IsNullOrWhiteSpace() ? Address : new Uri(Uri).Host; + } +} diff --git a/src/NzbDrone.Core/Notifications/Plex/PlexTv/PlexTvService.cs b/src/NzbDrone.Core/Notifications/Plex/PlexTv/PlexTvService.cs index b98d6642e..ab27779d6 100644 --- a/src/NzbDrone.Core/Notifications/Plex/PlexTv/PlexTvService.cs +++ b/src/NzbDrone.Core/Notifications/Plex/PlexTv/PlexTvService.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Net.Http; using NzbDrone.Common.Cache; @@ -14,6 +15,7 @@ public interface IPlexTvService PlexTvSignInUrlResponse GetSignInUrl(string callbackUrl, int pinId, string pinCode); string GetAuthToken(int pinId); void Ping(string authToken); + List GetServers(string authToken); HttpRequest GetWatchlist(string authToken, int pageSize, int pageOffset); } @@ -93,6 +95,16 @@ public void Ping(string authToken) _cache.Get(authToken, () => _proxy.Ping(_configService.PlexClientIdentifier, authToken), TimeSpan.FromHours(24)); } + public List GetServers(string authToken) + { + Ping(authToken); + + var clientIdentifier = _configService.PlexClientIdentifier; + var resources = _proxy.GetResources(clientIdentifier, authToken); + + return resources.Where(r => r.Owned && r.Provides.Contains("server")).ToList(); + } + public HttpRequest GetWatchlist(string authToken, int pageSize, int pageOffset) { Ping(authToken); diff --git a/src/NzbDrone.Core/Notifications/Plex/Server/PlexServer.cs b/src/NzbDrone.Core/Notifications/Plex/Server/PlexServer.cs index e80163b7d..7cf247e55 100644 --- a/src/NzbDrone.Core/Notifications/Plex/Server/PlexServer.cs +++ b/src/NzbDrone.Core/Notifications/Plex/Server/PlexServer.cs @@ -5,6 +5,7 @@ using NLog; using NzbDrone.Common.Cache; using NzbDrone.Common.Extensions; +using NzbDrone.Core.Annotations; using NzbDrone.Core.Exceptions; using NzbDrone.Core.MediaFiles; using NzbDrone.Core.Movies; @@ -188,6 +189,79 @@ public override object RequestAction(string action, IDictionary }; } + if (action == "servers") + { + Settings.Validate().Filter("AuthToken").ThrowOnError(); + + if (Settings.AuthToken.IsNullOrWhiteSpace()) + { + return new { }; + } + + var servers = _plexTvService.GetServers(Settings.AuthToken); + var options = servers.SelectMany(s => + { + var result = new List(); + + // result.Add(new FieldSelectStringOption + // { + // Value = s.Name, + // Name = s.Name, + // IsDisabled = true + // }); + + s.Connections.ForEach(c => + { + var isSecure = c.Protocol == "https"; + var additionalProperties = new Dictionary(); + var hints = new List(); + + additionalProperties.Add("host", c.Host); + additionalProperties.Add("port", c.Port); + additionalProperties.Add("useSsl", isSecure); + hints.Add(c.Local ? "Local" : "Remote"); + + if (isSecure) + { + hints.Add("Secure"); + } + + result.Add(new FieldSelectStringOption + { + Value = c.Uri, + Name = $"{s.Name} ({c.Host})", + Hint = string.Join(", ", hints), + AdditionalProperties = additionalProperties + }); + + if (isSecure) + { + var uri = $"http://{c.Address}:{c.Port}"; + var insecureAdditionalProperties = new Dictionary(); + + insecureAdditionalProperties.Add("host", c.Address); + insecureAdditionalProperties.Add("port", c.Port); + insecureAdditionalProperties.Add("useSsl", false); + + result.Add(new FieldSelectStringOption + { + Value = uri, + Name = $"{s.Name} ({c.Address})", + Hint = c.Local ? "Local" : "Remote", + AdditionalProperties = insecureAdditionalProperties + }); + } + }); + + return result; + }); + + return new + { + options + }; + } + return new { }; } } diff --git a/src/NzbDrone.Core/Notifications/Plex/Server/PlexServerSettings.cs b/src/NzbDrone.Core/Notifications/Plex/Server/PlexServerSettings.cs index 50ba7f757..721d80dce 100644 --- a/src/NzbDrone.Core/Notifications/Plex/Server/PlexServerSettings.cs +++ b/src/NzbDrone.Core/Notifications/Plex/Server/PlexServerSettings.cs @@ -1,4 +1,5 @@ using FluentValidation; +using Newtonsoft.Json; using NzbDrone.Common.Extensions; using NzbDrone.Core.Annotations; using NzbDrone.Core.Validation; @@ -22,40 +23,45 @@ public class PlexServerSettings : NotificationSettingsBase public PlexServerSettings() { + Host = ""; Port = 32400; UpdateLibrary = true; SignIn = "startOAuth"; } - [FieldDefinition(0, Label = "Host")] + [JsonIgnore] + [FieldDefinition(0, Label = "NotificationsPlexSettingsServer", Type = FieldType.Select, SelectOptionsProviderAction = "servers", HelpText = "NotificationsPlexSettingsServerHelpText")] + public string Server { get; set; } + + [FieldDefinition(1, Label = "Host")] public string Host { get; set; } - [FieldDefinition(1, Label = "Port")] + [FieldDefinition(2, Label = "Port")] public int Port { get; set; } - [FieldDefinition(2, Label = "UseSsl", Type = FieldType.Checkbox, HelpText = "NotificationsSettingsUseSslHelpText")] + [FieldDefinition(3, Label = "UseSsl", Type = FieldType.Checkbox, HelpText = "NotificationsSettingsUseSslHelpText")] [FieldToken(TokenField.HelpText, "UseSsl", "serviceName", "Plex")] public bool UseSsl { get; set; } - [FieldDefinition(3, Label = "UrlBase", Type = FieldType.Textbox, Advanced = true, HelpText = "ConnectionSettingsUrlBaseHelpText")] + [FieldDefinition(4, Label = "UrlBase", Type = FieldType.Textbox, Advanced = true, HelpText = "ConnectionSettingsUrlBaseHelpText")] [FieldToken(TokenField.HelpText, "UrlBase", "connectionName", "Plex")] [FieldToken(TokenField.HelpText, "UrlBase", "url", "http://[host]:[port]/[urlBase]/plex")] public string UrlBase { get; set; } - [FieldDefinition(4, Label = "NotificationsPlexSettingsAuthToken", Type = FieldType.Textbox, Privacy = PrivacyLevel.ApiKey, Advanced = true)] + [FieldDefinition(5, Label = "NotificationsPlexSettingsAuthToken", Type = FieldType.Textbox, Privacy = PrivacyLevel.ApiKey, Advanced = true)] public string AuthToken { get; set; } - [FieldDefinition(5, Label = "NotificationsPlexSettingsAuthenticateWithPlexTv", Type = FieldType.OAuth)] + [FieldDefinition(6, Label = "NotificationsPlexSettingsAuthenticateWithPlexTv", Type = FieldType.OAuth)] public string SignIn { get; set; } - [FieldDefinition(6, Label = "NotificationsSettingsUpdateLibrary", Type = FieldType.Checkbox)] + [FieldDefinition(7, Label = "NotificationsSettingsUpdateLibrary", Type = FieldType.Checkbox)] public bool UpdateLibrary { get; set; } - [FieldDefinition(7, Label = "NotificationsSettingsUpdateMapPathsFrom", Type = FieldType.Textbox, Advanced = true, HelpText = "NotificationsSettingsUpdateMapPathsFromHelpText")] + [FieldDefinition(8, Label = "NotificationsSettingsUpdateMapPathsFrom", Type = FieldType.Textbox, Advanced = true, HelpText = "NotificationsSettingsUpdateMapPathsFromHelpText")] [FieldToken(TokenField.HelpText, "NotificationsSettingsUpdateMapPathsFrom", "serviceName", "Plex")] public string MapFrom { get; set; } - [FieldDefinition(8, Label = "NotificationsSettingsUpdateMapPathsTo", Type = FieldType.Textbox, Advanced = true, HelpText = "NotificationsSettingsUpdateMapPathsToHelpText")] + [FieldDefinition(9, Label = "NotificationsSettingsUpdateMapPathsTo", Type = FieldType.Textbox, Advanced = true, HelpText = "NotificationsSettingsUpdateMapPathsToHelpText")] [FieldToken(TokenField.HelpText, "NotificationsSettingsUpdateMapPathsTo", "serviceName", "Plex")] public string MapTo { get; set; }