',\n controller: [\"$scope\", \"url\", function ($scope, url) {\n $scope.url = url;\n }],\n resolve: {\n url: function () {\n return url;\n }\n },\n size: \"md\",\n keyboard: true,\n windowTopClass: 'cover-modal-dialog'\n });\n };\n\n }\n}\n\nangular\n .module('nzbhydraApp')\n .controller('NfoModalInstanceCtrl', NfoModalInstanceCtrl);\n\nfunction NfoModalInstanceCtrl($scope, $uibModalInstance, nfo) {\n\n $scope.nfo = nfo;\n\n $scope.ok = function () {\n $uibModalInstance.close($scope.selected.item);\n };\n\n $scope.cancel = function () {\n $uibModalInstance.dismiss();\n };\n}\n\nangular\n .module('nzbhydraApp')\n .filter('kify', function () {\n return function (number) {\n if (number > 1000) {\n return Math.round(number / 1000) + \"k\";\n }\n return number;\n }\n });\n","angular\r\n .module('nzbhydraApp')\r\n .directive('saveOrSendFile', saveOrSendFile);\r\n\r\nfunction saveOrSendFile() {\r\n controller.$inject = [\"$scope\", \"$http\", \"growl\", \"ConfigService\"];\r\n return {\r\n templateUrl: 'static/html/directives/save-or-send-file.html',\r\n scope: {\r\n searchResultId: \"<\",\r\n isFile: \"<\",\r\n type: \"<\"\r\n },\r\n controller: controller\r\n };\r\n\r\n function controller($scope, $http, growl, ConfigService) {\r\n $scope.cssClass = \"glyphicon-save-file\";\r\n var endpoint;\r\n if ($scope.type === \"TORRENT\") {\r\n $scope.enableButton = !_.isNullOrEmpty(ConfigService.getSafe().downloading.saveTorrentsTo) || ConfigService.getSafe().downloading.sendMagnetLinks;\r\n $scope.tooltip = \"Save torrent to black hole or send magnet link\";\r\n endpoint = \"internalapi/saveOrSendTorrent\";\r\n } else {\r\n $scope.tooltip = \"Save NZB to black hole\";\r\n $scope.enableButton = !_.isNullOrEmpty(ConfigService.getSafe().downloading.saveNzbsTo);\r\n endpoint = \"internalapi/saveNzbToBlackhole\";\r\n }\r\n $scope.add = function () {\r\n $scope.cssClass = \"nzb-spinning\";\r\n $http.put(endpoint, $scope.searchResultId).then(function (response) {\r\n if (response.data.successful) {\r\n $scope.cssClass = \"glyphicon-ok\";\r\n } else {\r\n $scope.cssClass = \"glyphicon-remove\";\r\n growl.error(response.data.message);\r\n }\r\n });\r\n };\r\n }\r\n}\r\n","//Can be used in an ng-repeat directive to call a function when the last element was rendered\n//We use it to mark the end of sorting / filtering so we can stop blocking the UI\n\nonFinishRender.$inject = [\"$timeout\"];\nangular\n .module('nzbhydraApp')\n .directive('onFinishRender', onFinishRender);\n\nfunction onFinishRender($timeout) {\n function linkFunction(scope, element, attr) {\n\n if (scope.$last === true) {\n console.log(\"Render finished\");\n // console.timeEnd(\"Presenting\");\n // console.timeEnd(\"searchall\");\n scope.$emit(\"onFinishRender\")\n }\n }\n\n return {\n link: linkFunction\n }\n}","//Fork of https://github.com/dotansimha/angularjs-dropdown-multiselect to make it compatible with formly\nangular\n .module('nzbhydraApp')\n .directive('multiselectDropdown',\n\n dropdownMultiselectDirective\n );\n\nfunction dropdownMultiselectDirective() {\n return {\n scope: {\n selectedModel: '=',\n options: '=',\n settings: '=?',\n events: '=?'\n },\n transclude: {\n toggleDropdown: '?toggleDropdown'\n },\n templateUrl: 'static/html/directives/multiselect-dropdown.html',\n controller: [\"$scope\", \"$element\", \"$filter\", \"$document\", function dropdownMultiselectController($scope, $element, $filter, $document) {\n var $dropdownTrigger = $element.children()[0];\n\n var settings = {\n showSelectedValues: true,\n showSelectAll: true,\n showDeselectAll: true,\n noSelectedText: 'None selected'\n };\n var events = {\n onToggleItem: angular.noop\n };\n angular.extend(events, $scope.events || []);\n angular.extend(settings, $scope.settings || []);\n angular.extend($scope, {settings: settings, events: events});\n\n $scope.buttonText = \"\";\n if (settings.buttonText) {\n $scope.buttonText = settings.buttonText;\n } else {\n $scope.$watch(\"selectedModel\", function () {\n if (angular.isDefined($scope.selectedModel) && settings.showSelectedValues) {\n if ($scope.selectedModel.length === 0) {\n if ($scope.settings.noSelectedText) {\n $scope.buttonText = $scope.settings.noSelectedText;\n } else {\n $scope.buttonText = \"None selected\";\n }\n } else if ($scope.selectedModel.length === $scope.options.length) {\n $scope.buttonText = \"All selected\";\n } else {\n var selected = [];\n _.each($scope.options, function (x) {\n if ($scope.selectedModel.indexOf(x.id) > -1) {\n selected.push(x.label);\n }\n })\n $scope.buttonText = selected.join(\", \");\n }\n } else {\n if (angular.isUndefined($scope.selectedModel) || ($scope.settings.noSelectedText && $scope.selectedModel.length === 0)) {\n $scope.buttonText = $scope.settings.noSelectedText;\n } else {\n $scope.buttonText = $scope.selectedModel.length + \" / \" + $scope.options.length + \" selected\";\n }\n }\n }, true);\n }\n $scope.open = false;\n\n $scope.toggleDropdown = function () {\n $scope.open = !$scope.open;\n };\n\n $scope.toggleItem = function (option) {\n var index = $scope.selectedModel.indexOf(option.id);\n var oldValue = index > -1;\n if (oldValue) {\n $scope.selectedModel.splice(index, 1);\n } else {\n $scope.selectedModel.push(option.id);\n }\n $scope.events.onToggleItem(option, !oldValue);\n };\n\n $scope.selectAll = function () {\n $scope.selectedModel = _.pluck($scope.options, \"id\");\n };\n\n $scope.deselectAll = function () {\n $scope.selectedModel.splice(0, $scope.selectedModel.length);\n };\n\n //Close when clicked outside\n\n $document.on('click', function (e) {\n function contains(collection, target) {\n var containsTarget = false;\n collection.some(function (object) {\n if (object === target) {\n containsTarget = true;\n return true;\n }\n return false;\n });\n return containsTarget;\n }\n\n if ($scope.open) {\n var target = e.target.parentElement;\n var parentFound = false;\n\n while (angular.isDefined(target) && target !== null && !parentFound) {\n if (!!target.className.split && contains(target.className.split(' '), 'multiselect-parent') && !parentFound) {\n if (target === $dropdownTrigger) {\n parentFound = true;\n }\n }\n target = target.parentElement;\n }\n\n if (!parentFound) {\n $scope.$apply(function () {\n $scope.open = false;\n });\n }\n }\n });\n\n\n }]\n\n }\n}","angular\r\n .module('nzbhydraApp').directive(\"keepFocus\", ['$timeout', function ($timeout) {\r\n /*\r\n Intended use:\r\n \r\n */\r\n return {\r\n restrict: 'A',\r\n require: 'ngModel',\r\n link: function ($scope, $element, attrs, ngModel) {\r\n\r\n ngModel.$parsers.unshift(function (value) {\r\n $timeout(function () {\r\n $element[0].focus();\r\n });\r\n return value;\r\n });\r\n\r\n }\r\n };\r\n}]);","/*\r\n * (C) Copyright 2017 TheOtherP (theotherp@posteo.net)\r\n *\r\n * Licensed under the Apache License, Version 2.0 (the \"License\");\r\n * you may not use this file except in compliance with the License.\r\n * You may obtain a copy of the License at\r\n *\r\n * http://www.apache.org/licenses/LICENSE-2.0\r\n *\r\n * Unless required by applicable law or agreed to in writing, software\r\n * distributed under the License is distributed on an \"AS IS\" BASIS,\r\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\r\n * See the License for the specific language governing permissions and\r\n * limitations under the License.\r\n */\r\n\r\nangular\r\n .module('nzbhydraApp')\r\n .directive('indexerStateSwitch', indexerStateSwitch);\r\n\r\nfunction indexerStateSwitch() {\r\n controller.$inject = [\"$scope\"];\r\n return {\r\n templateUrl: 'static/html/directives/indexer-state-switch.html',\r\n scope: {\r\n indexer: \"=\",\r\n handleWidth: \"@\"\r\n },\r\n replace: true,\r\n controller: controller\r\n };\r\n\r\n function controller($scope) {\r\n $scope.value = $scope.indexer.state === \"ENABLED\";\r\n $scope.handleWidth = $scope.handleWidth || \"130px\";\r\n var initialized = false;\r\n\r\n function calculateTextAndColor() {\r\n if ($scope.indexer.state === \"DISABLED_USER\") {\r\n $scope.offText = \"Disabled by user\";\r\n $scope.offColor = \"default\";\r\n } else if ($scope.indexer.state === \"DISABLED_SYSTEM_TEMPORARY\") {\r\n $scope.offText = \"Temporary disabled\";\r\n $scope.offColor = \"warning\";\r\n } else if ($scope.indexer.state === \"DISABLED_SYSTEM\") {\r\n $scope.offText = \"Disabled by system\";\r\n $scope.offColor = \"danger\";\r\n }\r\n }\r\n\r\n calculateTextAndColor();\r\n\r\n $scope.onChange = function () {\r\n if (initialized) {\r\n //Skip on first call when initial value is set\r\n $scope.indexer.state = $scope.value ? \"ENABLED\" : \"DISABLED_USER\";\r\n calculateTextAndColor();\r\n }\r\n initialized = true;\r\n }\r\n }\r\n}","/*\r\n * (C) Copyright 2017 TheOtherP (theotherp@posteo.net)\r\n *\r\n * Licensed under the Apache License, Version 2.0 (the \"License\");\r\n * you may not use this file except in compliance with the License.\r\n * You may obtain a copy of the License at\r\n *\r\n * http://www.apache.org/licenses/LICENSE-2.0\r\n *\r\n * Unless required by applicable law or agreed to in writing, software\r\n * distributed under the License is distributed on an \"AS IS\" BASIS,\r\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\r\n * See the License for the specific language governing permissions and\r\n * limitations under the License.\r\n */\r\n\r\nangular\r\n .module('nzbhydraApp')\r\n .directive('indexerSelectionButton', indexerSelectionButton);\r\n\r\nfunction indexerSelectionButton() {\r\n controller.$inject = [\"$scope\"];\r\n return {\r\n templateUrl: 'static/html/directives/indexer-selection-button.html',\r\n scope: {\r\n selectedIndexers: \"=\",\r\n availableIndexers: \"=\",\r\n btn: \"@\"\r\n },\r\n controller: controller\r\n };\r\n\r\n function controller($scope) {\r\n\r\n $scope.anyTorrentIndexersSelectable = _.any($scope.availableIndexers,\r\n function (indexer) {\r\n return indexer.searchModuleType === \"TORZNAB\";\r\n }\r\n );\r\n\r\n $scope.invertSelection = function () {\r\n _.forEach($scope.availableIndexers, function (x) {\r\n var index = _.indexOf($scope.selectedIndexers, x.name);\r\n if (index === -1) {\r\n $scope.selectedIndexers.push(x.name);\r\n } else {\r\n $scope.selectedIndexers.splice(index, 1);\r\n }\r\n });\r\n };\r\n\r\n $scope.selectAll = function () {\r\n $scope.deselectAll();\r\n $scope.selectedIndexers.push.apply($scope.selectedIndexers, _.pluck($scope.availableIndexers, \"name\"));\r\n };\r\n\r\n $scope.deselectAll = function () {\r\n $scope.selectedIndexers.splice(0, $scope.selectedIndexers.length);\r\n };\r\n\r\n function selectByPredicate(predicate) {\r\n $scope.deselectAll();\r\n $scope.selectedIndexers.push.apply($scope.selectedIndexers,\r\n _.pluck(\r\n _.filter($scope.availableIndexers,\r\n predicate\r\n ), \"name\")\r\n );\r\n }\r\n\r\n $scope.reset = function () {\r\n selectByPredicate(function (indexer) {\r\n return indexer.preselect;\r\n });\r\n };\r\n\r\n $scope.selectAllUsenet = function () {\r\n selectByPredicate(function (indexer) {\r\n return indexer.searchModuleType !== \"TORZNAB\";\r\n });\r\n };\r\n\r\n $scope.selectAllTorrent = function () {\r\n selectByPredicate(function (indexer) {\r\n return indexer.searchModuleType === \"TORZNAB\";\r\n });\r\n }\r\n }\r\n}\r\n\r\n","angular\r\n .module('nzbhydraApp')\r\n .directive('indexerInput', indexerInput);\r\n\r\nfunction indexerInput() {\r\n controller.$inject = [\"$scope\"];\r\n return {\r\n templateUrl: 'static/html/directives/indexer-input.html',\r\n scope: {\r\n indexer: \"=\",\r\n model: \"=\",\r\n onClick: \"=\"\r\n },\r\n replace: true,\r\n controller: controller\r\n };\r\n\r\n function controller($scope) {\r\n $scope.isFocused = false;\r\n\r\n $scope.onFocus = function () {\r\n $scope.isFocused = true;\r\n };\r\n\r\n $scope.onBlur = function () {\r\n $scope.isFocused = false;\r\n };\r\n\r\n var expiryWarning;\r\n if ($scope.indexer.vipExpirationDate != null && $scope.indexer.vipExpirationDate !== \"Lifetime\") {\r\n var expiryDate = moment($scope.indexer.vipExpirationDate, \"YYYY-MM-DD\");\r\n if (expiryDate < moment()) {\r\n console.log(\"Expiry date reached for indexer \" + $scope.indexer.name);\r\n expiryWarning = \"VIP access expired on \" + $scope.indexer.vipExpirationDate;\r\n } else if (expiryDate.subtract(7, 'days') < moment()) {\r\n console.log(\"Expiry date near for indexer \" + $scope.indexer.name);\r\n expiryWarning = \"VIP access will expire on \" + $scope.indexer.vipExpirationDate;\r\n }\r\n }\r\n\r\n $scope.expiryWarning = expiryWarning;\r\n if ($scope.indexer.color !== null) {\r\n $scope.style = \"background-color: \" + $scope.indexer.color.replace(\"rgb\", \"rgba\").replace(\")\", \",0.5)\")\r\n }\r\n }\r\n\r\n}\r\n\r\n","angular\n .module('nzbhydraApp')\n .directive('hydraupdates', hydraupdates);\n\nfunction hydraupdates() {\n controller.$inject = [\"$scope\", \"UpdateService\"];\n return {\n templateUrl: 'static/html/directives/updates.html',\n controller: controller\n };\n\n function controller($scope, UpdateService) {\n\n $scope.loadingPromise = UpdateService.getInfos().then(function (response) {\n $scope.currentVersion = response.data.currentVersion;\n $scope.latestVersion = response.data.latestVersion;\n $scope.latestVersionIsBeta = response.data.latestVersionIsBeta;\n $scope.betaVersion = response.data.betaVersion;\n $scope.updateAvailable = response.data.updateAvailable;\n $scope.betaUpdateAvailable = response.data.betaUpdateAvailable;\n $scope.latestVersionIgnored = response.data.latestVersionIgnored;\n $scope.changelog = response.data.changelog;\n $scope.updatedExternally = response.data.updatedExternally;\n $scope.wrapperOutdated = response.data.wrapperOutdated;\n $scope.showUpdateBannerOnUpdatedExternally = response.data.showUpdateBannerOnUpdatedExternally;\n if ($scope.updatedExternally && !$scope.showUpdateBannerOnUpdatedExternally) {\n $scope.updateAvailable = false;\n }\n });\n\n UpdateService.getVersionHistory().then(function (response) {\n $scope.versionHistory = response.data;\n });\n\n\n $scope.update = function (version) {\n UpdateService.update(version);\n };\n\n $scope.showChangelog = function (version) {\n UpdateService.showChanges(version);\n };\n\n $scope.forceUpdate = function () {\n UpdateService.update($scope.latestVersion)\n };\n }\n}\n\n","angular\r\n .module('nzbhydraApp')\r\n .directive('hydraNews', hydraNews);\r\n\r\nfunction hydraNews() {\r\n controller.$inject = [\"$scope\", \"$http\"];\r\n return {\r\n templateUrl: \"static/html/directives/news.html\",\r\n controller: controller\r\n };\r\n\r\n function controller($scope, $http) {\r\n\r\n return $http.get(\"internalapi/news\").then(function (response) {\r\n $scope.news = response.data;\r\n });\r\n\r\n\r\n }\r\n}\r\n\r\n","\r\nLogModalInstanceCtrl.$inject = [\"$scope\", \"$uibModalInstance\", \"entry\"];\r\nescapeHtml.$inject = [\"$sanitize\"];angular\r\n .module('nzbhydraApp')\r\n .directive('hydralog', hydralog);\r\n\r\nfunction hydralog() {\r\n controller.$inject = [\"$scope\", \"$http\", \"$interval\", \"$uibModal\", \"$sce\", \"localStorageService\", \"growl\"];\r\n return {\r\n templateUrl: \"static/html/directives/log.html\",\r\n controller: controller\r\n };\r\n\r\n function controller($scope, $http, $interval, $uibModal, $sce, localStorageService, growl) {\r\n $scope.tailInterval = null;\r\n $scope.doUpdateLog = localStorageService.get(\"doUpdateLog\") !== null ? localStorageService.get(\"doUpdateLog\") : false;\r\n $scope.doTailLog = localStorageService.get(\"doTailLog\") !== null ? localStorageService.get(\"doTailLog\") : false;\r\n\r\n $scope.active = 0;\r\n $scope.currentJsonIndex = 0;\r\n $scope.hasMoreJsonLines = true;\r\n\r\n function getLog(index) {\r\n if ($scope.active === 0) {\r\n return $http.get(\"internalapi/debuginfos/jsonlogs\", {\r\n params: {\r\n offset: index,\r\n limit: 500\r\n }\r\n }).then(function (response) {\r\n var data = response.data;\r\n $scope.jsonLogLines = angular.fromJson(data.lines);\r\n $scope.hasMoreJsonLines = data.hasMore;\r\n });\r\n } else if ($scope.active === 1) {\r\n return $http.get(\"internalapi/debuginfos/currentlogfile\").then(function (response) {\r\n var data = response.data;\r\n $scope.log = $sce.trustAsHtml(data.replace(/&/g, \"&\")\r\n .replace(//g, \">\")\r\n .replace(/\"/g, \""\")\r\n .replace(/'/g, \"'\"));\r\n }, function (data) {\r\n growl.error(data)\r\n });\r\n } else if ($scope.active === 2) {\r\n return $http.get(\"internalapi/debuginfos/logfilenames\").then(function (response) {\r\n $scope.logfilenames = response.data;\r\n });\r\n }\r\n }\r\n\r\n $scope.logPromise = getLog();\r\n\r\n $scope.select = function (index) {\r\n $scope.active = index;\r\n $scope.update();\r\n };\r\n\r\n $scope.scrollToBottom = function () {\r\n document.getElementById(\"logfile\").scrollTop = 10000000;\r\n document.getElementById(\"logfile\").scrollTop = 100001000;\r\n };\r\n\r\n $scope.update = function () {\r\n getLog($scope.currentJsonIndex);\r\n if ($scope.active === 1) {\r\n $scope.scrollToBottom();\r\n }\r\n };\r\n\r\n $scope.getOlderFormatted = function () {\r\n getLog($scope.currentJsonIndex + 500).then(function () {\r\n $scope.currentJsonIndex += 500;\r\n });\r\n\r\n };\r\n\r\n $scope.getNewerFormatted = function () {\r\n var index = Math.max($scope.currentJsonIndex - 500, 0);\r\n getLog(index);\r\n $scope.currentJsonIndex = index;\r\n };\r\n\r\n function startUpdateLogInterval() {\r\n $scope.tailInterval = $interval(function () {\r\n if ($scope.active === 1) {\r\n $scope.update();\r\n if ($scope.doTailLog && $scope.active === 1) {\r\n $scope.scrollToBottom();\r\n }\r\n }\r\n }, 5000);\r\n }\r\n\r\n $scope.toggleUpdate = function (doUpdateLog) {\r\n $scope.doUpdateLog = doUpdateLog;\r\n if ($scope.doUpdateLog) {\r\n startUpdateLogInterval();\r\n } else if ($scope.tailInterval !== null) {\r\n console.log(\"Cancelling\");\r\n $interval.cancel($scope.tailInterval);\r\n localStorageService.set(\"doTailLog\", false);\r\n $scope.doTailLog = false;\r\n }\r\n localStorageService.set(\"doUpdateLog\", $scope.doUpdateLog);\r\n };\r\n\r\n $scope.toggleTailLog = function () {\r\n localStorageService.set(\"doTailLog\", $scope.doTailLog);\r\n };\r\n\r\n $scope.openModal = function openModal(entry) {\r\n var modalInstance = $uibModal.open({\r\n templateUrl: 'log-entry.html',\r\n controller: LogModalInstanceCtrl,\r\n size: \"xl\",\r\n resolve: {\r\n entry: function () {\r\n return entry;\r\n }\r\n }\r\n });\r\n\r\n modalInstance.result.then();\r\n };\r\n\r\n $scope.$on('$destroy', function () {\r\n if ($scope.tailInterval !== null) {\r\n $interval.cancel($scope.tailInterval);\r\n }\r\n });\r\n\r\n if ($scope.doUpdateLog) {\r\n startUpdateLogInterval();\r\n }\r\n\r\n\r\n }\r\n}\r\n\r\nangular\r\n .module('nzbhydraApp')\r\n .controller('LogModalInstanceCtrl', LogModalInstanceCtrl);\r\n\r\nfunction LogModalInstanceCtrl($scope, $uibModalInstance, entry) {\r\n\r\n $scope.entry = entry;\r\n\r\n $scope.ok = function () {\r\n $uibModalInstance.dismiss();\r\n };\r\n}\r\n\r\nangular\r\n .module('nzbhydraApp')\r\n .filter('formatTimestamp', formatTimestamp);\r\n\r\nfunction formatTimestamp() {\r\n return function (date) {\r\n //1579392000\r\n //1579374757\r\n if (date === null || date === undefined) {\r\n return null;\r\n }\r\n if (date < 1979374757) {\r\n date *= 1000;\r\n }\r\n return moment(date).local().format(\"YYYY-MM-DD HH:mm\");\r\n }\r\n}\r\n\r\nangular\r\n .module('nzbhydraApp')\r\n .filter('escapeHtml', escapeHtml);\r\n\r\nfunction escapeHtml($sanitize) {\r\n return function (text) {\r\n return $sanitize(text);\r\n }\r\n}\r\n\r\nangular\r\n .module('nzbhydraApp')\r\n .filter('formatClassname', formatClassname);\r\n\r\nfunction formatClassname() {\r\n return function (fqn) {\r\n return fqn.substr(fqn.lastIndexOf(\".\") + 1);\r\n\r\n }\r\n}","/*\n * (C) Copyright 2017 TheOtherP (theotherp@posteo.net)\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nNewsModalInstanceCtrl.$inject = [\"$scope\", \"$uibModalInstance\", \"news\"];\nWelcomeModalInstanceCtrl.$inject = [\"$scope\", \"$uibModalInstance\", \"$state\", \"MigrationService\"];\nangular\n .module('nzbhydraApp')\n .directive('hydraChecksFooter', hydraChecksFooter);\n\nfunction hydraChecksFooter() {\n controller.$inject = [\"$scope\", \"UpdateService\", \"RequestsErrorHandler\", \"HydraAuthService\", \"$http\", \"$uibModal\", \"ConfigService\", \"GenericStorageService\", \"ModalService\", \"growl\", \"NotificationService\", \"bootstrapped\"];\n return {\n templateUrl: 'static/html/directives/checks-footer.html',\n controller: controller\n };\n\n function controller($scope, UpdateService, RequestsErrorHandler, HydraAuthService, $http, $uibModal, ConfigService, GenericStorageService, ModalService, growl, NotificationService, bootstrapped) {\n $scope.updateAvailable = false;\n $scope.checked = false;\n var welcomeIsBeingShown = false;\n\n $scope.mayUpdate = HydraAuthService.getUserInfos().maySeeAdmin;\n\n $scope.$on(\"user:loggedIn\", function () {\n if (HydraAuthService.getUserInfos().maySeeAdmin && !$scope.checked) {\n retrieveUpdateInfos();\n }\n });\n\n function checkForOutOfMemoryException() {\n GenericStorageService.get(\"outOfMemoryDetected\", false).then(function (response) {\n if (response.data !== \"\" && response.data) {\n //headline, message, params, size, textAlign\n ModalService.open(\"Out of memory error detected\", 'The log indicates that the process ran out of memory. Please increase the XMX value in the main config and restart.', {\n yes: {\n text: \"OK\"\n }\n }, undefined, \"left\");\n GenericStorageService.put(\"outOfMemoryDetected\", false, false);\n }\n });\n }\n\n function checkForOpenToInternet() {\n GenericStorageService.get(\"showOpenToInternetWithoutAuth\", false).then(function (response) {\n if (response.data !== \"\" && response.data) {\n //headline, message, params, size, textAlign\n ModalService.open(\"Security issue - open to internet\", 'It looks like NZBHydra is exposed to the internet without any authentication enable. Please make sure it cannot be reached from outside your network or enable an authentication method.', {\n yes: {\n text: \"OK\"\n }\n }, undefined, \"left\");\n GenericStorageService.put(\"showOpenToInternetWithoutAuth\", false, false);\n }\n });\n }\n\n console.log(\"Checking for below Java 17.\");\n\n function checkForJavaBelow17() {\n GenericStorageService.get(\"belowJava17\", false).then(function (response) {\n if (response.data !== \"\" && response.data) {\n console.log(\"Java below 17\");\n //headline, message, params, size, textAlign\n ModalService.open(\"Java version below 17\", 'You\\'re currently running NZBHydra2 with an older java version. A future update will require Java 17. Please install Java 17 (not higher) from here.', {\n yes: {\n text: \"OK\"\n }\n }, undefined, \"left\");\n GenericStorageService.put(\"belowJava17\", false, false);\n }\n });\n }\n\n console.log(\"Checking for failed backup.\");\n\n function checkForFailedBackup() {\n GenericStorageService.get(\"FAILED_BACKUP\", false).then(function (response) {\n if (response.data !== \"\" && response.data && !response.data) {\n console.log(\"Failed backup detected\");\n //headline, message, params, size, textAlign\n ModalService.open(\"Failed backup\", 'The creation of a backup file has failed. Error message: \\\"' + response.data.message + '.\" For details please check the log around ' + response.data.time + '.', {\n yes: {\n text: \"OK\"\n }\n }, undefined, \"left\");\n GenericStorageService.put(\"FAILED_BACKUP\", false, null);\n }\n });\n }\n\n function checkForOutdatedWrapper() {\n $http.get(\"internalapi/updates/isDisplayWrapperOutdated\").then(function (response) {\n var data = response.data;\n if (data !== undefined && data !== null && data) {\n ModalService.open(\"Outdated wrappers detected\", 'The NZBHydra wrappers (i.e. the executables or python scripts you use to run NZBHydra) seem to be outdated. Please update them.
\\n' +\n ' Shut down NZBHydra, download the latest version and extract all the relevant wrapper files into your main NZBHydra folder. \\n' +\n ' For Windows these files are:\\n' +\n '
\\n' +\n '
NZBHydra2.exe
\\n' +\n '
NZBHydra2 Console.exe
\\n' +\n '
\\n' +\n ' For linux these files are:\\n' +\n '
\\n' +\n '
nzbhydra2
\\n' +\n '
nzbhydra2wrapper.py
\\n' +\n '
nzbhydra2wrapperPy3.py
\\n' +\n '
\\n' +\n ' Make sure to overwrite all of these files that already exist - you don\\'t need to update any files that aren\\'t already present.\\n' +\n '
\\n' +\n ' Afterwards start NZBHydra again.', {\n yes: {\n text: \"OK\",\n onYes: function () {\n $http.put(\"internalapi/updates/setOutdatedWrapperDetectedWarningShown\")\n }\n }\n }, undefined, \"left\");\n\n }\n });\n }\n\n if ($scope.mayUpdate) {\n retrieveUpdateInfos();\n checkForOutOfMemoryException();\n checkForOutdatedWrapper();\n checkForOpenToInternet();\n checkForJavaBelow17();\n checkForFailedBackup();\n }\n\n function retrieveUpdateInfos() {\n $scope.checked = true;\n UpdateService.getInfos().then(function (response) {\n if (response) {\n $scope.currentVersion = response.data.currentVersion;\n $scope.latestVersion = response.data.latestVersion;\n $scope.latestVersionIsBeta = response.data.latestVersionIsBeta;\n $scope.updateAvailable = response.data.updateAvailable;\n $scope.changelog = response.data.changelog;\n $scope.updatedExternally = response.data.updatedExternally;\n $scope.showUpdateBannerOnUpdatedExternally = response.data.showUpdateBannerOnUpdatedExternally;\n $scope.showWhatsNewBanner = response.data.showWhatsNewBanner;\n if ($scope.updatedExternally && !$scope.showUpdateBannerOnUpdatedExternally) {\n $scope.updateAvailable = false;\n }\n $scope.automaticUpdateToNotice = response.data.automaticUpdateToNotice;\n\n\n $scope.$emit(\"showUpdateFooter\", $scope.updateAvailable);\n $scope.$emit(\"showAutomaticUpdateFooter\", $scope.automaticUpdateToNotice);\n } else {\n $scope.$emit(\"showUpdateFooter\", false);\n }\n });\n }\n\n $scope.update = function () {\n UpdateService.update($scope.latestVersion);\n };\n\n $scope.ignore = function () {\n UpdateService.ignore($scope.latestVersion);\n $scope.updateAvailable = false;\n $scope.$emit(\"showUpdateFooter\", $scope.updateAvailable);\n };\n\n $scope.showChangelog = function () {\n UpdateService.showChanges($scope.latestVersion);\n };\n\n $scope.showChangesFromAutomaticUpdate = function () {\n UpdateService.showChangesFromAutomaticUpdate();\n $scope.automaticUpdateToNotice = null;\n $scope.$emit(\"showAutomaticUpdateFooter\", false);\n };\n\n $scope.dismissChangesFromAutomaticUpdate = function () {\n $scope.automaticUpdateToNotice = null;\n $scope.$emit(\"showAutomaticUpdateFooter\", false);\n console.log(\"Dismissing showAutomaticUpdateFooter\");\n return $http.get(\"internalapi/updates/ackAutomaticUpdateVersionHistory\").then(function (response) {\n });\n };\n\n function checkAndShowNews() {\n RequestsErrorHandler.specificallyHandled(function () {\n if (ConfigService.getSafe().showNews) {\n $http.get(\"internalapi/news/forcurrentversion\").then(function (response) {\n var data = response.data;\n if (data && data.length > 0) {\n $uibModal.open({\n templateUrl: 'static/html/news-modal.html',\n controller: NewsModalInstanceCtrl,\n size: \"lg\",\n resolve: {\n news: function () {\n return data;\n }\n }\n });\n $http.put(\"internalapi/news/saveshown\");\n }\n });\n }\n });\n }\n\n function checkExpiredIndexers() {\n _.each(ConfigService.getSafe().indexers, function (indexer) {\n if (indexer.vipExpirationDate != null && indexer.vipExpirationDate !== \"Lifetime\") {\n var expiryWarning;\n var expiryDate = moment(indexer.vipExpirationDate, \"YYYY-MM-DD\");\n var messagePrefix = \"VIP access for indexer \" + indexer.name;\n if (expiryDate < moment()) {\n expiryWarning = messagePrefix + \" expired on \" + indexer.vipExpirationDate;\n } else if (expiryDate.subtract(7, 'days') < moment()) {\n expiryWarning = messagePrefix + \" will expire on \" + indexer.vipExpirationDate;\n }\n if (expiryWarning) {\n console.log(expiryWarning);\n growl.warning(expiryWarning);\n }\n }\n });\n }\n\n function checkAndShowWelcome() {\n RequestsErrorHandler.specificallyHandled(function () {\n $http.get(\"internalapi/welcomeshown\").then(function (response) {\n if (!response.data) {\n $http.put(\"internalapi/welcomeshown\");\n var promise = $uibModal.open({\n templateUrl: 'static/html/welcome-modal.html',\n controller: WelcomeModalInstanceCtrl,\n size: \"md\"\n });\n promise.opened.then(function () {\n welcomeIsBeingShown = true;\n });\n promise.closed.then(function () {\n welcomeIsBeingShown = false;\n });\n } else {\n if (HydraAuthService.getUserInfos().maySeeAdmin) {\n _.defer(checkAndShowNews);\n _.defer(checkExpiredIndexers);\n }\n }\n }, function () {\n console.log(\"Error while checking for welcome\")\n });\n });\n }\n\n checkAndShowWelcome();\n\n function showUnreadNotifications(unreadNotifications, stompClient) {\n if (unreadNotifications.length > ConfigService.getSafe().notificationConfig.displayNotificationsMax) {\n growl.info(unreadNotifications.length + ' notifications have piled up. Go to the notification history to view them.', {disableCountDown: true});\n for (var i = 0; i < unreadNotifications.length; i++) {\n if (unreadNotifications[i].id === undefined) {\n console.log(\"Undefined ID found for notification \" + unreadNotifications[i]);\n continue;\n }\n stompClient.send(\"/app/markNotificationRead\", {}, unreadNotifications[i].id);\n }\n return;\n }\n for (var j = 0; j < unreadNotifications.length; j++) {\n var notification = unreadNotifications[j];\n var body = notification.body.replace(\"\\n\", \" \");\n switch (notification.messageType) {\n case \"INFO\":\n growl.info(body);\n break;\n case \"SUCCESS\":\n growl.success(body);\n break;\n case \"WARNING\":\n growl.warning(body);\n break;\n case \"FAILURE\":\n growl.danger(body);\n break;\n }\n if (notification.id === undefined) {\n console.log(\"Undefined ID found for notification \" + unreadNotifications[i]);\n continue;\n }\n stompClient.send(\"/app/markNotificationRead\", {}, notification.id);\n }\n }\n\n if (ConfigService.getSafe().notificationConfig.displayNotifications && HydraAuthService.getUserInfos().maySeeAdmin) {\n var socket = new SockJS(bootstrapped.baseUrl + 'websocket');\n var stompClient = Stomp.over(socket);\n stompClient.debug = null;\n stompClient.connect({}, function (frame) {\n stompClient.subscribe('/topic/notifications', function (message) {\n showUnreadNotifications(JSON.parse(message.body), stompClient);\n });\n });\n }\n\n }\n}\n\nangular\n .module('nzbhydraApp')\n .controller('NewsModalInstanceCtrl', NewsModalInstanceCtrl);\n\nfunction NewsModalInstanceCtrl($scope, $uibModalInstance, news) {\n $scope.news = news;\n $scope.close = function () {\n $uibModalInstance.dismiss();\n };\n}\n\nangular\n .module('nzbhydraApp')\n .controller('WelcomeModalInstanceCtrl', WelcomeModalInstanceCtrl);\n\nfunction WelcomeModalInstanceCtrl($scope, $uibModalInstance, $state, MigrationService) {\n $scope.close = function () {\n $uibModalInstance.dismiss();\n };\n\n $scope.startMigration = function () {\n $uibModalInstance.dismiss();\n MigrationService.migrate();\n };\n\n $scope.goToConfig = function () {\n $uibModalInstance.dismiss();\n $state.go(\"root.config.main\");\n }\n}\n","/*\n * (C) Copyright 2017 TheOtherP (theotherp@posteo.net)\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nangular\n .module('nzbhydraApp')\n .directive('footer', footer);\n\nfunction footer() {\n controller.$inject = [\"$scope\", \"$http\", \"$uibModal\", \"ConfigService\", \"GenericStorageService\", \"bootstrapped\"];\n return {\n templateUrl: 'static/html/directives/footer.html',\n controller: controller\n };\n\n function controller($scope, $http, $uibModal, ConfigService, GenericStorageService, bootstrapped) {\n $scope.updateFooterBottom = 0;\n\n var safeConfig = bootstrapped.safeConfig;\n $scope.showDownloaderStatus = safeConfig.downloading.showDownloaderStatus && _.filter(safeConfig.downloading.downloaders, function (x) {\n return x.enabled\n }).length > 0;\n $scope.showUpdateFooter = false;\n\n $scope.$on(\"showDownloaderStatus\", function (event, doShow) {\n $scope.showDownloaderStatus = doShow;\n updateFooterBottom();\n updatePaddingBottom();\n });\n $scope.$on(\"showUpdateFooter\", function (event, doShow) {\n $scope.showUpdateFooter = doShow;\n updateFooterBottom();\n updatePaddingBottom();\n });\n $scope.$on(\"showAutomaticUpdateFooter\", function (event, doShow) {\n $scope.showAutomaticUpdateFooter = doShow;\n updateFooterBottom();\n updatePaddingBottom();\n });\n\n function updateFooterBottom() {\n\n if ($scope.showDownloaderStatus) {\n if ($scope.showAutomaticUpdateFooter) {\n $scope.updateFooterBottom = 20;\n } else {\n $scope.updateFooterBottom = 38;\n }\n } else {\n $scope.updateFooterBottom = 0;\n }\n }\n\n function updatePaddingBottom() {\n var paddingBottom = 0;\n if ($scope.showDownloaderStatus) {\n paddingBottom += 30;\n }\n if ($scope.showUpdateFooter) {\n paddingBottom += 40;\n }\n $scope.paddingBottom = paddingBottom;\n document.getElementById(\"wrap\").classList.remove(\"padding-bottom-0\");\n document.getElementById(\"wrap\").classList.remove(\"padding-bottom-30\");\n document.getElementById(\"wrap\").classList.remove(\"padding-bottom-40\");\n document.getElementById(\"wrap\").classList.remove(\"padding-bottom-70\");\n var paddingBottomClass = \"padding-bottom-\" + paddingBottom;\n document.getElementById(\"wrap\").classList.add(paddingBottomClass);\n }\n\n updatePaddingBottom();\n\n updateFooterBottom();\n\n\n }\n}\n\n","angular\r\n .module('nzbhydraApp').directive('focusOn', focusOn);\r\n\r\nfunction focusOn() {\r\n return directive;\r\n\r\n function directive(scope, elem, attr) {\r\n scope.$on('focusOn', function (e, name) {\r\n if (name === attr.focusOn) {\r\n elem[0].focus();\r\n }\r\n });\r\n }\r\n}\r\n","/*\n * (C) Copyright 2017 TheOtherP (theotherp@posteo.net)\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nangular\n .module('nzbhydraApp')\n .directive('downloaderStatusFooter', downloaderStatusFooter);\n\nfunction downloaderStatusFooter() {\n controller.$inject = [\"$scope\", \"$http\", \"RequestsErrorHandler\", \"HydraAuthService\", \"$interval\", \"bootstrapped\"];\n return {\n templateUrl: 'static/html/directives/downloader-status-footer.html',\n controller: controller\n };\n\n function controller($scope, $http, RequestsErrorHandler, HydraAuthService, $interval, bootstrapped) {\n\n var downloaderStatus;\n var updateInterval = null;\n console.log(\"websocket\");\n var socket = new SockJS(bootstrapped.baseUrl + 'websocket');\n var stompClient = Stomp.over(socket);\n stompClient.debug = null;\n stompClient.connect({}, function (frame) {\n stompClient.subscribe('/topic/downloaderStatus', function (message) {\n downloaderStatus = JSON.parse(message.body);\n updateFooter(downloaderStatus);\n });\n stompClient.send(\"/app/connectDownloaderStatus\", function (message) {\n downloaderStatus = JSON.parse(message.body);\n updateFooter(downloaderStatus);\n })\n });\n\n\n $scope.$emit(\"showDownloaderStatus\", true);\n var downloadRateCounter = 0;\n\n $scope.downloaderChart = {\n options: {\n chart: {\n type: 'stackedAreaChart',\n height: 35,\n width: 300,\n margin: {\n top: 5,\n right: 0,\n bottom: 0,\n left: 0\n },\n x: function (d) {\n return d.x;\n },\n y: function (d) {\n return d.y;\n },\n interactive: true,\n useInteractiveGuideline: false,\n transitionDuration: 0,\n showControls: false,\n showLegend: false,\n showValues: false,\n duration: 0,\n tooltip: {\n valueFormatter: function (d, i) {\n return d + \" kb/s\";\n },\n keyFormatter: function () {\n return \"\";\n },\n id: \"downloader-status-tooltip\"\n },\n css: \"float:right;\"\n }\n },\n data: [{values: [], key: \"Bla\", color: '#00a950'}],\n config: {\n refreshDataOnly: true,\n deepWatchDataDepth: 0,\n deepWatchData: false,\n deepWatchOptions: false\n }\n };\n\n function updateFooter() {\n if (downloaderStatus.lastUpdateForNow && updateInterval === null) {\n //Server will send no new status updates for a while because the last two retrieved statuses are the same.\n //We must still update the footer so that the graph doesn't stand still\n console.debug(\"Retrieved last update for now, starting update interval\");\n updateInterval = $interval(function () {\n //Just put the last known rate at the end to keep it going\n $scope.downloaderChart.data[0].values.splice(0, 1);\n $scope.downloaderChart.data[0].values.push({x: downloadRateCounter++, y: downloaderStatus.lastDownloadRate});\n try {\n $scope.api.update();\n } catch (ignored) {\n }\n if (_.every($scope.downloaderChart.data[0].values, function (value) {\n return value === downloaderStatus.lastDownloadRate\n })) {\n //The bar has been filled with the latest known value, we can now stop until we get a new update\n console.debug(\"Filled the bar with last known value, stopping update interval\");\n $interval.cancel(updateInterval);\n updateInterval = null;\n }\n }, 1000);\n } else if (updateInterval !== null && !downloaderStatus.lastUpdateForNow) {\n //New data is incoming, cancel interval\n console.debug(\"Got new update, stopping update interval\")\n $interval.cancel(updateInterval);\n updateInterval = null;\n }\n\n $scope.foo = downloaderStatus;\n $scope.foo.downloaderImage = downloaderStatus.downloaderType === 'NZBGET' ? 'nzbgetlogo' : 'sabnzbdlogo';\n $scope.foo.url = downloaderStatus.url;\n //We need to splice the variable with the rates because it's watched by angular and when overwriting it we would lose the watch and it wouldn't be updated\n var maxEntriesHistory = 200;\n if ($scope.downloaderChart.data[0].values.length < maxEntriesHistory) {\n //Not yet full, just fill up\n console.debug(\"Adding data, filling bar with initial values\")\n for (var i = $scope.downloaderChart.data[0].values.length; i < maxEntriesHistory; i++) {\n if (i >= downloaderStatus.downloadingRatesInKilobytes.length) {\n break;\n }\n $scope.downloaderChart.data[0].values.push({x: downloadRateCounter++, y: downloaderStatus.downloadingRatesInKilobytes[i]});\n }\n } else {\n console.debug(\"Adding data, moving bar\")\n //Remove first one, add to the end\n $scope.downloaderChart.data[0].values.splice(0, 1);\n $scope.downloaderChart.data[0].values.push({x: downloadRateCounter++, y: downloaderStatus.lastDownloadRate});\n }\n try {\n $scope.api.update();\n } catch (ignored) {\n }\n if ($scope.foo.state === \"DOWNLOADING\") {\n $scope.foo.buttonClass = \"play\";\n } else if ($scope.foo.state === \"PAUSED\") {\n $scope.foo.buttonClass = \"pause\";\n } else if ($scope.foo.state === \"OFFLINE\") {\n $scope.foo.buttonClass = \"off\";\n } else {\n $scope.foo.buttonClass = \"time\";\n }\n $scope.foo.state = $scope.foo.state.substr(0, 1) + $scope.foo.state.substr(1).toLowerCase();\n //Bad but without the state isn't updated\n $scope.$apply();\n }\n\n }\n}\n\n","angular\r\n .module('nzbhydraApp')\r\n .directive('downloadNzbzipButton', downloadNzbzipButton);\r\n\r\nfunction downloadNzbzipButton() {\r\n controller.$inject = [\"$scope\", \"growl\", \"$http\", \"FileDownloadService\"];\r\n return {\r\n templateUrl: 'static/html/directives/download-nzbzip-button.html',\r\n require: ['^searchResults'],\r\n scope: {\r\n searchResults: \"<\",\r\n searchTitle: \"<\",\r\n callback: \"&\"\r\n },\r\n controller: controller\r\n };\r\n\r\n\r\n function controller($scope, growl, $http, FileDownloadService) {\r\n $scope.download = function () {\r\n if (angular.isUndefined($scope.searchResults) || $scope.searchResults.length === 0) {\r\n growl.info(\"You should select at least one result...\");\r\n } else {\r\n var values = _.map($scope.searchResults, function (value) {\r\n return value.searchResultId;\r\n });\r\n var link = \"internalapi/nzbzip\";\r\n\r\n var searchTitle;\r\n if (angular.isDefined($scope.searchTitle)) {\r\n searchTitle = \" for \" + $scope.searchTitle.replace(\"[^a-zA-Z0-9.-]\", \"_\");\r\n } else {\r\n searchTitle = \"\";\r\n }\r\n var filename = \"NZBHydra NZBs\" + searchTitle + \".zip\";\r\n $http({method: \"post\", url: link, data: values}).then(function (response) {\r\n if (response.data.successful && response.data.zip !== null) {\r\n link = \"internalapi/nzbzipDownload\";\r\n FileDownloadService.downloadFile(link, filename, \"POST\", response.data.zipFilepath);\r\n if (angular.isDefined($scope.callback)) {\r\n $scope.callback({result: response.data.addedIds});\r\n }\r\n if (response.data.missedIds.length > 0) {\r\n growl.error(\"Unable to add \" + response.missedIds.length + \" out of \" + values.length + \" NZBs to ZIP\");\r\n }\r\n } else {\r\n growl.error(response.data.message);\r\n }\r\n }, function (data, status, headers, config) {\r\n growl.error(status);\r\n });\r\n }\r\n }\r\n }\r\n}\r\n\r\n","angular\r\n .module('nzbhydraApp')\r\n .directive('downloadNzbsButton', downloadNzbsButton);\r\n\r\nfunction downloadNzbsButton() {\r\n controller.$inject = [\"$scope\", \"$http\", \"NzbDownloadService\", \"ConfigService\", \"growl\"];\r\n return {\r\n templateUrl: 'static/html/directives/download-nzbs-button.html',\r\n require: ['^searchResults'],\r\n scope: {\r\n searchResults: \"<\",\r\n callback: \"&\"\r\n },\r\n controller: controller\r\n };\r\n\r\n function controller($scope, $http, NzbDownloadService, ConfigService, growl) {\r\n\r\n $scope.downloaders = NzbDownloadService.getEnabledDownloaders();\r\n $scope.blackholeEnabled = ConfigService.getSafe().downloading.saveTorrentsTo !== null;\r\n\r\n $scope.download = function (downloader) {\r\n if (angular.isUndefined($scope.searchResults) || $scope.searchResults.length === 0) {\r\n growl.info(\"You should select at least one result...\");\r\n } else {\r\n\r\n var didFilterOutResults = false;\r\n var didKeepAnyResults = false;\r\n var searchResults = _.filter($scope.searchResults, function (value) {\r\n if (value.downloadType === \"NZB\") {\r\n didKeepAnyResults = true;\r\n return true;\r\n } else {\r\n console.log(\"Not sending torrent result to downloader\");\r\n didFilterOutResults = true;\r\n return false;\r\n }\r\n });\r\n if (didFilterOutResults && !didKeepAnyResults) {\r\n growl.info(\"None of the selected results were NZBs. Adding aborted\");\r\n if (angular.isDefined($scope.callback)) {\r\n $scope.callback({result: []});\r\n }\r\n return;\r\n } else if (didFilterOutResults && didKeepAnyResults) {\r\n growl.info(\"Some the selected results are torrent results which were skipped\");\r\n }\r\n\r\n var tos = _.map(searchResults, function (entry) {\r\n return {searchResultId: entry.searchResultId, originalCategory: entry.originalCategory}\r\n });\r\n\r\n NzbDownloadService.download(downloader, tos).then(function (response) {\r\n if (angular.isDefined(response.data)) {\r\n if (response !== \"dismissed\") {\r\n if (response.data.successful) {\r\n if (response.data.message == null) {\r\n growl.info(\"Successfully added all NZBs\");\r\n } else {\r\n growl.warning(response.data.message);\r\n }\r\n } else {\r\n growl.error(response.data.message);\r\n }\r\n } else {\r\n growl.error(\"Error while adding NZBs\");\r\n }\r\n if (angular.isDefined($scope.callback)) {\r\n $scope.callback({result: response.data.addedIds});\r\n }\r\n }\r\n }, function () {\r\n growl.error(\"Error while adding NZBs\");\r\n });\r\n }\r\n };\r\n\r\n $scope.sendToBlackhole = function () {\r\n var didFilterOutResults = false;\r\n var didKeepAnyResults = false;\r\n var searchResults = _.filter($scope.searchResults, function (value) {\r\n if (value.downloadType === \"TORRENT\") {\r\n didKeepAnyResults = true;\r\n return true;\r\n } else {\r\n console.log(\"Not sending NZB result to black hole\");\r\n didFilterOutResults = true;\r\n return false;\r\n }\r\n });\r\n if (didFilterOutResults && !didKeepAnyResults) {\r\n growl.info(\"None of the selected results were torrents. Adding aborted\");\r\n if (angular.isDefined($scope.callback)) {\r\n $scope.callback({result: []});\r\n }\r\n return;\r\n } else if (didFilterOutResults && didKeepAnyResults) {\r\n growl.info(\"Some the selected results are NZB results which were skipped\");\r\n }\r\n var searchResultIds = _.pluck(searchResults, \"searchResultId\");\r\n $http.put(\"internalapi/saveTorrent\", searchResultIds).then(function (response) {\r\n if (response.data.successful) {\r\n growl.info(\"Successfully saved all torrents\");\r\n } else {\r\n growl.error(response.data.message);\r\n }\r\n if (angular.isDefined($scope.callback)) {\r\n $scope.callback({result: response.data.addedIds});\r\n }\r\n });\r\n }\r\n\r\n }\r\n}\r\n\r\n","\r\nfreetextFilter.$inject = [\"DebugService\"];\r\nbooleanFilter.$inject = [\"DebugService\"];angular\r\n .module('nzbhydraApp').directive(\"columnFilterWrapper\", columnFilterWrapper);\r\n\r\nfunction columnFilterWrapper() {\r\n controller.$inject = [\"$scope\", \"DebugService\"];\r\n return {\r\n restrict: \"E\",\r\n templateUrl: 'static/html/dataTable/columnFilterOuter.html',\r\n transclude: true,\r\n controllerAs: 'columnFilterWrapperCtrl',\r\n scope: {\r\n inline: \"@\"\r\n },\r\n bindToController: true,\r\n controller: controller,\r\n link: function (scope, element, attr, ctrl) {\r\n scope.element = element;\r\n }\r\n };\r\n\r\n function controller($scope, DebugService) {\r\n var vm = this;\r\n\r\n vm.open = false;\r\n vm.isActive = false;\r\n\r\n vm.toggle = function () {\r\n vm.open = !vm.open;\r\n if (vm.open) {\r\n $scope.$broadcast(\"opened\");\r\n }\r\n };\r\n\r\n vm.clear = function () {\r\n if (vm.open) {\r\n $scope.$broadcast(\"clear\");\r\n }\r\n };\r\n\r\n $scope.$on(\"filter\", function (event, column, filterModel, isActive, open) {\r\n vm.open = open || false;\r\n vm.isActive = isActive;\r\n });\r\n\r\n DebugService.log(\"filter-wrapper\");\r\n }\r\n\r\n}\r\n\r\n\r\nangular\r\n .module('nzbhydraApp').directive(\"freetextFilter\", freetextFilter);\r\n\r\nfunction freetextFilter(DebugService) {\r\n controller.$inject = [\"$scope\", \"focus\"];\r\n return {\r\n template: '',\r\n require: \"^columnFilterWrapper\",\r\n controllerAs: 'innerController',\r\n scope: {\r\n column: \"@\",\r\n onKey: \"@\",\r\n placeholder: \"@\",\r\n tooltip: \"@\"\r\n },\r\n controller: controller\r\n };\r\n\r\n function controller($scope, focus) {\r\n $scope.inline = $scope.$parent.$parent.columnFilterWrapperCtrl.inline; //Hacky way of getting the value from the outer wrapper\r\n $scope.data = {};\r\n $scope.tooltip = $scope.tooltip || \"\";\r\n\r\n $scope.$on(\"opened\", function () {\r\n focus(\"freetext-filter-input\");\r\n });\r\n\r\n function emitFilterEvent(isOpen) {\r\n isOpen = $scope.inline || isOpen;\r\n $scope.$emit(\"filter\", $scope.column, {\r\n filterValue: $scope.data.filter,\r\n filterType: \"freetext\"\r\n }, angular.isDefined($scope.data.filter) && $scope.data.filter.length > 0, isOpen);\r\n }\r\n\r\n $scope.$on(\"clear\", function () {\r\n //Don't clear but close window (event is fired when clicked outside)\r\n emitFilterEvent(false);\r\n });\r\n\r\n $scope.onKeyUp = function (keyEvent) {\r\n if (keyEvent.which === 13 || $scope.onKey) {\r\n emitFilterEvent($scope.onKey && keyEvent.which !== 13); //Keep open if triggered by key, close always when enter pressed\r\n }\r\n };\r\n DebugService.log(\"filter-freetext\");\r\n }\r\n}\r\n\r\nangular\r\n .module('nzbhydraApp').directive(\"checkboxesFilter\", checkboxesFilter);\r\n\r\nfunction checkboxesFilter() {\r\n controller.$inject = [\"$scope\", \"DebugService\"];\r\n return {\r\n template: '',\r\n controllerAs: 'checkboxesFilterController',\r\n scope: {\r\n column: \"@\",\r\n entries: \"<\",\r\n preselect: \"<\",\r\n showInvert: \"<\",\r\n isBoolean: \"<\"\r\n },\r\n controller: controller\r\n };\r\n\r\n function controller($scope, DebugService) {\r\n $scope.selected = {\r\n entries: []\r\n };\r\n $scope.active = false;\r\n\r\n if ($scope.preselect) {\r\n $scope.selected.entries.push.apply($scope.selected.entries, $scope.entries);\r\n }\r\n\r\n $scope.invert = function () {\r\n $scope.selected.entries = _.difference($scope.entries, $scope.selected.entries);\r\n };\r\n\r\n $scope.selectAll = function () {\r\n $scope.selected.entries.push.apply($scope.selected.entries, $scope.entries);\r\n };\r\n\r\n $scope.deselectAll = function () {\r\n $scope.selected.entries.splice(0, $scope.selected.entries.length);\r\n };\r\n\r\n $scope.apply = function () {\r\n $scope.active = $scope.selected.entries.length < $scope.entries.length;\r\n $scope.$emit(\"filter\", $scope.column, {\r\n filterValue: _.pluck($scope.selected.entries, \"id\"),\r\n filterType: \"checkboxes\",\r\n isBoolean: $scope.isBoolean\r\n }, $scope.active)\r\n };\r\n $scope.clear = function () {\r\n $scope.selectAll();\r\n $scope.active = false;\r\n $scope.$emit(\"filter\", $scope.column, {\r\n filterValue: undefined,\r\n filterType: \"checkboxes\",\r\n isBoolean: $scope.isBoolean\r\n }, $scope.active)\r\n };\r\n $scope.$on(\"clear\", $scope.clear);\r\n DebugService.log(\"filter-checkboxes\");\r\n }\r\n}\r\n\r\nangular\r\n .module('nzbhydraApp').directive(\"booleanFilter\", booleanFilter);\r\n\r\nfunction booleanFilter(DebugService) {\r\n controller.$inject = [\"$scope\"];\r\n return {\r\n template: '',\r\n controllerAs: 'booleanFilterController',\r\n scope: {\r\n column: \"@\",\r\n options: \"<\",\r\n preselect: \"@\"\r\n },\r\n controller: controller\r\n };\r\n\r\n\r\n function controller($scope) {\r\n $scope.selected = {value: $scope.options[$scope.preselect].value};\r\n $scope.active = false;\r\n\r\n $scope.apply = function () {\r\n $scope.active = $scope.selected.value !== $scope.options[0].value;\r\n $scope.$emit(\"filter\", $scope.column, {\r\n filterValue: $scope.selected.value,\r\n filterType: \"boolean\"\r\n }, $scope.active)\r\n };\r\n $scope.clear = function () {\r\n $scope.selected.value = true;\r\n $scope.active = false;\r\n $scope.$emit(\"filter\", $scope.column, {filterValue: undefined, filterType: \"boolean\"}, $scope.active)\r\n };\r\n $scope.$on(\"clear\", $scope.clear);\r\n DebugService.log(\"filter-boolean\");\r\n }\r\n}\r\n\r\nangular\r\n .module('nzbhydraApp').directive(\"timeFilter\", timeFilter);\r\n\r\nfunction timeFilter() {\r\n controller.$inject = [\"$scope\", \"DebugService\"];\r\n return {\r\n template: '',\r\n scope: {\r\n column: \"@\",\r\n selected: \"<\"\r\n },\r\n controller: controller\r\n };\r\n\r\n function controller($scope, DebugService) {\r\n\r\n $scope.dateOptions = {\r\n dateDisabled: false,\r\n formatYear: 'yy',\r\n startingDay: 1\r\n };\r\n\r\n $scope.formats = ['dd-MMMM-yyyy', 'yyyy/MM/dd', 'dd.MM.yyyy', 'shortDate'];\r\n $scope.format = $scope.formats[0];\r\n $scope.altInputFormats = ['M!/d!/yyyy'];\r\n $scope.active = false;\r\n\r\n $scope.openAfter = function () {\r\n $scope.after.opened = true;\r\n };\r\n\r\n $scope.openBefore = function () {\r\n $scope.before.opened = true;\r\n };\r\n\r\n $scope.after = {\r\n opened: false\r\n };\r\n\r\n $scope.before = {\r\n opened: false\r\n };\r\n\r\n $scope.apply = function () {\r\n $scope.active = $scope.selected.beforeDate || $scope.selected.afterDate;\r\n $scope.$emit(\"filter\", $scope.column, {\r\n filterValue: {\r\n after: $scope.selected.afterDate,\r\n before: $scope.selected.beforeDate\r\n }, filterType: \"time\"\r\n }, $scope.active)\r\n };\r\n $scope.clear = function () {\r\n $scope.selected.beforeDate = undefined;\r\n $scope.selected.afterDate = undefined;\r\n $scope.active = false;\r\n $scope.$emit(\"filter\", $scope.column, {filterValue: undefined, filterType: \"time\"}, $scope.active)\r\n };\r\n $scope.$on(\"clear\", $scope.clear);\r\n DebugService.log(\"filter-time\");\r\n }\r\n}\r\n\r\nangular\r\n .module('nzbhydraApp').directive(\"numberRangeFilter\", numberRangeFilter);\r\n\r\nfunction numberRangeFilter() {\r\n controller.$inject = [\"$scope\", \"DebugService\"];\r\n return {\r\n template: '',\r\n scope: {\r\n column: \"@\",\r\n min: \"<\",\r\n max: \"<\",\r\n addon: \"@\",\r\n tooltip: \"@\"\r\n },\r\n controller: controller\r\n };\r\n\r\n function controller($scope, DebugService) {\r\n $scope.filterValue = {min: undefined, max: undefined};\r\n $scope.active = false;\r\n\r\n function apply() {\r\n $scope.active = $scope.filterValue.min || $scope.filterValue.max;\r\n $scope.$emit(\"filter\", $scope.column, {\r\n filterValue: $scope.filterValue,\r\n filterType: \"numberRange\"\r\n }, $scope.active)\r\n }\r\n\r\n $scope.clear = function () {\r\n $scope.filterValue = {min: undefined, max: undefined};\r\n $scope.active = false;\r\n $scope.$emit(\"filter\", $scope.column, {\r\n filterValue: undefined,\r\n filterType: \"numberRange\",\r\n isBoolean: $scope.isBoolean\r\n }, $scope.active)\r\n };\r\n $scope.$on(\"clear\", $scope.clear);\r\n\r\n $scope.apply = function () {\r\n apply();\r\n };\r\n\r\n $scope.onKeypress = function (keyEvent) {\r\n if (keyEvent.which === 13) {\r\n apply();\r\n }\r\n };\r\n\r\n DebugService.log(\"filter-number\");\r\n }\r\n}\r\n\r\n\r\nangular\r\n .module('nzbhydraApp').directive(\"columnSortable\", columnSortable);\r\n\r\nfunction columnSortable() {\r\n controller.$inject = [\"$scope\"];\r\n return {\r\n restrict: \"E\",\r\n templateUrl: \"static/html/dataTable/columnSortable.html\",\r\n transclude: true,\r\n scope: {\r\n sortMode: \"<\", //0: no sorting, 1: asc, 2: desc\r\n column: \"@\",\r\n reversed: \"<\",\r\n startMode: \"<\"\r\n },\r\n controller: controller\r\n };\r\n\r\n function controller($scope) {\r\n if (angular.isUndefined($scope.sortMode)) {\r\n $scope.sortMode = 0;\r\n }\r\n\r\n if (angular.isUndefined($scope.startMode)) {\r\n $scope.startMode = 1;\r\n }\r\n\r\n $scope.sortModel = {\r\n sortMode: $scope.sortMode,\r\n column: $scope.column,\r\n reversed: $scope.reversed,\r\n startMode: $scope.startMode,\r\n active: false\r\n };\r\n\r\n $scope.$on(\"newSortColumn\", function (event, column, sortMode) {\r\n $scope.sortModel.active = column === $scope.sortModel.column;\r\n if (column !== $scope.sortModel.column) {\r\n $scope.sortModel.sortMode = 0;\r\n } else {\r\n $scope.sortModel.sortMode = sortMode;\r\n }\r\n });\r\n\r\n $scope.sort = function () {\r\n if ($scope.sortModel.sortMode === 0 || angular.isUndefined($scope.sortModel.sortMode)) {\r\n $scope.sortModel.sortMode = $scope.sortModel.startMode;\r\n } else if ($scope.sortModel.sortMode === 1) {\r\n $scope.sortModel.sortMode = 2;\r\n } else {\r\n $scope.sortModel.sortMode = 1;\r\n }\r\n $scope.$emit(\"sort\", $scope.sortModel.column, $scope.sortModel.sortMode, $scope.sortModel.reversed)\r\n };\r\n\r\n }\r\n}\r\n","angular\r\n .module('nzbhydraApp')\r\n .directive('connectionTest', connectionTest);\r\n\r\nfunction connectionTest() {\r\n controller.$inject = [\"$scope\"];\r\n return {\r\n templateUrl: 'static/html/directives/connection-test.html',\r\n require: ['^type', '^data'],\r\n scope: {\r\n type: \"=\",\r\n id: \"=\",\r\n data: \"=\",\r\n downloader: \"=\"\r\n },\r\n controller: controller\r\n };\r\n\r\n function controller($scope) {\r\n $scope.message = \"\";\r\n\r\n\r\n var testButton = \"#button-test-connection\";\r\n var testMessage = \"#message-test-connection\";\r\n\r\n function showSuccess() {\r\n angular.element(testButton).removeClass(\"btn-default\");\r\n angular.element(testButton).removeClass(\"btn-danger\");\r\n angular.element(testButton).addClass(\"btn-success\");\r\n }\r\n\r\n function showError() {\r\n angular.element(testButton).removeClass(\"btn-default\");\r\n angular.element(testButton).removeClass(\"btn-success\");\r\n angular.element(testButton).addClass(\"btn-danger\");\r\n }\r\n\r\n $scope.testConnection = function () {\r\n angular.element(testButton).addClass(\"glyphicon-refresh-animate\");\r\n var myInjector = angular.injector([\"ng\"]);\r\n var $http = myInjector.get(\"$http\");\r\n var url;\r\n var params;\r\n if ($scope.type === \"downloader\") {\r\n url = \"internalapi/test_downloader\";\r\n params = {name: $scope.downloader, username: $scope.data.username, password: $scope.data.password};\r\n if ($scope.downloader === \"SABNZBD\") {\r\n params.apiKey = $scope.data.apiKey;\r\n params.url = $scope.data.url;\r\n } else {\r\n params.host = $scope.data.host;\r\n params.port = $scope.data.port;\r\n params.ssl = $scope.data.ssl;\r\n }\r\n } else if ($scope.data.type === \"newznab\") {\r\n url = \"internalapi/test_newznab\";\r\n params = {host: $scope.data.host, apiKey: $scope.data.apiKey};\r\n if (angular.isDefined($scope.data.username)) {\r\n params[\"username\"] = $scope.data.username;\r\n params[\"password\"] = $scope.data.password;\r\n }\r\n }\r\n $http.get(url, {params: params}).then(function (result) {\r\n //Using ng-class and a scope variable doesn't work for some reason, is only updated at second click\r\n if (result.successful) {\r\n angular.element(testMessage).text(\"\");\r\n showSuccess();\r\n } else {\r\n angular.element(testMessage).text(result.message);\r\n showError();\r\n }\r\n\r\n }, function () {\r\n angular.element(testMessage).text(result.message);\r\n showError();\r\n }\r\n ).finally(function () {\r\n angular.element(testButton).removeClass(\"glyphicon-refresh-animate\");\r\n })\r\n }\r\n\r\n }\r\n}\r\n\r\n","//Taken from https://github.com/IamAdamJowett/angular-click-outside\r\n\r\nclickOutside.$inject = [\"$document\", \"$parse\", \"$timeout\"];\r\nfunction childOf(/*child node*/c, /*parent node*/p) { //returns boolean\r\n while ((c = c.parentNode) && c !== p) ;\r\n return !!c;\r\n}\r\n\r\nangular\r\n .module('nzbhydraApp').directive(\"clickOutside\", clickOutside);\r\n\r\n/**\r\n * @ngdoc directive\r\n * @name angular-click-outside.directive:clickOutside\r\n * @description Directive to add click outside capabilities to DOM elements\r\n * @requires $document\r\n * @requires $parse\r\n * @requires $timeout\r\n **/\r\nfunction clickOutside($document, $parse, $timeout) {\r\n return {\r\n restrict: 'A',\r\n link: function ($scope, elem, attr) {\r\n\r\n // postpone linking to next digest to allow for unique id generation\r\n $timeout(function () {\r\n var classList = (attr.outsideIfNot !== undefined) ? attr.outsideIfNot.split(/[ ,]+/) : [],\r\n fn;\r\n\r\n function eventHandler(e) {\r\n var i,\r\n element,\r\n r,\r\n id,\r\n classNames,\r\n l;\r\n\r\n // check if our element already hidden and abort if so\r\n if (angular.element(elem).hasClass(\"ng-hide\")) {\r\n return;\r\n }\r\n\r\n // if there is no click target, no point going on\r\n if (!e || !e.target) {\r\n return;\r\n }\r\n\r\n if (angular.isDefined(attr.outsideIgnore) && $scope.$eval(attr.outsideIgnore)) {\r\n return;\r\n }\r\n var isChild = childOf(e.target, elem.context);\r\n if (isChild) {\r\n return;\r\n }\r\n // loop through the available elements, looking for classes in the class list that might match and so will eat\r\n for (element = e.target; element; element = element.parentNode) {\r\n // check if the element is the same element the directive is attached to and exit if so (props @CosticaPuntaru)\r\n if (element === elem[0]) {\r\n return;\r\n }\r\n\r\n // now we have done the initial checks, start gathering id's and classes\r\n id = element.id,\r\n classNames = element.className,\r\n l = classList.length;\r\n\r\n // Unwrap SVGAnimatedString classes\r\n if (classNames && classNames.baseVal !== undefined) {\r\n classNames = classNames.baseVal;\r\n }\r\n\r\n // if there are no class names on the element clicked, skip the check\r\n if (classNames || id) {\r\n\r\n // loop through the elements id's and classnames looking for exceptions\r\n for (i = 0; i < l; i++) {\r\n //prepare regex for class word matching\r\n r = new RegExp('\\\\b' + classList[i] + '\\\\b');\r\n\r\n // check for exact matches on id's or classes, but only if they exist in the first place\r\n if ((id !== undefined && id === classList[i]) || (classNames && r.test(classNames))) {\r\n // now let's exit out as it is an element that has been defined as being ignored for clicking outside\r\n return;\r\n }\r\n }\r\n }\r\n }\r\n\r\n // if we have got this far, then we are good to go with processing the command passed in via the click-outside attribute\r\n $timeout(function () {\r\n fn = $parse(attr['clickOutside']);\r\n fn($scope, {event: e});\r\n });\r\n }\r\n\r\n // if the devices has a touchscreen, listen for this event\r\n if (_hasTouch()) {\r\n $document.on('touchstart', eventHandler);\r\n }\r\n\r\n // still listen for the click event even if there is touch to cater for touchscreen laptops\r\n $document.on('click', eventHandler);\r\n\r\n // when the scope is destroyed, clean up the documents event handlers as we don't want it hanging around\r\n $scope.$on('$destroy', function () {\r\n if (_hasTouch()) {\r\n $document.off('touchstart', eventHandler);\r\n }\r\n\r\n $document.off('click', eventHandler);\r\n });\r\n\r\n /**\r\n * @description Private function to attempt to figure out if we are on a touch device\r\n * @private\r\n **/\r\n function _hasTouch() {\r\n // works on most browsers, IE10/11 and Surface\r\n return 'ontouchstart' in window || navigator.maxTouchPoints;\r\n }\r\n });\r\n }\r\n };\r\n}\r\n","angular\r\n .module('nzbhydraApp')\r\n .directive('cfgFormEntry', cfgFormEntry);\r\n\r\nfunction cfgFormEntry() {\r\n return {\r\n templateUrl: 'static/html/directives/cfg-form-entry.html',\r\n require: [\"^title\", \"^cfg\"],\r\n scope: {\r\n title: \"@\",\r\n cfg: \"=\",\r\n help: \"@\",\r\n type: \"@?\",\r\n options: \"=?\"\r\n },\r\n controller: [\"$scope\", \"$element\", \"$attrs\", function ($scope, $element, $attrs) {\r\n $scope.type = angular.isDefined($scope.type) ? $scope.type : 'text';\r\n $scope.options = angular.isDefined($scope.type) ? $scope.$eval($attrs.options) : [];\r\n }]\r\n };\r\n}","angular\n .module('nzbhydraApp')\n .directive('hydrabackup', hydrabackup);\n\nfunction hydrabackup() {\n controller.$inject = [\"$scope\", \"BackupService\", \"Upload\", \"FileDownloadService\", \"$http\", \"RequestsErrorHandler\", \"growl\", \"RestartService\"];\n return {\n templateUrl: 'static/html/directives/backup.html',\n controller: controller\n };\n\n function controller($scope, BackupService, Upload, FileDownloadService, $http, RequestsErrorHandler, growl, RestartService) {\n $scope.refreshBackupList = function () {\n BackupService.getBackupsList().then(function (backups) {\n $scope.backups = backups;\n });\n };\n\n $scope.refreshBackupList();\n\n $scope.uploadActive = false;\n\n\n $scope.createBackupFile = function () {\n $http.get(\"internalapi/backup/backuponly\", {params: {dontdownload: true}}).then(function () {\n $scope.refreshBackupList();\n });\n };\n $scope.createAndDownloadBackupFile = function () {\n FileDownloadService.downloadFile(\"internalapi/backup/backup\", \"nzbhydra-backup-\" + moment().format(\"YYYY-MM-DD-HH-mm\") + \".zip\", \"GET\").then(function () {\n $scope.refreshBackupList();\n });\n };\n\n $scope.uploadBackupFile = function (file, errFiles) {\n RequestsErrorHandler.specificallyHandled(function () {\n\n $scope.file = file;\n $scope.errFile = errFiles && errFiles[0];\n if (file) {\n $scope.uploadActive = true;\n file.upload = Upload.upload({\n url: 'internalapi/backup/restorefile',\n file: file\n });\n\n file.upload.then(function (response) {\n if (response.data.successful) {\n $scope.uploadActive = false;\n RestartService.startCountdown(\"Upload successful. Restarting for wrapper to restore data.\");\n } else {\n file.progress = 0;\n growl.error(response.data.message)\n }\n\n }, function (response) {\n growl.error(response.data.message)\n }, function (evt) {\n file.progress = Math.min(100, parseInt(100.0 * evt.loaded / evt.total));\n file.loaded = Math.floor(evt.loaded / 1024);\n file.total = Math.floor(evt.total / 1024);\n });\n }\n });\n };\n\n $scope.restoreFromFile = function (filename) {\n BackupService.restoreFromFile(filename).then(function () {\n RestartService.startCountdown(\"Extraction of backup successful. Restarting for wrapper to restore data.\");\n },\n function (response) {\n growl.error(response.data);\n })\n }\n\n }\n}\n\n","\naddableNzbs.$inject = [\"DebugService\"];angular\n .module('nzbhydraApp')\n .directive('addableNzbs', addableNzbs);\n\nfunction addableNzbs(DebugService) {\n controller.$inject = [\"$scope\", \"NzbDownloadService\"];\n return {\n templateUrl: 'static/html/directives/addable-nzbs.html',\n require: [],\n scope: {\n searchresult: \"<\",\n alwaysAsk: \"<\"\n },\n controller: controller\n };\n\n function controller($scope, NzbDownloadService) {\n $scope.alwaysAsk = $scope.alwaysAsk === \"true\";\n $scope.downloaders = _.filter(NzbDownloadService.getEnabledDownloaders(), function (downloader) {\n if ($scope.searchresult.downloadType !== \"NZB\") {\n return downloader.downloadType === $scope.searchresult.downloadType\n }\n return true;\n });\n }\n}\n","\r\naddableNzb.$inject = [\"DebugService\"];angular\r\n .module('nzbhydraApp')\r\n .directive('addableNzb', addableNzb);\r\n\r\nfunction addableNzb(DebugService) {\r\n controller.$inject = [\"$scope\", \"NzbDownloadService\", \"growl\"];\r\n return {\r\n templateUrl: 'static/html/directives/addable-nzb.html',\r\n scope: {\r\n searchresult: \"=\",\r\n downloader: \"<\",\r\n alwaysAsk: \"<\"\r\n },\r\n controller: controller\r\n };\r\n\r\n function controller($scope, NzbDownloadService, growl) {\r\n if (!_.isNullOrEmpty($scope.downloader.iconCssClass)) {\r\n $scope.cssClass = \"fa fa-\" + $scope.downloader.iconCssClass.replace(\"fa-\", \"\").replace(\"fa \", \"\");\r\n } else {\r\n $scope.cssClass = $scope.downloader.downloaderType === \"SABNZBD\" ? \"sabnzbd\" : \"nzbget\";\r\n }\r\n\r\n $scope.add = function () {\r\n var originalClass = $scope.cssClass;\r\n $scope.cssClass = \"nzb-spinning\";\r\n NzbDownloadService.download($scope.downloader, [{\r\n searchResultId: $scope.searchresult.searchResultId ? $scope.searchresult.searchResultId : $scope.searchresult.id,\r\n originalCategory: $scope.searchresult.originalCategory,\r\n mappedCategory: $scope.searchresult.category\r\n }], $scope.alwaysAsk).then(function (response) {\r\n if (response !== \"dismissed\") {\r\n if (response.data.successful && (response.data.addedIds != null && response.data.addedIds.indexOf(Number($scope.searchresult.searchResultId)) > -1)) {\r\n $scope.cssClass = $scope.downloader.downloaderType === \"SABNZBD\" ? \"sabnzbd-success\" : \"nzbget-success\";\r\n } else {\r\n $scope.cssClass = $scope.downloader.downloaderType === \"SABNZBD\" ? \"sabnzbd-error\" : \"nzbget-error\";\r\n growl.error(response.data.message);\r\n }\r\n } else {\r\n $scope.cssClass = originalClass;\r\n }\r\n }, function () {\r\n $scope.cssClass = $scope.downloader.downloaderType === \"SABNZBD\" ? \"sabnzbd-error\" : \"nzbget-error\";\r\n growl.error(\"An unexpected error occurred while trying to contact NZBHydra or add the NZB.\");\r\n })\r\n };\r\n }\r\n}","/*\n * (C) Copyright 2017 TheOtherP (theotherp@posteo.net)\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nCheckCapsModalInstanceCtrl.$inject = [\"$scope\", \"$interval\", \"$http\", \"$timeout\", \"growl\", \"capsCheckRequest\"];\nIndexerConfigBoxService.$inject = [\"$http\", \"$q\", \"$uibModal\"];\nIndexerCheckBeforeCloseService.$inject = [\"$q\", \"ModalService\", \"IndexerConfigBoxService\", \"growl\", \"blockUI\"];\nfunction regexValidator(regex, message, prefixViewValue, preventEmpty) {\n return {\n expression: function ($viewValue, $modelValue) {\n var value = $modelValue || $viewValue;\n if (value) {\n if (Array.isArray(value)) {\n for (var i = 0; i < value.length; i++) {\n if (!regex.test(value[i])) {\n return false;\n }\n }\n return true;\n } else {\n return regex.test(value);\n }\n }\n return !preventEmpty;\n },\n message: (prefixViewValue ? '$viewValue + \" ' : '\" ') + message + '\"'\n };\n}\n\nfunction getIndexerBoxFields(indexerModel, parentModel, isInitial, CategoriesService) {\n var fieldset = [];\n if (indexerModel.searchModuleType === \"TORZNAB\") {\n fieldset.push({\n type: 'help',\n templateOptions: {\n type: 'help',\n lines: [\"Torznab indexers can only be used for internal searches or dedicated searches using /torznab/api\"]\n }\n });\n }\n if ((indexerModel.searchModuleType === \"NEWZNAB\" || indexerModel.searchModuleType === \"TORZNAB\") && !isInitial && indexerModel.searchModuleType !== 'JACKETT_CONFIG') {\n var message;\n var cssClass;\n if (!indexerModel.configComplete) {\n message = \"The config of this indexer is incomplete. Please click the button at the bottom to check its capabilities and complete its configuration.\";\n cssClass = \"alert alert-danger\";\n } else {\n message = \"The capabilities of this indexer were not checked completely. Some actually supported search types or IDs may not be usable.\";\n cssClass = \"alert alert-warning\";\n }\n fieldset.push({\n type: 'help',\n hideExpression: 'model.allCapsChecked && model.configComplete',\n templateOptions: {\n type: 'help',\n lines: [message],\n class: cssClass\n }\n });\n }\n\n var stateHelp = \"\";\n if (indexerModel.state === \"DISABLED_SYSTEM_TEMPORARY\" || indexerModel.state === \"DISABLED_SYSTEM\") {\n if (indexerModel.state === \"DISABLED_SYSTEM_TEMPORARY\") {\n stateHelp = \"The indexer was disabled by the program due to an error. It will be reenabled automatically or you can enable it manually\";\n } else {\n stateHelp = \"The indexer was disabled by the program due to error from which it cannot recover by itself. Try checking the caps to make sure it works or just enable it and see what happens.\";\n }\n }\n\n if (indexerModel.searchModuleType === 'NEWZNAB' || indexerModel.searchModuleType === 'TORZNAB') {\n fieldset.push(\n {\n key: 'name',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n label: 'Name',\n required: true\n },\n validators: {\n uniqueName: {\n expression: function (viewValue) {\n if (isInitial || viewValue !== indexerModel.name) {\n return _.pluck(parentModel, \"name\").indexOf(viewValue) === -1;\n }\n return true;\n },\n message: '\"Indexer \\\\\"\" + $viewValue + \"\\\\\" already exists\"'\n },\n noComma:\n {\n expression: function ($viewValue, $modelValue) {\n var value = $modelValue || $viewValue;\n if (value) {\n return value.indexOf(\",\") === -1;\n }\n return true;\n },\n message: '\"Name may not contain a comma\"'\n }\n }\n })\n }\n\n if (indexerModel.searchModuleType !== 'JACKETT_CONFIG') {\n fieldset.push({\n key: 'state',\n type: 'horizontalIndexerStateSwitch',\n templateOptions: {\n type: 'switch',\n label: 'State',\n help: stateHelp\n }\n });\n }\n\n if (['WTFNZB', 'NEWZNAB', 'TORZNAB', 'JACKETT_CONFIG'].includes(indexerModel.searchModuleType)) {\n var hostField = {\n key: 'host',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n label: 'Host',\n required: true,\n placeholder: 'http://www.someindexer.com'\n },\n watcher: {\n listener: function (field, newValue, oldValue, scope) {\n if (newValue !== oldValue) {\n scope.$parent.needsConnectionTest = true;\n }\n }\n }\n };\n if (indexerModel.searchModuleType === 'TORZNAB') {\n hostField.templateOptions.help = 'If you use Jackett and have an external URL use that one';\n }\n fieldset.push(\n hostField\n );\n }\n\n if (['WTFNZB', 'NEWZNAB', 'TORZNAB', 'JACKETT_CONFIG', 'NZBINDEX_API'].includes(indexerModel.searchModuleType) && indexerModel.host !== 'https://feed.animetosho.org') {\n fieldset.push(\n {\n key: 'apiKey',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n label: 'API Key'\n },\n watcher: {\n listener: function (field, newValue, oldValue, scope) {\n if (newValue !== oldValue) {\n scope.$parent.needsConnectionTest = true;\n }\n }\n }\n }\n )\n }\n\n if (['NEWZNAB', 'TORZNAB'].includes(indexerModel.searchModuleType)) {\n fieldset.push(\n {\n key: 'apiPath',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n label: 'API path',\n help: 'Path to the API. If empty /api is used',\n required: false,\n advanced: true\n },\n watcher: {\n listener: function (field, newValue, oldValue, scope) {\n if (newValue !== oldValue) {\n scope.$parent.needsConnectionTest = true;\n }\n }\n }\n }\n )\n }\n\n if (['NEWZNAB', 'TORZNAB', 'JACKETT_CONFIG'].includes(indexerModel.searchModuleType)) {\n fieldset.push(\n {\n key: 'username',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n required: false,\n label: 'Username',\n help: 'Only needed if indexer requires HTTP auth for API access (rare).'\n },\n watcher: {\n listener: function (field, newValue, oldValue, scope) {\n if (newValue !== oldValue) {\n scope.$parent.needsConnectionTest = true;\n }\n }\n }\n }\n );\n }\n\n if ('WTFNZB' === indexerModel.searchModuleType) {\n fieldset.push(\n {\n key: 'username',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n required: true,\n label: 'Username',\n help: 'See the API help on the website. Copy the user ID from the example API request where it says i=<yourUserId> (e.g. ABg4Cd==)'\n },\n watcher: {\n listener: function (field, newValue, oldValue, scope) {\n if (newValue !== oldValue) {\n scope.$parent.needsConnectionTest = true;\n }\n }\n }\n }\n );\n fieldset.push(\n {\n key: 'password',\n type: 'passwordSwitch',\n hideExpression: '!model.username',\n templateOptions: {\n type: 'text',\n required: false,\n label: 'Password',\n help: 'Only needed if indexer requires HTTP auth for API access (rare).'\n }\n }\n )\n }\n\n\n if (indexerModel.searchModuleType !== 'JACKETT_CONFIG') {\n fieldset.push(\n {\n key: 'score',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'Priority',\n required: true,\n help: 'When duplicate search results are found the result from the indexer with the highest number will be selected.',\n tooltip: 'The priority determines which indexer is used if duplicate results are found (i.e. results that link to the same upload, not just results with the same name). The result from the indexer with the highest number is shown first in the GUI and returned for API searches.'\n\n }\n });\n }\n\n fieldset.push(\n {\n key: 'timeout',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'Timeout',\n min: 1,\n help: 'Supercedes the general timeout in \"Searching\".',\n advanced: true\n }\n },\n {\n key: 'schedule',\n type: 'horizontalChips',\n templateOptions: {\n type: 'text',\n label: 'Schedule',\n help: 'Determines when an indexer should be selected. See wiki. You can enter multiple time spans. Apply values with return key.',\n advanced: true\n }\n }\n );\n\n if (['NEWZNAB', 'TORZNAB'].includes(indexerModel.searchModuleType)) {\n fieldset.push(\n {\n key: 'hitLimit',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'API hit limit',\n help: 'Maximum number of API hits since \"API hit reset time\".',\n tooltip: 'When the maximum number of API hits is reached the indexer isn\\'t used anymore. Only API hits done by NZBHydra are taken into account.'\n },\n validators: {\n greaterThanZero: {\n expression: function ($viewValue, $modelValue) {\n var value = $modelValue || $viewValue;\n return _.isNullOrEmpty(value) || value > 0;\n },\n message: '\"Value must be greater than 0\"'\n }\n }\n },\n {\n key: 'downloadLimit',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'Download limit',\n help: 'When # of downloads since \"Hit reset time\" is reached indexer will not be searched.'\n },\n validators: {\n greaterThanZero: {\n expression: function ($viewValue, $modelValue) {\n var value = $modelValue || $viewValue;\n return _.isNullOrEmpty(value) || value > 0;\n },\n message: '\"Value must be greater than 0\"'\n }\n }\n }\n );\n fieldset.push(\n {\n key: 'hitLimitResetTime',\n type: 'horizontalInput',\n hideExpression: '!model.hitLimit && !model.downloadLimit',\n templateOptions: {\n type: 'number',\n label: 'Hit reset time',\n help: 'UTC hour of day at which the API hit counter is reset (0-23). Leave empty for a rolling reset counter.',\n tooltip: 'Either define the time of day when the counter is reset by the indexer or leave it empty to use a rolling reset counter, meaning the number of hits for the last 24h at the time of the search is limited.'\n },\n validators: {\n timeOfDay: {\n expression: function ($viewValue, $modelValue) {\n var value = $modelValue || $viewValue;\n return value >= 0 && value <= 23;\n },\n message: '$viewValue + \" is not a valid hour of day (0-23)\"'\n }\n }\n },\n {\n key: 'loadLimitOnRandom',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'Load limiting',\n help: 'If set indexer will only be picked for one out of x API searches (on average).',\n tooltip: 'For indexers with a low API hit limit you can enable load limiting. Define any number n so that the indexer will only be used for searches in 1/n cases (on average). For example if you define a load limit of 5 the indexer will only be picked every fifth search.',\n advanced: true\n },\n validators: {\n greaterThanZero: {\n expression: function ($viewValue, $modelValue) {\n var value = $modelValue || $viewValue;\n return _.isNullOrEmpty(value) || value > 1;\n },\n message: '\"Value must be greater than 1\"'\n }\n }\n }\n );\n }\n if (indexerModel.searchModuleType === 'TORZNAB') {\n fieldset.push({\n key: 'minSeeders',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'Minimum # seeders',\n help: 'Torznab results with fewer seeders will be ignored. Supercedes any setting made in the searching config.'\n }\n })\n }\n\n if (['NEWZNAB', 'TORZNAB', 'WTFNZB'].includes(indexerModel.searchModuleType)) {\n fieldset.push(\n {\n key: 'userAgent',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n required: false,\n label: 'User agent',\n help: 'Rarely needed. Will supercede the one in the main searching settings.',\n advanced: true\n }\n }\n )\n }\n\n if (['NEWZNAB', 'TORZNAB'].includes(indexerModel.searchModuleType)) {\n fieldset.push(\n {\n key: 'customParameters',\n type: 'horizontalChips',\n templateOptions: {\n type: 'text',\n required: false,\n label: 'Custom parameters',\n help: 'Define custom parameters to be sent to the indexer when searching. Use the format \"name=value\"Apply values with return key.',\n advanced: 'true'\n }\n }\n )\n }\n\n fieldset.push(\n {\n key: 'preselect',\n type: 'horizontalSwitch',\n hideExpression: 'model.enabledForSearchSource===\"EXTERNAL\"',\n templateOptions: {\n type: 'switch',\n label: 'Preselect',\n help: 'Preselect this indexer on the search page.'\n }\n }\n );\n fieldset.push(\n {\n key: 'enabledForSearchSource',\n type: 'horizontalSelect',\n templateOptions: {\n label: 'Enable for...',\n options: [\n {name: 'Internal searches only', value: 'INTERNAL'},\n {name: 'API searches only', value: 'API'},\n {name: 'All but API update queries ', value: 'ALL_BUT_RSS'},\n {name: 'Only API update queries ', value: 'ONLY_RSS'},\n {name: 'Internal and any API searches', value: 'BOTH'}\n ],\n help: 'Select for which searches this indexer will be used. \"Update queries\" are searches without query or ID (e.g. done by Sonarr periodically).',\n advanced: true\n }\n }\n );\n\n fieldset.push(\n {\n key: 'color',\n type: 'colorInput',\n templateOptions: {\n label: 'Color',\n help: 'If set it will be used in the search results to mark the indexer\\'s results.',\n tooltip: 'To mark expanded results they\\'re shown in a darker shade so it\\'s recommended to use indexer colors which not only differ in lightness',\n advanced: true\n }\n }\n );\n\n fieldset.push(\n {\n key: 'vipExpirationDate',\n type: 'horizontalInput',\n templateOptions: {\n required: false,\n label: 'VIP expiry',\n help: 'Enter when your VIP access expires and NZBHydra will track it and warn you when close to expiry. Enter as YYYY-MM-DD or \"Lifetime\".'\n },\n validators: {\n port: regexValidator(/^(\\d{4}-\\d{2}-\\d{2})|Lifetime$/, \"is no valid date (must be 'YYYY-MM-DD' or 'Lifetime')\", true, false)\n }\n }\n );\n\n if (indexerModel.searchModuleType !== \"ANIZB\" && indexerModel.searchModuleType !== 'JACKETT_CONFIG') {\n var cats = CategoriesService.getWithoutAll();\n var options = _.map(cats, function (x) {\n return {id: x.name, label: x.name}\n });\n fieldset.push(\n {\n key: 'enabledCategories',\n type: 'horizontalMultiselect',\n templateOptions: {\n label: 'Categories',\n help: 'Only use indexer when searching for these and also reject results from others. Selecting none equals selecting all.',\n options: options,\n settings: {\n showSelectedValues: false,\n noSelectedText: \"None/All\"\n },\n advanced: true\n }\n }\n );\n }\n\n\n if ((['NEWZNAB', 'TORZNAB'].includes(indexerModel.searchModuleType)) && !isInitial && indexerModel.searchModuleType !== 'JACKETT_CONFIG') {\n fieldset.push(\n {\n key: 'supportedSearchIds',\n type: 'horizontalMultiselect',\n templateOptions: {\n label: 'Search IDs',\n options: [\n {label: 'IMDB (TV)', id: 'TVIMDB'},\n {label: 'TVDB', id: 'TVDB'},\n {label: 'TVRage', id: 'TVRAGE'},\n {label: 'Trakt', id: 'TRAKT'},\n {label: 'TVMaze', id: 'TVMAZE'},\n {label: 'IMDB', id: 'IMDB'},\n {label: 'TMDB', id: 'TMDB'}\n ],\n noSelectedText: \"None\",\n advanced: true\n }\n }\n );\n fieldset.push(\n {\n key: 'supportedSearchTypes',\n type: 'horizontalMultiselect',\n templateOptions: {\n label: 'Search types',\n options: [\n {label: 'Audio', id: 'AUDIO'},\n {label: 'Ebooks', id: 'BOOK'},\n {label: 'Movies', id: 'MOVIE'},\n {label: 'Search', id: 'SEARCH'},\n {label: 'TV', id: 'TVSEARCH'}\n ],\n buttonText: \"None\",\n advanced: true\n }\n }\n );\n fieldset.push(\n {\n type: 'horizontalCheckCaps',\n hideExpression: '!model.host || !model.name',\n templateOptions: {\n label: 'Check capabilities',\n help: 'Find out what search types and IDs the indexer supports.',\n tooltip: 'The first time an indexer is added the connection is tested. When successful the supported search IDs and types are checked. These determine if indexers allow searching for movies, shows or ebooks using meta data like the IMDB id or the author and title. Newznab indexers cannot be used until this check was completed. Click this button to execute the caps check again.'\n }\n }\n )\n }\n\n if (indexerModel.searchModuleType === 'NZBINDEX') {\n fieldset.push(\n {\n key: 'generalMinSize',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'Min size',\n help: 'NZBIndex returns a lot of crap with small file sizes. Set this value and all smaller results will be filtered out no matter the category'\n }\n }\n );\n }\n\n if (indexerModel.searchModuleType === 'BINSEARCH') {\n fieldset.push({\n key: 'binsearchOtherGroups',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Search in other groups',\n help: 'If disabled binsearch will only search in the most popular usenet groups'\n }\n })\n }\n\n return fieldset;\n}\n\nfunction _showBox(indexerModel, parentModel, isInitial, $uibModal, CategoriesService, mode, form, callback) {\n var modalInstance = $uibModal.open({\n templateUrl: 'static/html/config/indexer-config-box.html',\n controller: 'IndexerConfigBoxInstanceController',\n size: 'lg',\n resolve: {\n model: function () {\n indexerModel.showAdvanced = parentModel.showAdvanced;\n return indexerModel;\n },\n fields: function () {\n return getIndexerBoxFields(indexerModel, parentModel, isInitial, CategoriesService, mode);\n },\n form: function () {\n return form;\n },\n isInitial: function () {\n return isInitial\n },\n parentModel: function () {\n return parentModel;\n }\n }\n });\n\n\n modalInstance.result.then(function (returnedModel) {\n form.$setDirty(true);\n if (angular.isDefined(callback)) {\n callback(true, returnedModel);\n }\n }, function () {\n if (angular.isDefined(callback)) {\n callback(false);\n }\n });\n}\n\nangular\n .module('nzbhydraApp')\n .config([\"formlyConfigProvider\", function config(formlyConfigProvider) {\n\n formlyConfigProvider.setType({\n name: 'indexers',\n templateUrl: 'static/html/config/indexer-config.html',\n controller: function ($scope, $uibModal, growl, CategoriesService) {\n $scope.showBox = showBox;\n $scope.formOptions = {formState: $scope.formState};\n $scope.showPresetSelection = showPresetSelection;\n\n function showPresetSelection() {\n $uibModal.open({\n templateUrl: 'static/html/config/indexer-config-selection.html',\n controller: 'IndexerConfigSelectionBoxInstanceController',\n size: 'lg',\n resolve: {\n model: function () {\n return $scope.model;\n },\n form: function () {\n return $scope.form;\n }\n }\n });\n }\n\n //Called when clicking the box of an existing indexer\n function showBox(indexerModel, model) {\n _showBox(indexerModel, model, false, $uibModal, CategoriesService, \"indexer\", $scope.form)\n }\n\n }\n });\n }]);\n\n\nangular.module('nzbhydraApp').controller('IndexerConfigSelectionBoxInstanceController', [\"$scope\", \"$q\", \"$uibModalInstance\", \"$uibModal\", \"$http\", \"model\", \"form\", \"growl\", \"CategoriesService\", \"$timeout\", \"ModalService\", \"RequestsErrorHandler\", function ($scope, $q, $uibModalInstance, $uibModal, $http, model, form, growl, CategoriesService, $timeout, ModalService, RequestsErrorHandler) {\n\n $scope.showBox = showBox;\n $scope.isInitial = false;\n\n $scope.select = function (modelPreset) {\n\n addEntry(modelPreset);\n $timeout(function () {\n $uibModalInstance.close();\n },\n 200);\n };\n\n $scope.readJackettConfig = function () {\n var indexerModel = createIndexerModel();\n indexerModel.searchModuleType = \"JACKETT_CONFIG\";\n indexerModel.isInitial = false;\n indexerModel.host = \"http://127.0.0.1:9117\";\n indexerModel.name = \"Jackett config\";\n _showBox(indexerModel, model, true, $uibModal, CategoriesService, \"jackettConfig\", form, function (isSubmitted, returnedModel) {\n if (isSubmitted) {\n //User pushed button, now we read the config\n RequestsErrorHandler.specificallyHandled(function () {\n $http.post(\"internalapi/indexer/readJackettConfig\", {existingIndexers: model, jackettConfig: returnedModel}, {\n headers: {\n \"Accept\": \"application/json;charset=utf-8\",\n \"Accept-Charset\": \"charset=utf-8\"\n }\n }).then(function (response) {\n //Replace model with new result\n model.splice(0, model.length);\n _.each(response.data.newIndexersConfig, function (x) {\n model.push(x);\n });\n growl.info(\"Added \" + response.data.addedTrackers + \" new trackers from Jackett\");\n growl.info(\"Updated \" + response.data.updatedTrackers + \" trackers from Jackett\");\n\n }, function (response) {\n ModalService.open(\"Error reading jackett config\", response.data, {}, \"md\", \"left\");\n });\n });\n }\n });\n\n $timeout(function () {\n $uibModalInstance.close();\n },\n 200);\n };\n\n function showBox(indexerModel, model) {\n _showBox(indexerModel, model, false, $uibModal, CategoriesService, \"indexer\", form)\n }\n\n function createIndexerModel() {\n return angular.copy({\n allCapsChecked: false,\n apiKey: null,\n backend: 'NEWZNAB',\n color: null,\n configComplete: false,\n categoryMapping: null,\n downloadLimit: null,\n enabledCategories: [],\n enabledForSearchSource: \"BOTH\",\n generalMinSize: null,\n hitLimit: null,\n hitLimitResetTime: 0,\n host: null,\n loadLimitOnRandom: null,\n name: null,\n password: null,\n preselect: true,\n score: 0,\n searchModuleType: 'NEWZNAB',\n showOnSearch: true,\n state: \"ENABLED\",\n supportedSearchIds: undefined,\n supportedSearchTypes: undefined,\n timeout: null,\n username: null,\n userAgent: null\n });\n }\n\n function addEntry(preset) {\n if (checkAddingAllowed(model, preset)) {\n var indexerModel = createIndexerModel();\n if (angular.isDefined(preset)) {\n _.extend(indexerModel, preset);\n }\n\n $scope.isInitial = true;\n\n _showBox(indexerModel, model, true, $uibModal, CategoriesService, \"indexer\", form, function (isSubmitted, returnedModel) {\n if (isSubmitted) {\n //Here is where the entry is actually added to the model\n model.push(angular.isDefined(returnedModel) ? returnedModel : indexerModel);\n }\n });\n } else {\n growl.error(\"That predefined indexer is already configured.\"); //For now this is the only case where adding is forbidden so we use this hardcoded message \"for now\"... (;-))\n }\n }\n\n function checkAddingAllowed(existingIndexers, preset) {\n if (!preset || !(preset.searchModuleType === \"ANIZB\" || preset.searchModuleType === \"BINSEARCH\" || preset.searchModuleType === \"NZBINDEX\" || preset.searchModuleType === \"NZBCLUB\")) {\n return true;\n }\n return !_.any(existingIndexers, function (existingEntry) {\n return existingEntry.name === preset.name;\n });\n }\n\n $scope.newznabPresets = [\n {\n name: \"abNZB\",\n host: \"https://abnzb.com/\"\n },\n {\n name: \"altHUB\",\n host: \"https://api.althub.co.za\"\n },\n {\n name: \"Animetosho (Newznab)\",\n host: \"https://feed.animetosho.org\",\n categories: [\"Anime\"],\n supportedSearchIds: [],\n supportedSearchTypes: [\"SEARCH\"],\n allCapsChecked: true,\n configComplete: true,\n categoryMapping: {\n anime: 5070,\n audiobook: null,\n comic: null,\n ebook: null,\n magazine: null,\n categories: [\n {\n id: 5070,\n name: \"Anime\",\n subCategories: []\n }\n ]\n }\n },\n {\n name: \"DogNZB\",\n host: \"https://api.dognzb.cr\"\n },\n {\n name: \"Drunken Slug\",\n host: \"https://api.drunkenslug.com\"\n },\n {\n name: \"FastNZB\",\n host: \"https://fastnzb.com\"\n },\n {\n name: \"LuluNZB\",\n host: \"https://lulunzb.com\"\n },\n {\n name: \"miatrix\",\n host: \"https://www.miatrix.com\"\n },\n {\n name: \"NZB Finder\",\n host: \"https://nzbfinder.ws\"\n },\n {\n name: \"NZBCat\",\n host: \"https://nzb.cat\"\n },\n {\n name: \"nzb.su\",\n host: \"https://api.nzb.su\"\n },\n {\n name: \"NZBGeek\",\n host: \"https://api.nzbgeek.info\"\n },\n {\n name: \"NzbNdx\",\n host: \"https://www.nzbndx.com\"\n },\n {\n name: \"NzBNooB\",\n host: \"https://www.nzbnoob.com\"\n },\n {\n name: \"NzbNation\",\n host: \"http://www.nzbnation.com/\"\n },\n {\n name: \"nzbplanet\",\n host: \"https://nzbplanet.net\"\n },\n {\n name: \"omgwtfnzbs\",\n host: \"https://api.omgwtfnzbs.org\"\n },\n {\n name: \"SceneNZBs\",\n host: \"https://scenenzbs.com\"\n },\n {\n name: \"spotweb.com\",\n host: \"https://spotweb.me\"\n },\n {\n name: \"Tabula-Rasa\",\n host: \"https://www.tabula-rasa.pw/api/v1/\"\n },\n {\n allCapsChecked: true,\n enabledForSearchSource: \"INTERNAL\",\n categories: [],\n configComplete: true,\n downloadLimit: null,\n hitLimit: null,\n hitLimitResetTime: null,\n host: \"https://binsearch.info\",\n loadLimitOnRandom: null,\n name: \"Binsearch\",\n password: null,\n preselect: true,\n score: 0,\n showOnSearch: true,\n state: \"ENABLED\",\n supportedSearchIds: [],\n supportedSearchTypes: [],\n timeout: null,\n searchModuleType: \"BINSEARCH\",\n username: null\n },\n {\n allCapsChecked: true,\n enabledForSearchSource: \"INTERNAL\",\n categories: [],\n configComplete: true,\n downloadLimit: null,\n generalMinSize: 1,\n hitLimit: null,\n hitLimitResetTime: null,\n host: \"https://nzbindex.com\",\n loadLimitOnRandom: null,\n name: \"NZBIndex\",\n password: null,\n preselect: true,\n score: 0,\n showOnSearch: true,\n state: \"ENABLED\",\n supportedSearchIds: [],\n supportedSearchTypes: [],\n timeout: null,\n searchModuleType: \"NZBINDEX\",\n username: null\n },\n {\n allCapsChecked: true,\n enabledForSearchSource: \"INTERNAL\",\n categories: [],\n configComplete: true,\n downloadLimit: null,\n generalMinSize: 1,\n hitLimit: null,\n hitLimitResetTime: null,\n host: \"https://api.nzbindex.com\",\n loadLimitOnRandom: null,\n name: \"NZBIndex API\",\n password: null,\n preselect: true,\n score: 0,\n showOnSearch: true,\n state: \"ENABLED\",\n supportedSearchIds: [],\n supportedSearchTypes: [],\n timeout: null,\n searchModuleType: \"NZBINDEX_API\",\n username: null\n },\n {\n allCapsChecked: true,\n enabledForSearchSource: \"INTERNAL\",\n categories: [],\n configComplete: true,\n downloadLimit: null,\n generalMinSize: 1,\n hitLimit: null,\n hitLimitResetTime: null,\n host: \"https://beta.nzbindex.com/search\",\n loadLimitOnRandom: null,\n name: \"NZBIndex Beta\",\n password: null,\n preselect: true,\n score: 0,\n showOnSearch: true,\n state: \"ENABLED\",\n supportedSearchIds: [],\n supportedSearchTypes: [],\n timeout: null,\n searchModuleType: \"NZBINDEX_BETA\",\n username: null\n },\n {\n allCapsChecked: true,\n enabledForSearchSource: \"INTERNAL\",\n categories: [],\n configComplete: true,\n downloadLimit: null,\n hitLimit: null,\n hitLimitResetTime: null,\n host: \"https://www.nzbking.com/search\",\n loadLimitOnRandom: null,\n name: \"NZBKing.com\",\n password: null,\n preselect: true,\n score: 0,\n showOnSearch: true,\n state: \"ENABLED\",\n supportedSearchIds: [],\n supportedSearchTypes: [],\n timeout: null,\n searchModuleType: \"NZBKING\",\n username: null\n },\n {\n allCapsChecked: true,\n enabledForSearchSource: \"INTERNAL\",\n categories: [],\n configComplete: true,\n downloadLimit: null,\n generalMinSize: 1,\n hitLimit: null,\n hitLimitResetTime: null,\n host: null,\n loadLimitOnRandom: null,\n name: \"WtfNzb\",\n password: null,\n preselect: true,\n score: 0,\n showOnSearch: true,\n state: \"ENABLED\",\n supportedSearchIds: [],\n supportedSearchTypes: [],\n timeout: null,\n searchModuleType: \"WTFNZB\",\n username: null,\n userAgent: null\n }\n ];\n\n $scope.newznabPresets = _.sortBy($scope.newznabPresets, function (entry) {\n return entry.name.toLowerCase()\n });\n\n $scope.torznabPresets = [\n {\n allCapsChecked: false,\n configComplete: false,\n name: \"Jackett/Cardigann\",\n host: \"http://127.0.0.1:9117/api/v2.0/indexers/YOURTRACKER/results/torznab/\",\n supportedSearchIds: undefined,\n supportedSearchTypes: undefined,\n searchModuleType: \"TORZNAB\",\n state: \"ENABLED\",\n enabledForSearchSource: \"BOTH\"\n },\n {\n categories: [\"Anime\"],\n allCapsChecked: true,\n configComplete: true,\n name: \"Animetosho (Torznab)\",\n host: \"https://feed.animetosho.org\",\n supportedSearchIds: [],\n supportedSearchTypes: [\"SEARCH\"],\n searchModuleType: \"TORZNAB\",\n state: \"ENABLED\",\n enabledForSearchSource: \"BOTH\"\n }\n ];\n\n $scope.emptyTorznabPreset = {\n allCapsChecked: false,\n configComplete: false,\n supportedSearchIds: undefined,\n supportedSearchTypes: undefined,\n searchModuleType: \"TORZNAB\",\n state: \"ENABLED\",\n enabledForSearchSource: \"BOTH\"\n };\n $scope.torznabPresets = _.sortBy($scope.torznabPresets, function (entry) {\n return entry.name.toLowerCase()\n });\n}]);\n\n\nangular.module('nzbhydraApp').controller('IndexerConfigBoxInstanceController', [\"$scope\", \"$q\", \"$uibModalInstance\", \"$http\", \"model\", \"form\", \"fields\", \"isInitial\", \"parentModel\", \"growl\", \"IndexerCheckBeforeCloseService\", function ($scope, $q, $uibModalInstance, $http, model, form, fields, isInitial, parentModel, growl, IndexerCheckBeforeCloseService) {\n\n $scope.model = model;\n $scope.fields = fields;\n $scope.isInitial = isInitial;\n $scope.spinnerActive = false;\n $scope.needsConnectionTest = false;\n\n $scope.obSubmit = function () {\n if (model.searchModuleType === 'JACKETT_CONFIG') {\n $uibModalInstance.close(model);\n } else if (form.$valid) {\n var a = IndexerCheckBeforeCloseService.checkBeforeClose($scope, model).then(function (data) {\n if (angular.isDefined(data)) {\n $scope.model = data;\n }\n $uibModalInstance.close(data);\n });\n } else {\n growl.error(\"Config invalid. Please check your settings.\");\n angular.forEach(form.$error, function (error) {\n angular.forEach(error, function (field) {\n field.$setTouched();\n });\n });\n }\n };\n\n $scope.cancel = function () {\n $uibModalInstance.dismiss();\n };\n\n $scope.deleteEntry = function () {\n parentModel.splice(parentModel.indexOf(model), 1);\n $uibModalInstance.close($scope);\n };\n\n $scope.reset = function () {\n //Reset the model twice (for some reason when we do it once the search types / ids fields are empty, resetting again fixes that... (wtf))\n $scope.options.resetModel();\n $scope.options.resetModel();\n };\n\n $scope.$on(\"modal.closing\", function (targetScope, reason) {\n if (reason === \"backdrop click\") {\n $scope.reset($scope);\n }\n });\n}]);\n\n\nangular\n .module('nzbhydraApp')\n .controller('CheckCapsModalInstanceCtrl', CheckCapsModalInstanceCtrl);\n\nfunction CheckCapsModalInstanceCtrl($scope, $interval, $http, $timeout, growl, capsCheckRequest) {\n\n var updateMessagesInterval = undefined;\n\n $scope.messages = undefined;\n $http.post(\"internalapi/indexer/checkCaps\", capsCheckRequest).then(function (response) {\n $scope.$close([response.data, capsCheckRequest.indexerConfig]);\n if (response.data.length === 0) {\n growl.info(\"No indexers were checked\");\n }\n }, function () {\n $scope.$dismiss(\"Unknown error\")\n });\n\n $timeout(\n updateMessagesInterval = $interval(function () {\n $http.get(\"internalapi/indexer/checkCapsMessages\").then(function (response) {\n var map = response.data;\n var messages = [];\n for (var name in map) {\n if (map.hasOwnProperty(name)) {\n for (var i = 0; i < map[name].length; i++) {\n var message = \"\";\n if (capsCheckRequest.checkType !== \"SINGLE\") {\n message += name + \": \";\n }\n message += map[name][i];\n messages.push(message);\n }\n }\n }\n $scope.messages = messages;\n });\n\n }, 500),\n 500);\n\n\n $scope.$on('$destroy', function () {\n if (angular.isDefined(updateMessagesInterval)) {\n $interval.cancel(updateMessagesInterval);\n }\n });\n}\n\nangular\n .module('nzbhydraApp')\n .factory('IndexerConfigBoxService', IndexerConfigBoxService);\n\nfunction IndexerConfigBoxService($http, $q, $uibModal) {\n\n return {\n checkConnection: checkConnection,\n checkCaps: checkCaps\n };\n\n function checkConnection(url, settings) {\n var deferred = $q.defer();\n\n $http.post(url, settings).then(function (result) {\n //Using ng-class and a scope variable doesn't work for some reason, is only updated at second click\n if (result.data.successful) {\n deferred.resolve({checked: true, message: null, model: result.data});\n } else {\n deferred.reject({checked: true, message: result.data.message});\n }\n }, function (result) {\n deferred.reject({checked: false, message: result.data.message});\n });\n\n return deferred.promise;\n }\n\n function checkCaps(capsCheckRequest) {\n var deferred = $q.defer();\n\n var result = $uibModal.open({\n templateUrl: 'static/html/checker-state.html',\n controller: CheckCapsModalInstanceCtrl,\n size: \"md\",\n backdrop: \"static\",\n backdropClass: \"waiting-cursor\",\n resolve: {\n capsCheckRequest: function () {\n return capsCheckRequest;\n }\n }\n });\n\n result.result.then(function (data) {\n deferred.resolve(data[0], data[1]);\n }, function (message) {\n deferred.reject(message);\n });\n\n return deferred.promise;\n }\n\n}\n\nangular\n .module('nzbhydraApp')\n .factory('IndexerCheckBeforeCloseService', IndexerCheckBeforeCloseService);\n\nfunction IndexerCheckBeforeCloseService($q, ModalService, IndexerConfigBoxService, growl, blockUI) {\n\n return {\n checkBeforeClose: checkBeforeClose\n };\n\n function checkBeforeClose(scope, model) {\n var deferred = $q.defer();\n if (model.searchModuleType === 'JACKETT_CONFIG') {\n deferred.resolve(model);\n } else if (!scope.isInitial && (!scope.needsConnectionTest || scope.form.capsChecked)) {\n checkCapsWhenClosing(scope, model).then(function () {\n deferred.resolve(model);\n }, function () {\n deferred.reject();\n });\n } else {\n scope.spinnerActive = true;\n blockUI.start(\"Testing connection...\");\n var url = \"internalapi/indexer/checkConnection\";\n IndexerConfigBoxService.checkConnection(url, model).then(function () {\n growl.info(\"Connection to the indexer tested successfully\");\n checkCapsWhenClosing(scope, model).then(function (data) {\n scope.spinnerActive = false;\n blockUI.reset();\n deferred.resolve(data);\n }, function () {\n scope.spinnerActive = false;\n blockUI.reset();\n deferred.reject();\n });\n },\n function (data) {\n scope.spinnerActive = false;\n blockUI.reset();\n handleConnectionCheckFail(ModalService, data, model, \"indexer\", deferred);\n });\n }\n return deferred.promise;\n }\n\n //Called when the indexer dialog is closed\n function checkCapsWhenClosing(scope, model) {\n var deferred = $q.defer();\n if (angular.isUndefined(model.supportedSearchIds) || angular.isUndefined(model.supportedSearchTypes)) {\n\n blockUI.start(\"New indexer found. Testing its capabilities. This may take a bit...\");\n IndexerConfigBoxService.checkCaps({indexerConfig: model, checkType: \"SINGLE\"}).then(\n function (data) {\n data = data[0]; //We get a list of results (with one result because the check type is single)\n blockUI.reset();\n scope.spinnerActive = false;\n if (data.allCapsChecked && data.configComplete) {\n growl.info(\"Successfully tested capabilites of indexer\");\n } else if (!data.allCapsChecked && data.configComplete) {\n ModalService.open(\"Incomplete caps check\", \"The capabilities of the indexer could not be checked completely. You may use it but it's recommended to repeat the check at another time. Until then some search types or IDs may not be usable.\", {}, \"md\", \"left\");\n } else if (!data.configComplete) {\n ModalService.open(\"Error testing capabilities\", \"An error occurred while contacting the indexer. It will not be usable until the caps check has been executed. You can trigger it manually from the indexer config box\", {}, \"md\", \"left\");\n }\n\n deferred.resolve(data.indexerConfig);\n },\n function () {\n blockUI.reset();\n scope.spinnerActive = false;\n model.supportedSearchIds = undefined;\n model.supportedSearchTypes = undefined;\n ModalService.open(\"Error testing capabilities\", \"An error occurred while contacting the indexer. It will not be usable until the caps check has been executed. You can trigger it manually using the button below.\", {}, \"md\", \"left\");\n deferred.resolve();\n }).finally(\n function () {\n scope.spinnerActive = false;\n })\n } else {\n deferred.resolve();\n }\n return deferred.promise;\n }\n}\n","/*\n * (C) Copyright 2017 TheOtherP (theotherp@posteo.net)\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\nDownloaderConfigBoxService.$inject = [\"$http\", \"$q\", \"$uibModal\"];\nDownloaderCheckBeforeCloseService.$inject = [\"$q\", \"DownloaderConfigBoxService\", \"growl\", \"ModalService\", \"blockUI\"];\nangular\n .module('nzbhydraApp')\n .config([\"formlyConfigProvider\", function config(formlyConfigProvider) {\n\n formlyConfigProvider.setType({\n name: 'downloaderConfig',\n templateUrl: 'static/html/config/downloader-config.html',\n controller: function ($scope, $uibModal, growl, CategoriesService, localStorageService) {\n $scope.formOptions = {formState: $scope.formState};\n $scope._showBox = _showBox;\n $scope.showBox = showBox;\n $scope.isInitial = false;\n $scope.presets = [\n {\n name: \"NZBGet\",\n downloaderType: \"NZBGET\",\n username: \"nzbgetx\",\n nzbAddingType: \"UPLOAD\",\n nzbAccessType: \"REDIRECT\",\n iconCssClass: \"\",\n downloadType: \"NZB\",\n url: \"http://nzbget:tegbzn6789@localhost:6789\"\n },\n {\n url: \"http://localhost:8080\",\n downloaderType: \"SABNZBD\",\n name: \"SABnzbd\",\n nzbAddingType: \"UPLOAD\",\n nzbAccessType: \"REDIRECT\",\n iconCssClass: \"\",\n downloadType: \"NZB\"\n }\n ];\n\n function _showBox(model, parentModel, isInitial, callback) {\n var modalInstance = $uibModal.open({\n templateUrl: 'static/html/config/downloader-config-box.html',\n controller: 'DownloaderConfigBoxInstanceController',\n size: 'lg',\n resolve: {\n model: function () {\n //Isn't properly stored in parentmodel for some reason, this works just as well\n model.showAdvanced = localStorageService.get(\"showAdvanced\");\n console.log(model.showAdvanced);\n return model;\n },\n fields: function () {\n return getDownloaderBoxFields(model, parentModel, isInitial, angular.injector(), CategoriesService);\n },\n isInitial: function () {\n return isInitial\n },\n parentModel: function () {\n return parentModel;\n },\n data: function () {\n return $scope.options.data;\n }\n }\n });\n\n\n modalInstance.result.then(function (returnedModel) {\n $scope.form.$setDirty(true);\n if (angular.isDefined(callback)) {\n callback(true, returnedModel);\n }\n }, function () {\n if (angular.isDefined(callback)) {\n callback(false);\n }\n });\n }\n\n function showBox(model, parentModel) {\n $scope._showBox(model, parentModel, false)\n }\n\n $scope.addEntry = function (entriesCollection, preset) {\n var model = angular.copy({\n enabled: true\n });\n if (angular.isDefined(preset)) {\n _.extend(model, preset);\n }\n\n $scope.isInitial = true;\n\n $scope._showBox(model, entriesCollection, true, function (isSubmitted, returnedModel) {\n if (isSubmitted) {\n //Here is where the entry is actually added to the model\n entriesCollection.push(angular.isDefined(returnedModel) ? returnedModel : model);\n }\n });\n };\n\n function getDownloaderBoxFields(model, parentModel, isInitial) {\n var fieldset = [];\n\n fieldset = _.union(fieldset, [\n {\n key: 'enabled',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Enabled'\n }\n },\n {\n key: 'name',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n label: 'Name',\n required: true\n },\n validators: {\n uniqueName: {\n expression: function (viewValue) {\n if (isInitial || viewValue !== model.name) {\n return _.pluck(parentModel, \"name\").indexOf(viewValue) === -1;\n }\n return true;\n },\n message: '\"Downloader \\\\\"\" + $viewValue + \"\\\\\" already exists\"'\n }\n }\n\n },\n {\n key: 'url',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n label: 'URL',\n help: 'URL with scheme and full path',\n required: true\n },\n watcher: {\n listener: function (field, newValue, oldValue, scope) {\n if (newValue !== oldValue) {\n scope.$parent.needsConnectionTest = true;\n }\n }\n }\n }\n ]);\n\n\n if (model.downloaderType === \"SABNZBD\") {\n fieldset.push({\n key: 'apiKey',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n label: 'API Key'\n },\n watcher: {\n listener: function (field, newValue, oldValue, scope) {\n if (newValue !== oldValue) {\n scope.$parent.needsConnectionTest = true;\n }\n }\n }\n })\n } else if (model.downloaderType === \"NZBGET\") {\n fieldset.push({\n key: 'username',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n label: 'Username'\n },\n watcher: {\n listener: function (field, newValue, oldValue, scope) {\n if (newValue !== oldValue) {\n scope.$parent.needsConnectionTest = true;\n }\n }\n }\n });\n fieldset.push({\n key: 'password',\n type: 'passwordSwitch',\n templateOptions: {\n type: 'text',\n label: 'Password'\n },\n watcher: {\n listener: function (field, newValue, oldValue, scope) {\n if (newValue !== oldValue) {\n scope.$parent.needsConnectionTest = true;\n }\n }\n }\n })\n }\n\n fieldset = _.union(fieldset, [\n {\n key: 'defaultCategory',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n label: 'Default category',\n help: 'When adding NZBs this category will be used instead of asking for the category. Write \"Use original category\", \"Use no category\" or \"Use mapped category\" to not be asked.',\n placeholder: 'Ask when downloading'\n }\n },\n {\n key: 'nzbAddingType',\n type: 'horizontalSelect',\n templateOptions: {\n type: 'select',\n label: 'NZB adding type',\n options: [\n {name: 'Send link', value: 'SEND_LINK'},\n {name: 'Upload NZB', value: 'UPLOAD'}\n ],\n help: \"How NZBs are added to the downloader, either by sending a link to the NZB or by uploading the NZB data.\",\n tooltip: 'You can select if you want to upload the NZB to the downloader or send a Hydra link. The downloader will do the download itself. This is a matter of taste, but adding a link and redirecting the downloader is the fastest way.' +\n ' Usually the links are determined using the URL via which you call it in your browser. If your downloader cannot access NZBHydra using that URL you can set a specific URL to be used in the main downloading config.',\n advanced: true\n }\n },\n {\n key: 'addPaused',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Add paused',\n help: 'Add NZBs paused',\n advanced: true\n }\n },\n {\n key: 'iconCssClass',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n label: 'Icon CSS class',\n help: 'Copy an icon name from https://fontawesome.com/v4.7.0/icons/ (e.g. \"film\")',\n placeholder: 'Default',\n tooltip: 'If you have multiple downloaders of the same type you can select an icon from the Font Awesome library. This icon will be shown in the search results and the NZB download history instead of the default downloader icon.',\n advanced: true\n }\n }\n ]);\n\n return fieldset;\n }\n }\n });\n }]);\n\n\nangular\n .module('nzbhydraApp')\n .factory('DownloaderConfigBoxService', DownloaderConfigBoxService);\n\nfunction DownloaderConfigBoxService($http, $q, $uibModal) {\n\n return {\n checkConnection: checkConnection,\n checkCaps: checkCaps\n };\n\n function checkConnection(url, settings) {\n var deferred = $q.defer();\n\n $http.post(url, settings).then(function (result) {\n //Using ng-class and a scope variable doesn't work for some reason, is only updated at second click\n if (result.data.successful) {\n deferred.resolve({checked: true, message: null, model: result.data});\n } else {\n deferred.reject({checked: true, message: result.data.message});\n }\n }, function (result) {\n deferred.reject({checked: false, message: result.data.message});\n });\n\n return deferred.promise;\n }\n\n function checkCaps(capsCheckRequest) {\n var deferred = $q.defer();\n\n var result = $uibModal.open({\n templateUrl: 'static/html/checker-state.html',\n controller: CheckCapsModalInstanceCtrl,\n size: \"md\",\n backdrop: \"static\",\n backdropClass: \"waiting-cursor\",\n resolve: {\n capsCheckRequest: function () {\n return capsCheckRequest;\n }\n }\n });\n\n result.result.then(function (data) {\n deferred.resolve(data[0], data[1]);\n }, function (message) {\n deferred.reject(message);\n });\n\n return deferred.promise;\n }\n}\n\nangular.module('nzbhydraApp').controller('DownloaderConfigBoxInstanceController', [\"$scope\", \"$q\", \"$uibModalInstance\", \"$http\", \"model\", \"fields\", \"isInitial\", \"parentModel\", \"data\", \"growl\", \"DownloaderCheckBeforeCloseService\", function ($scope, $q, $uibModalInstance, $http, model, fields, isInitial, parentModel, data, growl, DownloaderCheckBeforeCloseService) {\n\n $scope.model = model;\n $scope.fields = fields;\n $scope.isInitial = isInitial;\n $scope.spinnerActive = false;\n $scope.needsConnectionTest = false;\n\n $scope.obSubmit = function () {\n if ($scope.form.$valid) {\n var a = DownloaderCheckBeforeCloseService.checkBeforeClose($scope, model).then(function (data) {\n if (angular.isDefined(data)) {\n $scope.model = data;\n }\n $uibModalInstance.close(data);\n });\n } else {\n growl.error(\"Config invalid. Please check your settings.\");\n angular.forEach($scope.form.$error, function (error) {\n angular.forEach(error, function (field) {\n field.$setTouched();\n });\n });\n }\n };\n\n $scope.cancel = function () {\n $uibModalInstance.dismiss();\n };\n\n $scope.deleteEntry = function () {\n parentModel.splice(parentModel.indexOf(model), 1);\n $uibModalInstance.close($scope);\n };\n\n $scope.reset = function () {\n if (angular.isDefined(data.resetFunction)) {\n //Reset the model twice (for some reason when we do it once the search types / ids fields are empty, resetting again fixes that... (wtf))\n $scope.options.resetModel();\n $scope.options.resetModel();\n }\n };\n\n $scope.$on(\"modal.closing\", function (targetScope, reason) {\n if (reason === \"backdrop click\") {\n $scope.reset($scope);\n }\n });\n}]);\n\n\nangular\n .module('nzbhydraApp')\n .factory('DownloaderCheckBeforeCloseService', DownloaderCheckBeforeCloseService);\n\nfunction DownloaderCheckBeforeCloseService($q, DownloaderConfigBoxService, growl, ModalService, blockUI) {\n\n return {\n checkBeforeClose: checkBeforeClose\n };\n\n function checkBeforeClose(scope, model) {\n var deferred = $q.defer();\n if (!scope.isInitial && !scope.needsConnectionTest) {\n deferred.resolve();\n } else {\n scope.spinnerActive = true;\n blockUI.start(\"Testing connection...\");\n var url = \"internalapi/downloader/checkConnection\";\n DownloaderConfigBoxService.checkConnection(url, JSON.stringify(model)).then(function () {\n blockUI.reset();\n scope.spinnerActive = false;\n growl.info(\"Connection to the downloader tested successfully\");\n deferred.resolve();\n },\n function (data) {\n blockUI.reset();\n scope.spinnerActive = false;\n handleConnectionCheckFail(ModalService, data, model, \"downloader\", deferred);\n }).finally(function () {\n scope.spinnerActive = false;\n blockUI.reset();\n });\n }\n return deferred.promise;\n }\n}","/*\n * (C) Copyright 2017 TheOtherP (theotherp@posteo.net)\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nhashCode = function (s) {\n return s.split(\"\").reduce(function (a, b) {\n a = ((a << 5) - a) + b.charCodeAt(0);\n return a & a\n }, 0);\n};\n\nangular\n .module('nzbhydraApp').run([\"formlyConfig\", \"formlyValidationMessages\", function (formlyConfig, formlyValidationMessages) {\n formlyValidationMessages.addStringMessage('required', 'This field is required');\n formlyValidationMessages.addStringMessage('newznabCategories', 'Invalid');\n formlyConfig.extras.errorExistsAndShouldBeVisibleExpression = 'fc.$touched || form.$submitted';\n}]);\n\nangular\n .module('nzbhydraApp')\n .config([\"formlyConfigProvider\", function config(formlyConfigProvider) {\n formlyConfigProvider.extras.removeChromeAutoComplete = true;\n formlyConfigProvider.extras.explicitAsync = true;\n formlyConfigProvider.disableWarnings = window.onProd;\n\n\n formlyConfigProvider.setWrapper({\n name: 'settingWrapper',\n templateUrl: 'setting-wrapper.html'\n });\n\n\n formlyConfigProvider.setWrapper({\n name: 'fieldset',\n templateUrl: 'fieldset-wrapper.html',\n controller: ['$scope', function ($scope) {\n $scope.tooltipIsOpen = false;\n }]\n });\n\n formlyConfigProvider.setType({\n name: 'help',\n template: [\n '
'\n ].join(' '),\n controller: function ($scope, $uibModal, $http) {\n $scope.open = function () {\n var model = $scope.model;\n var modelCopy = structuredClone(model);\n $uibModal.open({\n templateUrl: 'static/html/custom-mapping-help.html',\n controller: [\"$scope\", \"$uibModalInstance\", \"$http\", function ($scope, $uibModalInstance, $http) {\n $scope.model = modelCopy;\n $scope.cancel = function () {\n $uibModalInstance.close();\n }\n $scope.submit = function () {\n Object.assign(model, $scope.model)\n $uibModalInstance.close();\n\n }\n\n $scope.test = function () {\n if (!$scope.exampleInput) {\n $scope.exampleResult = \"Empty example data\";\n return;\n\n }\n console.log(\"custom mapping test\");\n $http.post('internalapi/customMapping/test', {mapping: $scope.model, exampleInput: $scope.exampleInput, matchAll: $scope.matchAll}).then(function (response) {\n console.log(response.data);\n console.log(response.data.output);\n if (response.data.error) {\n $scope.exampleResult = response.data.error;\n } else if (response.data.match) {\n $scope.exampleResult = response.data.output;\n } else {\n $scope.exampleResult = \"Input does not match example\";\n }\n }, function (response) {\n $scope.exampleResult = response.message;\n })\n }\n }],\n size: \"md\"\n })\n }\n }\n });\n\n function updateIndexerModel(model, indexerConfig) {\n model.supportedSearchIds = indexerConfig.supportedSearchIds;\n model.supportedSearchTypes = indexerConfig.supportedSearchTypes;\n model.categoryMapping = indexerConfig.categoryMapping;\n model.configComplete = indexerConfig.configComplete;\n model.allCapsChecked = indexerConfig.allCapsChecked;\n model.hitLimit = indexerConfig.hitLimit;\n model.downloadLimit = indexerConfig.downloadLimit;\n model.state = indexerConfig.state;\n model.backend = indexerConfig.backend;\n }\n\n formlyConfigProvider.setType({\n //BUtton\n name: 'checkCaps',\n templateUrl: 'button-check-caps.html',\n controller: function ($scope, IndexerConfigBoxService, ModalService, growl) {\n $scope.message = \"\";\n $scope.uniqueId = hashCode($scope.model.name) + hashCode($scope.model.host);\n\n var testButton = \"#button-check-caps-\" + $scope.uniqueId;\n var testMessage = \"#message-check-caps-\" + $scope.uniqueId;\n\n function showSuccess() {\n angular.element(testButton).removeClass(\"btn-default\");\n angular.element(testButton).removeClass(\"btn-danger\");\n angular.element(testButton).removeClass(\"btn-warning\");\n angular.element(testButton).addClass(\"btn-success\");\n }\n\n function showError() {\n angular.element(testButton).removeClass(\"btn-default\");\n angular.element(testButton).removeClass(\"btn-warning\");\n angular.element(testButton).removeClass(\"btn-success\");\n angular.element(testButton).addClass(\"btn-danger\");\n }\n\n function showWarning() {\n angular.element(testButton).removeClass(\"btn-default\");\n angular.element(testButton).removeClass(\"btn-danger\");\n angular.element(testButton).removeClass(\"btn-success\");\n angular.element(testButton).addClass(\"btn-warning\");\n }\n\n\n //When button is clicked\n $scope.checkCaps = function () {\n angular.element(testButton).addClass(\"glyphicon-refresh-animate\");\n IndexerConfigBoxService.checkCaps({\n indexerConfig: $scope.model,\n checkType: \"SINGLE\"\n }).then(function (data) {\n data = data[0]; //We get a list of results (with one result because the check type is single)\n //Formly doesn't allow replacing the model so we need to set all the relevant values ourselves\n updateIndexerModel($scope.model, data.indexerConfig);\n if (data.indexerConfig.supportedSearchIds.length > 0) {\n var message = \"Supports \" + data.indexerConfig.supportedSearchIds;\n angular.element(testMessage).text(message);\n }\n if (data.indexerConfig.allCapsChecked && data.indexerConfig.configComplete) {\n showSuccess();\n growl.info(\"Successfully tested capabilites of indexer\");\n $scope.form.capsChecked = true;\n } else if (!data.indexerConfig.allCapsChecked && data.indexerConfig.configComplete) {\n showWarning();\n ModalService.open(\"Incomplete caps check\", \"The capabilities of the indexer could not be checked completely. You may use it but it's recommended to repeat the check at another time. Until then some search types or IDs may not be usable.\", {}, \"md\", \"left\");\n $scope.form.capsChecked = true;\n } else if (!data.configComplete) {\n showError();\n ModalService.open(\"Error testing capabilities\", \"An error occurred while contacting the indexer. It will not be usable until the caps check has been executed. You can trigger it manually from the indexer config box\", {}, \"md\", \"left\");\n }\n }, function (message) {\n angular.element(testMessage).text(message);\n showError();\n ModalService.open(\"Error testing capabilities\", \"An error occurred while contacting the indexer. It will not be usable until the caps check has been executed. You can trigger it manually from the indexer config box\", {}, \"md\", \"left\");\n }).finally(function () {\n angular.element(testButton).removeClass(\"glyphicon-refresh-animate\");\n });\n }\n }\n });\n\n formlyConfigProvider.setType({\n name: 'horizontalCheckCaps',\n extends: 'checkCaps',\n wrapper: ['settingWrapper', 'bootstrapHasError']\n });\n\n\n formlyConfigProvider.setType({\n name: 'horizontalApiKeyInput',\n extends: 'apiKeyInput',\n wrapper: ['settingWrapper', 'bootstrapHasError']\n });\n\n formlyConfigProvider.setType({\n name: 'horizontalPercentInput',\n extends: 'percentInput',\n wrapper: ['settingWrapper', 'bootstrapHasError']\n });\n\n\n formlyConfigProvider.setType({\n name: 'switch',\n template: ''\n });\n\n formlyConfigProvider.setType({\n name: 'indexerStateSwitch',\n template: ''\n });\n\n\n formlyConfigProvider.setType({\n name: 'horizontalIndexerStateSwitch',\n extends: 'indexerStateSwitch',\n wrapper: ['settingWrapper', 'bootstrapHasError']\n });\n\n\n formlyConfigProvider.setType({\n name: 'duoSetting',\n extends: 'input',\n defaultOptions: {\n className: 'col-md-9',\n templateOptions: {\n type: 'number',\n noRow: true,\n label: ''\n }\n }\n });\n\n formlyConfigProvider.setType({\n name: 'horizontalSwitch',\n extends: 'switch',\n wrapper: ['settingWrapper', 'bootstrapHasError']\n });\n\n formlyConfigProvider.setType({\n name: 'horizontalSelect',\n extends: 'select',\n wrapper: ['settingWrapper', 'bootstrapHasError'],\n controller: function ($scope) {\n if ($scope.options.templateOptions.optionsFunction !== undefined) {\n $scope.to.options.push.apply($scope.to.options, $scope.options.templateOptions.optionsFunction($scope.model));\n }\n if ($scope.options.templateOptions.optionsFunctionAfter !== undefined) {\n $scope.options.templateOptions.optionsFunctionAfter($scope.model);\n }\n }\n });\n\n\n formlyConfigProvider.setType({\n name: 'horizontalMultiselect',\n defaultOptions: {\n templateOptions: {\n optionsAttr: 'bs-options',\n ngOptions: 'option[to.valueProp] as option in to.options | filter: $select.search'\n }\n },\n template: '',\n controller: function ($scope) {\n var settings = $scope.to.settings || [];\n settings.classes = settings.classes || [];\n angular.extend(settings.classes, [\"form-control\"]);\n $scope.settings = settings;\n if ($scope.options.templateOptions.optionsFunction !== null && $scope.options.templateOptions.optionsFunction !== undefined) {\n $scope.to.options.push.apply($scope.to.options, $scope.options.templateOptions.optionsFunction($scope.model));\n }\n $scope.events = {\n onToggleItem: function (item, newValue) {\n $scope.form.$setDirty(true);\n }\n }\n },\n wrapper: ['settingWrapper', 'bootstrapHasError']\n });\n\n formlyConfigProvider.setType({\n name: 'label',\n template: ''\n });\n\n formlyConfigProvider.setType({\n name: 'duolabel',\n extends: 'label',\n defaultOptions: {\n className: 'col-md-2',\n templateOptions: {\n label: '-'\n }\n }\n });\n\n formlyConfigProvider.setType({\n name: 'repeatSection',\n templateUrl: 'repeatSection.html',\n controller: function ($scope) {\n $scope.formOptions = {formState: $scope.formState};\n $scope.addNew = addNew;\n $scope.remove = remove;\n $scope.copyFields = copyFields;\n\n function copyFields(fields) {\n fields = angular.copy(fields);\n $scope.repeatfields = fields;\n return fields;\n }\n\n $scope.clear = function (field) {\n return _.mapObject(field, function (key, val) {\n if (typeof val === 'object') {\n return $scope.clear(val);\n }\n return undefined;\n\n });\n };\n\n function addNew(preset) {\n console.log(preset);\n $scope.form.$setDirty(true);\n $scope.model[$scope.options.key] = $scope.model[$scope.options.key] || [];\n var repeatsection = $scope.model[$scope.options.key];\n var newsection = angular.copy($scope.options.templateOptions.defaultModel);\n Object.assign(newsection, preset);\n repeatsection.push(newsection);\n }\n\n function remove($index) {\n $scope.model[$scope.options.key].splice($index, 1);\n $scope.form.$setDirty(true);\n }\n }\n });\n\n formlyConfigProvider.setType({\n name: 'recheckAllCaps',\n templateUrl: 'static/html/config/recheck-all-caps.html',\n controller: function ($scope, $uibModal, growl, IndexerConfigBoxService) {\n $scope.recheck = function (checkType) {\n IndexerConfigBoxService.checkCaps({checkType: checkType}).then(function (listOfResults) {\n //A bit ugly, but we have to update the current model with the new data from the list\n for (var i = 0; i < $scope.model.length; i++) {\n for (var j = 0; j < listOfResults.length; j++) {\n if ($scope.model[i].name === listOfResults[j].indexerConfig.name) {\n updateIndexerModel($scope.model[i], listOfResults[j].indexerConfig);\n $scope.form.$setDirty(true);\n }\n }\n }\n });\n }\n }\n });\n\n\n formlyConfigProvider.setType({\n name: 'notificationSection',\n templateUrl: 'notificationRepeatSection.html',\n controller: function ($scope, NotificationService) {\n $scope.formOptions = {formState: $scope.formState};\n $scope.addNew = addNew;\n $scope.remove = remove;\n $scope.copyFields = copyFields;\n $scope.eventTypes = [];\n\n var allData = NotificationService.getAllData();\n _.each(_.keys(allData), function (key) {\n $scope.eventTypes.push({\"key\": key, \"label\": allData[key].readable})\n })\n\n function copyFields(fields) {\n fields = angular.copy(fields);\n $scope.repeatfields = fields;\n return fields;\n }\n\n $scope.clear = function (field) {\n return _.mapObject(field, function (key, val) {\n if (typeof val === 'object') {\n return $scope.clear(val);\n }\n return undefined;\n\n });\n };\n\n function addNew(eventType) {\n $scope.form.$setDirty(true);\n $scope.model[$scope.options.key] = $scope.model[$scope.options.key] || [];\n var repeatsection = $scope.model[$scope.options.key];\n var newsection = angular.copy($scope.options.templateOptions.defaultModel);\n\n var eventTypeData = NotificationService.getAllData()[eventType];\n console.log(eventTypeData);\n newsection.eventType = eventType;\n newsection.titleTemplate = eventTypeData.titleTemplate;\n newsection.bodyTemplate = eventTypeData.bodyTemplate;\n newsection.messageType = eventTypeData.messageType;\n\n repeatsection.push(newsection);\n }\n\n function remove($index) {\n $scope.model[$scope.options.key].splice($index, 1);\n $scope.form.$setDirty(true);\n }\n }\n });\n\n formlyConfigProvider.setType({\n //Button\n name: 'testNotification',\n templateUrl: 'button-test-notification.html',\n controller: function ($scope, NotificationService) {\n\n\n //When button is clicked\n $scope.testNotification = function () {\n NotificationService.testNotification($scope.model.eventType)\n }\n }\n });\n\n formlyConfigProvider.setType({\n name: 'horizontalTestNotification',\n extends: 'testNotification',\n wrapper: ['settingWrapper', 'bootstrapHasError']\n });\n\n\n }]);\n\n","\nConfigService.$inject = [\"$http\", \"$q\", \"$cacheFactory\", \"$uibModal\", \"bootstrapped\", \"RequestsErrorHandler\"];angular\n .module('nzbhydraApp')\n .factory('ConfigService', ConfigService);\n\nfunction ConfigService($http, $q, $cacheFactory, $uibModal, bootstrapped, RequestsErrorHandler) {\n\n ConfigureInModalInstanceCtrl.$inject = [\"$scope\", \"$uibModalInstance\", \"$http\", \"growl\", \"$interval\", \"RequestsErrorHandler\", \"localStorageService\", \"externalTool\", \"dialogInfo\"];\n var cache = $cacheFactory(\"nzbhydra\");\n var safeConfig = bootstrapped.safeConfig;\n\n return {\n set: set,\n get: get,\n getSafe: getSafe,\n invalidateSafe: invalidateSafe,\n maySeeAdminArea: maySeeAdminArea,\n reloadConfig: reloadConfig,\n apiHelp: apiHelp,\n configureIn: configureIn\n };\n\n function set(newConfig, ignoreWarnings) {\n var deferred = $q.defer();\n $http.put('internalapi/config', newConfig)\n .then(function (response) {\n if (response.data.ok && (ignoreWarnings || response.data.warningMessages.length === 0)) {\n cache.put(\"config\", newConfig);\n setTimeout(function () {\n invalidateSafe();\n }, 500)\n }\n deferred.resolve(response);\n\n }, function (errorresponse) {\n console.log(\"Error saving settings:\");\n console.log(errorresponse);\n deferred.reject(errorresponse);\n });\n return deferred.promise;\n }\n\n function reloadConfig() {\n return $http.get('internalapi/config/reload').then(function (response) {\n return response.data;\n });\n }\n\n function apiHelp() {\n return $http.get('internalapi/config/apiHelp').then(function (response) {\n return response.data;\n });\n }\n\n function get() {\n var config = cache.get(\"config\");\n if (angular.isUndefined(config)) {\n config = $http.get('internalapi/config').then(function (response) {\n return response.data;\n });\n cache.put(\"config\", config);\n }\n\n return config;\n }\n\n function getSafe() {\n return safeConfig;\n }\n\n function invalidateSafe() {\n RequestsErrorHandler.specificallyHandled(function () {\n $http.get('internalapi/config/safe').then(function (response) {\n safeConfig = response.data;\n });\n });\n\n }\n\n function maySeeAdminArea() {\n function loadAll() {\n var maySeeAdminArea = cache.get(\"maySeeAdminArea\");\n if (!angular.isUndefined(maySeeAdminArea)) {\n var deferred = $q.defer();\n deferred.resolve(maySeeAdminArea);\n return deferred.promise;\n }\n\n return $http.get('internalapi/mayseeadminarea')\n .then(function (configResponse) {\n var config = configResponse.data;\n cache.put(\"maySeeAdminArea\", config);\n return configResponse.data;\n });\n }\n\n return loadAll().then(function (maySeeAdminArea) {\n return maySeeAdminArea;\n });\n }\n\n function configureIn(externalTool) {\n $uibModal.open({\n templateUrl: 'static/html/configure-in-modal.html',\n controller: ConfigureInModalInstanceCtrl,\n size: \"md\",\n resolve: {\n externalTool: function () {\n return externalTool;\n },\n dialogInfo: function () {\n return $http.get(\"internalapi/externalTools/getDialogInfo\").then(function (response) {\n return response.data;\n })\n }\n }\n })\n }\n\n function ConfigureInModalInstanceCtrl($scope, $uibModalInstance, $http, growl, $interval, RequestsErrorHandler, localStorageService, externalTool, dialogInfo) {\n var lastConfig = localStorageService.get(externalTool);\n\n $scope.externalTool = externalTool;\n $scope.externalToolDisplayName = externalTool;\n $scope.externalToolsMessages = [];\n $scope.closeButtonType = \"warning\";\n $scope.completed = false;\n $scope.working = false;\n $scope.showMessages = false;\n\n $scope.nzbhydraHost = dialogInfo.nzbhydraHost;\n $scope.usenetIndexersConfigured = dialogInfo.usenetIndexersConfigured;\n $scope.prioritiesConfigured = dialogInfo.prioritiesConfigured;\n $scope.configureForUsenet = dialogInfo.usenetIndexersConfigured;\n $scope.torrentIndexersConfigured = dialogInfo.torrentIndexersConfigured;\n $scope.configureForTorrents = dialogInfo.torrentIndexersConfigured;\n $scope.addDisabledIndexers = false;\n\n if (!$scope.configureForUsenet && !$scope.configureForTorrents) {\n growl.error(\"No usenet or torrent indexers configured\");\n }\n\n\n $scope.nzbhydraName = \"NZBHydra2\";\n $scope.xdarrHost = \"http://localhost:\"\n $scope.addType = \"SINGLE\";\n $scope.enableRss = true;\n $scope.enableAutomaticSearch = true;\n $scope.enableInteractiveSearch = true;\n $scope.categories = null;\n $scope.animeCategories = null;\n $scope.priority = 0;\n $scope.useHydraPriorities = true;\n\n if (externalTool === \"Sonarr\" || externalTool === \"Sonarrv3\") {\n $scope.xdarrHost += \"8989\";\n $scope.categories = \"5030,5040\";\n if (externalTool === \"Sonarrv3\") {\n $scope.externalToolDisplayName = \"Sonarr v3+\";\n }\n } else if (externalTool === \"Radarr\" || externalTool === \"Radarrv3\") {\n $scope.xdarrHost += \"7878\";\n $scope.categories = \"2000\";\n if (externalTool === \"Radarrv3\") {\n $scope.externalToolDisplayName = \"Radarr v3+\";\n }\n } else if (externalTool === \"Lidarr\") {\n $scope.xdarrHost += \"8686\";\n $scope.categories = \"3000\";\n } else if (externalTool === \"Readarr\") {\n $scope.xdarrHost += \"8787\";\n $scope.categories = \"7020,8010\";\n }\n $scope.removeYearFromSearchString = false;\n\n if (lastConfig !== null && lastConfig !== undefined) {\n Object.assign($scope, lastConfig);\n }\n\n $scope.close = function () {\n $uibModalInstance.dismiss();\n };\n\n $scope.submit = function (deleteOnly) {\n if ($scope.completed && !deleteOnly) {\n $uibModalInstance.dismiss();\n }\n if (!$scope.usenetIndexersConfigured && !$scope.torrentIndexersConfigured && !deleteOnly) {\n growl.error(\"No usenet or torrent indexers configured\");\n return;\n }\n $scope.externalToolsMessages = [];\n $scope.spinnerActive = true;\n $scope.working = true;\n $scope.showMessages = true;\n var data = {\n\n nzbhydraName: $scope.nzbhydraName,\n externalTool: $scope.externalTool,\n nzbhydraHost: $scope.nzbhydraHost,\n addType: deleteOnly ? \"DELETE_ONLY\" : $scope.addType,\n xdarrHost: $scope.xdarrHost,\n xdarrApiKey: $scope.xdarrApiKey,\n enableRss: $scope.enableRss,\n enableAutomaticSearch: $scope.enableAutomaticSearch,\n enableInteractiveSearch: $scope.enableInteractiveSearch,\n categories: $scope.categories,\n animeCategories: $scope.animeCategories,\n removeYearFromSearchString: $scope.removeYearFromSearchString,\n earlyDownloadLimit: $scope.earlyDownloadLimit,\n multiLanguages: $scope.multiLanguages,\n configureForUsenet: $scope.configureForUsenet,\n configureForTorrents: $scope.configureForTorrents,\n additionalParameters: $scope.additionalParameters,\n minimumSeeders: $scope.minimumSeeders,\n seedRatio: $scope.seedRatio,\n seedTime: $scope.seedTime,\n seasonPackSeedTime: $scope.seasonPackSeedTime,\n discographySeedTime: $scope.discographySeedTime,\n addDisabledIndexers: $scope.addDisabledIndexers,\n priority: $scope.priority,\n useHydraPriorities: $scope.useHydraPriorities\n }\n\n localStorageService.set(externalTool, data);\n\n function updateMessages() {\n $http.get(\"internalapi/externalTools/messages\").then(function (response) {\n $scope.externalToolsMessages = response.data;\n });\n }\n\n var updateInterval = $interval(function () {\n updateMessages();\n }, 500);\n\n RequestsErrorHandler.specificallyHandled(function () {\n $scope.completed = false;\n $http.post(\"internalapi/externalTools/configure\", data).then(function (response) {\n updateMessages();\n $interval.cancel(updateInterval);\n $scope.spinnerActive = false;\n console.log(response);\n if (response.data) {\n $scope.completed = true;\n $scope.closeButtonType = \"success\";\n } else {\n $scope.working = false;\n $scope.completed = false;\n }\n }, function (error) {\n updateMessages();\n console.error(error.data);\n $interval.cancel(updateInterval);\n $scope.completed = false;\n $scope.spinnerActive = false;\n $scope.working = false;\n });\n });\n };\n\n }\n}\n","/*\n * (C) Copyright 2017 TheOtherP (theotherp@posteo.net)\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nConfigFields.$inject = [\"$injector\"];\nangular\n .module('nzbhydraApp')\n .factory('ConfigFields', ConfigFields);\n\nfunction ConfigFields($injector) {\n return {\n getFields: getFields\n };\n\n function ipValidator() {\n return {\n expression: function ($viewValue, $modelValue) {\n var value = $modelValue || $viewValue;\n if (value) {\n return /^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$/.test(value)\n || /^(?:[0-9]{1,3}\\.){3}[0-9]{1,3}$/.test(value);\n }\n return true;\n },\n message: '$viewValue + \" is not a valid IP Address\"'\n };\n }\n\n function regexValidator(regex, message, prefixViewValue, preventEmpty) {\n return {\n expression: function ($viewValue, $modelValue) {\n var value = $modelValue || $viewValue;\n if (value) {\n if (Array.isArray(value)) {\n for (var i = 0; i < value.length; i++) {\n if (!regex.test(value[i])) {\n return false;\n }\n }\n return true;\n } else {\n return regex.test(value);\n }\n }\n return !preventEmpty;\n },\n message: (prefixViewValue ? '$viewValue + \" ' : '\" ') + message + '\"'\n };\n }\n\n function getFields(rootModel, showAdvanced) {\n return {\n main: [\n {\n wrapper: 'fieldset',\n templateOptions: {label: 'Hosting'},\n fieldGroup: [\n {\n key: 'host',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n label: 'Host',\n required: true,\n placeholder: 'IPv4 address to bind to',\n help: 'I strongly recommend using a reverse proxy instead of exposing this directly. Requires restart.'\n },\n validators: {\n ipAddress: ipValidator()\n }\n },\n {\n key: 'port',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'Port',\n required: true,\n placeholder: '5076',\n help: 'Requires restart.'\n },\n validators: {\n port: regexValidator(/^\\d{1,5}$/, \"is no valid port\", true)\n }\n },\n {\n key: 'urlBase',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n label: 'URL base',\n placeholder: '/nzbhydra',\n help: 'Adapt when using a reverse proxy. See wiki. Always use when calling Hydra, even locally.',\n tooltip: 'If you use Hydra behind a reverse proxy you might want to set the URL base to a value like \"/nzbhydra\". If you accesses Hydra with tools running outside your network (for example from your phone) set the external URL so that it matches the full Hydra URL. That way the NZB links returned in the search results refer to your global URL and not your local address.',\n advanced: true\n },\n validators: {\n urlBase: regexValidator(/^((\\/.*[^\\/])|\\/)$/, 'URL base has to start and may not end with /', false, true)\n }\n\n },\n {\n key: 'ssl',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Use SSL',\n help: 'Requires restart.',\n tooltip: 'You can use SSL but I recommend using a reverse proxy with SSL. See the wiki for notes regarding reverse proxies and SSL. It\\'s more secure and can be configured better.',\n advanced: true\n }\n },\n {\n key: 'sslKeyStore',\n hideExpression: '!model.ssl',\n type: 'fileInput',\n templateOptions: {\n label: 'SSL keystore file',\n required: true,\n type: \"file\",\n help: 'Requires restart. See wiki.'\n }\n },\n {\n key: 'sslKeyStorePassword',\n hideExpression: '!model.ssl',\n type: 'horizontalInput',\n templateOptions: {\n type: 'password',\n label: 'SSL keystore password',\n required: true,\n help: 'Requires restart.'\n }\n }\n\n\n ]\n },\n {\n wrapper: 'fieldset',\n templateOptions: {\n label: 'Proxy',\n tooltip: 'You can select to use either a SOCKS or an HTTPS proxy. All outside connections will be done via the configured proxy.',\n advanced: true\n }\n ,\n fieldGroup: [\n {\n key: 'proxyType',\n type: 'horizontalSelect',\n templateOptions: {\n type: 'select',\n label: 'Use proxy',\n options: [\n {name: 'None', value: 'NONE'},\n {name: 'SOCKS', value: 'SOCKS'},\n {name: 'HTTP(S)', value: 'HTTP'}\n ]\n }\n },\n {\n key: 'proxyHost',\n type: 'horizontalInput',\n hideExpression: 'model.proxyType===\"NONE\"',\n templateOptions: {\n type: 'text',\n label: 'SOCKS proxy host',\n placeholder: 'Set to use a SOCKS proxy',\n help: \"IPv4 only\"\n }\n },\n {\n key: 'proxyPort',\n type: 'horizontalInput',\n hideExpression: 'model.proxyType===\"NONE\"',\n templateOptions: {\n type: 'number',\n label: 'Proxy port',\n placeholder: '1080'\n }\n },\n {\n key: 'proxyUsername',\n type: 'horizontalInput',\n hideExpression: 'model.proxyType===\"NONE\"',\n templateOptions: {\n type: 'text',\n label: 'Proxy username'\n }\n },\n {\n key: 'proxyPassword',\n type: 'passwordSwitch',\n hideExpression: 'model.proxyType===\"NONE\"',\n templateOptions: {\n type: 'text',\n label: 'Proxy password'\n }\n },\n {\n key: 'proxyIgnoreLocal',\n type: 'horizontalSwitch',\n hideExpression: 'model.proxyType===\"NONE\"',\n templateOptions: {\n type: 'switch',\n label: 'Bypass local network addresses'\n }\n },\n {\n key: 'proxyIgnoreDomains',\n type: 'horizontalChips',\n hideExpression: 'model.proxyType===\"NONE\"',\n templateOptions: {\n type: 'text',\n help: 'Separate by comma. You can use wildcards (*). Case insensitive. Apply values with enter key.',\n label: 'Bypass domains'\n }\n }\n ]\n },\n {\n wrapper: 'fieldset',\n templateOptions: {label: 'UI'},\n fieldGroup: [\n\n {\n key: 'theme',\n type: 'horizontalSelect',\n templateOptions: {\n type: 'select',\n label: 'Theme',\n options: [\n {name: 'Auto', value: 'auto'},\n {name: 'Grey', value: 'grey'},\n {name: 'Bright', value: 'bright'},\n {name: 'Dark', value: 'dark'}\n ]\n }\n }\n ]\n },\n {\n wrapper: 'fieldset',\n templateOptions: {label: 'Security'},\n fieldGroup: [\n {\n key: 'apiKey',\n type: 'horizontalApiKeyInput',\n templateOptions: {\n label: 'API key',\n help: 'Alphanumeric only.',\n required: true\n },\n validators: {\n apiKey: regexValidator(/^[a-zA-Z0-9]*$/, \"API key must only contain numbers and digits\", false)\n }\n },\n {\n key: 'dereferer',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n label: 'Dereferer',\n help: 'Redirect external links to hide your instance. Insert $s for escaped target URL and $us for unescaped target URL. Use empty value to disable.',\n advanced: true\n }\n },\n {\n key: 'verifySsl',\n type: 'horizontalSwitch',\n templateOptions: {\n label: 'Verify SSL certificates',\n help: 'If enabled only valid/known SSL certificates will be accepted when accessing indexers. Change requires restart. See wiki.',\n advanced: true\n }\n },\n {\n key: 'verifySslDisabledFor',\n type: 'horizontalChips',\n templateOptions: {\n type: 'text',\n label: 'Disable SSL for...',\n help: 'Add hosts for which to disable SSL verification. Apply words with return key.',\n advanced: true\n }\n },\n {\n key: 'disableSslLocally',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'text',\n label: 'Disable SSL locally',\n help: 'Disable SSL for local hosts.',\n advanced: true\n }\n },\n {\n key: 'sniDisabledFor',\n type: 'horizontalChips',\n templateOptions: {\n type: 'text',\n label: 'Disable SNI',\n help: 'Add a host if you get an \"unrecognized_name\" error. Apply words with return key. See wiki.',\n advanced: true\n }\n },\n {\n key: 'useCsrf',\n type: 'horizontalSwitch',\n templateOptions: {\n label: 'Use CSRF protection',\n help: 'Use CSRF protection.',\n advanced: true\n }\n }\n ]\n },\n\n {\n wrapper: 'fieldset',\n key: 'logging',\n templateOptions: {\n label: 'Logging',\n tooltip: 'The base settings should suffice for most users. If you want you can enable logging of IP adresses for failed logins and NZB downloads.',\n advanced: true\n },\n fieldGroup: [\n {\n key: 'logfilelevel',\n type: 'horizontalSelect',\n templateOptions: {\n type: 'select',\n label: 'Logfile level',\n options: [\n {name: 'Error', value: 'ERROR'},\n {name: 'Warning', value: 'WARN'},\n {name: 'Info', value: 'INFO'},\n {name: 'Debug', value: 'DEBUG'}\n ],\n help: 'Takes effect on next restart.'\n }\n },\n {\n key: 'logMaxHistory',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'Max log history',\n help: 'How many daily log files will be kept.'\n }\n },\n {\n key: 'consolelevel',\n type: 'horizontalSelect',\n templateOptions: {\n type: 'select',\n label: 'Console log level',\n options: [\n {name: 'Error', value: 'ERROR'},\n {name: 'Warning', value: 'WARN'},\n {name: 'Info', value: 'INFO'},\n {name: 'Debug', value: 'DEBUG'}\n ],\n help: 'Takes effect on next restart.'\n }\n },\n {\n key: 'logGc',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Log GC',\n help: 'Enable garbage collection logging. Only for debugging of memory issues.'\n }\n },\n {\n key: 'logIpAddresses',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Log IP addresses'\n }\n },\n {\n key: 'mapIpToHost',\n type: 'horizontalSwitch',\n hideExpression: '!model.logIpAddresses',\n templateOptions: {\n type: 'switch',\n label: 'Map hosts',\n help: 'Try to map logged IP addresses to host names.',\n tooltip: 'Enabling this may cause NZBHydra to load very, very slowly when accessed remotely.'\n }\n },\n {\n key: 'logUsername',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Log user names'\n }\n },\n {\n key: 'markersToLog',\n type: 'horizontalMultiselect',\n hideExpression: 'model.consolelevel !== \"DEBUG\" && model.logfilelevel !== \"DEBUG\"',\n templateOptions: {\n label: 'Log markers',\n help: 'Select certain sections for more output on debug level. Please enable only when asked for.',\n options: [\n {label: 'API limits', id: 'LIMITS'},\n {label: 'Category mapping', id: 'CATEGORY_MAPPING'},\n {label: 'Config file handling', id: 'CONFIG_READ_WRITE'},\n {label: 'Custom mapping', id: 'CUSTOM_MAPPING'},\n {label: 'Downloader status updating', id: 'DOWNLOADER_STATUS_UPDATE'},\n {label: 'Duplicate detection', id: 'DUPLICATES'},\n {label: 'External tool configuration', id: 'EXTERNAL_TOOLS'},\n {label: 'History cleanup', id: 'HISTORY_CLEANUP'},\n {label: 'HTTP', id: 'HTTP'},\n {label: 'HTTPS', id: 'HTTPS'},\n {label: 'HTTP Server', id: 'SERVER'},\n {label: 'Indexer scheduler', id: 'SCHEDULER'},\n {label: 'Notifications', id: 'NOTIFICATIONS'},\n {label: 'NZB download status updating', id: 'DOWNLOAD_STATUS_UPDATE'},\n {label: 'Performance', id: 'PERFORMANCE'},\n {label: 'Rejected results', id: 'RESULT_ACCEPTOR'},\n {label: 'Removed trailing words', id: 'TRAILING'},\n {label: 'URL calculation', id: 'URL_CALCULATION'},\n {label: 'User agent mapping', id: 'USER_AGENT'},\n {label: 'VIP expiry', id: 'VIP_EXPIRY'}\n ],\n buttonText: \"None\"\n }\n },\n {\n key: 'historyUserInfoType',\n type: 'horizontalSelect',\n templateOptions: {\n type: 'select',\n label: 'History user info',\n options: [\n {name: 'IP and username', value: 'BOTH'},\n {name: 'IP address', value: 'IP'},\n {name: 'Username', value: 'USERNAME'},\n {name: 'None', value: 'NONE'}\n ],\n help: 'Only affects if value is displayed in the search/download history.',\n hideExpression: '!model.keepHistory'\n }\n }\n ]\n },\n {\n wrapper: 'fieldset',\n templateOptions: {\n label: 'Backup',\n advanced: true\n },\n fieldGroup: [\n {\n key: 'backupFolder',\n type: 'horizontalInput',\n templateOptions: {\n label: 'Backup folder',\n help: 'Either relative to the NZBHydra data folder or an absolute folder.'\n }\n },\n {\n key: 'backupEveryXDays',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'Backup every...',\n addonRight: {\n text: 'days'\n }\n }\n },\n {\n key: 'backupBeforeUpdate',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Backup before update'\n }\n }\n ]\n },\n {\n wrapper: 'fieldset',\n templateOptions: {label: 'Updates'},\n fieldGroup: [\n {\n key: 'updateAutomatically',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Install updates automatically'\n }\n }, {\n key: 'updateToPrereleases',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Install prereleases',\n advanced: true\n }\n },\n {\n key: 'deleteBackupsAfterWeeks',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'Delete backups after...',\n addonRight: {\n text: 'weeks'\n },\n advanced: true\n }\n },\n {\n key: 'showUpdateBannerOnDocker',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Show update banner when managed externally',\n advanced: true,\n help: 'If enabled a banner will be shown when new versions are available even when NZBHydra is run inside docker or is installed using a package manager (where you wouldn\\'t let NZBHydra update itself).'\n }\n },\n {\n key: 'showWhatsNewBanner',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Show info banner after automatic updates',\n help: 'Please keep it enabled, I put some effort into the changelog ;-)',\n advanced: true\n }\n }\n ]\n },\n {\n wrapper: 'fieldset',\n templateOptions: {\n label: 'History',\n advanced: true\n },\n fieldGroup: [\n {\n key: 'keepHistory',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Keep history',\n help: 'Controls search and download history.',\n tooltip: 'If disabled no search or download history will be kept. These sections will be hidden in the GUI. You won\\'t be able to see stats. The database will still contain a short-lived history of transactions that are kept for 24 hours.'\n }\n },\n {\n key: 'keepHistoryForWeeks',\n type: 'horizontalInput',\n hideExpression: '!model.keepHistory',\n templateOptions: {\n type: 'number',\n label: 'Keep history for...',\n addonRight: {\n text: 'weeks'\n },\n min: 1,\n help: 'Only keep history (searches, downloads) for a certain time. Will decrease database size and may improve performance a bit. Rather reduce how long stats are kept.'\n }\n },\n {\n key: 'keepStatsForWeeks',\n type: 'horizontalInput',\n hideExpression: '!model.keepHistory',\n templateOptions: {\n type: 'number',\n label: 'Keep stats for...',\n addonRight: {\n text: 'weeks'\n },\n min: 1,\n help: 'Only keep stats for a certain time. Will decrease database size.'\n }\n }\n ]\n },\n {\n wrapper: 'fieldset',\n templateOptions: {\n label: 'Database',\n tooltip: 'You should not change these values unless you\\'re either told to or really know what you\\'re doing.',\n advanced: true\n },\n fieldGroup: [\n {\n key: 'databaseCompactTime',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'Database compact time',\n addonRight: {\n text: 'ms'\n },\n min: 200,\n help: 'The time the database is given to compact (reduce size) when shutting down. Reduce this if shutting down NZBHydra takes too long (database size may increase). Takes effect on next restart.'\n }\n },\n {\n key: 'databaseRetentionTime',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'Database retention time',\n addonRight: {\n text: 'ms'\n },\n help: 'How long the db should retain old, persisted data. See here.'\n }\n },\n {\n key: 'databaseWriteDelay',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'Database write delay',\n addonRight: {\n text: 'ms'\n },\n help: 'Maximum delay between a commit and flushing the log, in milliseconds. See here.'\n }\n }\n\n ]\n },\n {\n wrapper: 'fieldset',\n templateOptions: {label: 'Other'},\n fieldGroup: [\n {\n key: 'startupBrowser',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Open browser on startup'\n }\n },\n {\n key: 'showNews',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Show news',\n help: \"Hydra will occasionally show news when opened. You can always find them in the system section\",\n advanced: true\n }\n },\n {\n key: 'checkOpenPort',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Check for open port',\n help: \"Check if NZBHydra is reachable from the internet and not protected\",\n advanced: true\n }\n },\n {\n key: 'xmx',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'JVM memory',\n addonRight: {\n text: 'MB'\n },\n min: 128,\n help: '256 should suffice except when working with big databases / many indexers. See wiki.',\n advanced: true\n }\n }\n ]\n\n }\n ],\n\n searching: [\n {\n wrapper: 'fieldset',\n templateOptions: {\n label: 'Indexer access',\n tooltip: 'Settings that control how communication with indexers is done and how to handle errors while doing that.',\n advanced: true\n },\n fieldGroup: [\n {\n key: 'timeout',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'Timeout when accessing indexers',\n help: 'Any web call to an indexer taking longer than this is aborted.',\n min: 1,\n addonRight: {\n text: 'seconds'\n }\n }\n },\n {\n key: 'userAgent',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n label: 'User agent',\n help: 'Used when accessing indexers.',\n required: true,\n tooltip: 'Some indexers don\\'t seem to like Hydra and disable access based on the user agent. You can change it here if you want. Please leave it as it is if you have no problems. This allows indexers to gather better statistics on how their API services are used.',\n }\n },\n {\n key: 'userAgents',\n type: 'horizontalChips',\n templateOptions: {\n type: 'text',\n label: 'Map user agents',\n help: 'Used to map the user agent from accessing services to the service names. Apply words with return key.',\n }\n },\n {\n key: 'ignoreLoadLimitingForInternalSearches',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Ignore load limiting internally',\n help: 'When enabled load limiting defined for indexers will be ignored for internal searches.',\n }\n },\n {\n key: 'ignoreTemporarilyDisabled',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Ignore temporary errors',\n tooltip: \"By default if access to an indexer fails the indexer is disabled for a certain amount of time (for a short while first, then increasingly longer if the problems persist). Disable this and always try these indexers.\",\n }\n }\n ]\n }, {\n wrapper: 'fieldset',\n templateOptions: {\n label: 'Category handling',\n tooltip: 'Settings that control the handling of newznab categories (e.g. 2000 for Movies).',\n advanced: true\n },\n fieldGroup: [\n\n {\n key: 'transformNewznabCategories',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Transform newznab categories',\n help: 'Map newznab categories from API searches to configured categories and use all configured newznab categories in searches.'\n }\n },\n {\n key: 'sendTorznabCategories',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Send categories to trackers',\n help: 'If disabled no categories will be included in queries to torznab indexers (trackers).'\n }\n }\n ]\n },\n {\n wrapper: 'fieldset',\n templateOptions: {\n label: 'Media IDs / Query generation / Query processing',\n tooltip: 'Raw search engines like Binsearch don\\'t support searches based on IDs (e.g. for a movie using an IMDB id). You can enable query generation for these. Hydra will then try to retrieve the movie\\'s or show\\'s title and generate a query, for example \"showname s01e01\". In some cases an ID based search will not provide any results. You can enable a fallback so that in such a case the search will be repeated with a query using the title of the show or movie.'\n },\n fieldGroup: [\n {\n key: 'alwaysConvertIds',\n type: 'horizontalSelect',\n templateOptions: {\n label: 'Convert media IDs for...',\n options: [\n {name: 'Internal searches', value: 'INTERNAL'},\n {name: 'API searches', value: 'API'},\n {name: 'All searches', value: 'BOTH'},\n {name: 'Never', value: 'NONE'}\n ],\n help: \"When enabled media ID conversions will always be done even when an indexer supports the already known ID(s).\",\n advanced: true\n }\n },\n {\n key: 'generateQueries',\n type: 'horizontalSelect',\n templateOptions: {\n label: 'Generate queries',\n options: [\n {name: 'Internal searches', value: 'INTERNAL'},\n {name: 'API searches', value: 'API'},\n {name: 'All searches', value: 'BOTH'},\n {name: 'Never', value: 'NONE'}\n ],\n help: \"Generate queries for indexers which do not support ID based searches.\"\n }\n },\n {\n key: 'idFallbackToQueryGeneration',\n type: 'horizontalSelect',\n templateOptions: {\n label: 'Fallback to generated queries',\n options: [\n {name: 'Internal searches', value: 'INTERNAL'},\n {name: 'API searches', value: 'API'},\n {name: 'All searches', value: 'BOTH'},\n {name: 'Never', value: 'NONE'}\n ],\n help: \"When no results were found for a query ID search again using a generated query (on indexer level).\"\n }\n },\n {\n key: 'language',\n type: 'horizontalSelect',\n templateOptions: {\n type: 'text',\n label: 'Language',\n required: true,\n help: 'Used for movie query generation and autocomplete only.',\n options: [{\"name\": \"Abkhaz\", value: \"ab\"}, {\n \"name\": \"Afar\",\n value: \"aa\"\n }, {\"name\": \"Afrikaans\", value: \"af\"}, {\"name\": \"Akan\", value: \"ak\"}, {\n \"name\": \"Albanian\",\n value: \"sq\"\n }, {\"name\": \"Amharic\", value: \"am\"}, {\n \"name\": \"Arabic\",\n value: \"ar\"\n }, {\"name\": \"Aragonese\", value: \"an\"}, {\"name\": \"Armenian\", value: \"hy\"}, {\n \"name\": \"Assamese\",\n value: \"as\"\n }, {\"name\": \"Avaric\", value: \"av\"}, {\"name\": \"Avestan\", value: \"ae\"}, {\n \"name\": \"Aymara\",\n value: \"ay\"\n }, {\"name\": \"Azerbaijani\", value: \"az\"}, {\n \"name\": \"Bambara\",\n value: \"bm\"\n }, {\"name\": \"Bashkir\", value: \"ba\"}, {\n \"name\": \"Basque\",\n value: \"eu\"\n }, {\"name\": \"Belarusian\", value: \"be\"}, {\"name\": \"Bengali\", value: \"bn\"}, {\n \"name\": \"Bihari\",\n value: \"bh\"\n }, {\"name\": \"Bislama\", value: \"bi\"}, {\n \"name\": \"Bosnian\",\n value: \"bs\"\n }, {\"name\": \"Breton\", value: \"br\"}, {\"name\": \"Bulgarian\", value: \"bg\"}, {\n \"name\": \"Burmese\",\n value: \"my\"\n }, {\"name\": \"Catalan\", value: \"ca\"}, {\n \"name\": \"Chamorro\",\n value: \"ch\"\n }, {\"name\": \"Chechen\", value: \"ce\"}, {\"name\": \"Chichewa\", value: \"ny\"}, {\n \"name\": \"Chinese\",\n value: \"zh\"\n }, {\"name\": \"Chuvash\", value: \"cv\"}, {\n \"name\": \"Cornish\",\n value: \"kw\"\n }, {\"name\": \"Corsican\", value: \"co\"}, {\"name\": \"Cree\", value: \"cr\"}, {\n \"name\": \"Croatian\",\n value: \"hr\"\n }, {\"name\": \"Czech\", value: \"cs\"}, {\"name\": \"Danish\", value: \"da\"}, {\n \"name\": \"Divehi\",\n value: \"dv\"\n }, {\"name\": \"Dutch\", value: \"nl\"}, {\n \"name\": \"Dzongkha\",\n value: \"dz\"\n }, {\"name\": \"English\", value: \"en\"}, {\n \"name\": \"Esperanto\",\n value: \"eo\"\n }, {\"name\": \"Estonian\", value: \"et\"}, {\"name\": \"Ewe\", value: \"ee\"}, {\n \"name\": \"Faroese\",\n value: \"fo\"\n }, {\"name\": \"Fijian\", value: \"fj\"}, {\"name\": \"Finnish\", value: \"fi\"}, {\n \"name\": \"French\",\n value: \"fr\"\n }, {\"name\": \"Fula\", value: \"ff\"}, {\n \"name\": \"Galician\",\n value: \"gl\"\n }, {\"name\": \"Georgian\", value: \"ka\"}, {\"name\": \"German\", value: \"de\"}, {\n \"name\": \"Greek\",\n value: \"el\"\n }, {\"name\": \"Guaraní\", value: \"gn\"}, {\n \"name\": \"Gujarati\",\n value: \"gu\"\n }, {\"name\": \"Haitian\", value: \"ht\"}, {\"name\": \"Hausa\", value: \"ha\"}, {\n \"name\": \"Hebrew\",\n value: \"he\"\n }, {\"name\": \"Herero\", value: \"hz\"}, {\n \"name\": \"Hindi\",\n value: \"hi\"\n }, {\"name\": \"Hiri Motu\", value: \"ho\"}, {\n \"name\": \"Hungarian\",\n value: \"hu\"\n }, {\"name\": \"Interlingua\", value: \"ia\"}, {\n \"name\": \"Indonesian\",\n value: \"id\"\n }, {\"name\": \"Interlingue\", value: \"ie\"}, {\n \"name\": \"Irish\",\n value: \"ga\"\n }, {\"name\": \"Igbo\", value: \"ig\"}, {\"name\": \"Inupiaq\", value: \"ik\"}, {\n \"name\": \"Ido\",\n value: \"io\"\n }, {\"name\": \"Icelandic\", value: \"is\"}, {\n \"name\": \"Italian\",\n value: \"it\"\n }, {\"name\": \"Inuktitut\", value: \"iu\"}, {\"name\": \"Japanese\", value: \"ja\"}, {\n \"name\": \"Javanese\",\n value: \"jv\"\n }, {\"name\": \"Kalaallisut\", value: \"kl\"}, {\n \"name\": \"Kannada\",\n value: \"kn\"\n }, {\"name\": \"Kanuri\", value: \"kr\"}, {\"name\": \"Kashmiri\", value: \"ks\"}, {\n \"name\": \"Kazakh\",\n value: \"kk\"\n }, {\"name\": \"Khmer\", value: \"km\"}, {\n \"name\": \"Kikuyu\",\n value: \"ki\"\n }, {\"name\": \"Kinyarwanda\", value: \"rw\"}, {\"name\": \"Kyrgyz\", value: \"ky\"}, {\n \"name\": \"Komi\",\n value: \"kv\"\n }, {\"name\": \"Kongo\", value: \"kg\"}, {\"name\": \"Korean\", value: \"ko\"}, {\n \"name\": \"Kurdish\",\n value: \"ku\"\n }, {\"name\": \"Kwanyama\", value: \"kj\"}, {\n \"name\": \"Latin\",\n value: \"la\"\n }, {\"name\": \"Luxembourgish\", value: \"lb\"}, {\n \"name\": \"Ganda\",\n value: \"lg\"\n }, {\"name\": \"Limburgish\", value: \"li\"}, {\"name\": \"Lingala\", value: \"ln\"}, {\n \"name\": \"Lao\",\n value: \"lo\"\n }, {\"name\": \"Lithuanian\", value: \"lt\"}, {\n \"name\": \"Luba-Katanga\",\n value: \"lu\"\n }, {\"name\": \"Latvian\", value: \"lv\"}, {\"name\": \"Manx\", value: \"gv\"}, {\n \"name\": \"Macedonian\",\n value: \"mk\"\n }, {\"name\": \"Malagasy\", value: \"mg\"}, {\n \"name\": \"Malay\",\n value: \"ms\"\n }, {\"name\": \"Malayalam\", value: \"ml\"}, {\"name\": \"Maltese\", value: \"mt\"}, {\n \"name\": \"Māori\",\n value: \"mi\"\n }, {\"name\": \"Marathi\", value: \"mr\"}, {\n \"name\": \"Marshallese\",\n value: \"mh\"\n }, {\"name\": \"Mongolian\", value: \"mn\"}, {\"name\": \"Nauru\", value: \"na\"}, {\n \"name\": \"Navajo\",\n value: \"nv\"\n }, {\"name\": \"Northern Ndebele\", value: \"nd\"}, {\n \"name\": \"Nepali\",\n value: \"ne\"\n }, {\"name\": \"Ndonga\", value: \"ng\"}, {\n \"name\": \"Norwegian Bokmål\",\n value: \"nb\"\n }, {\"name\": \"Norwegian Nynorsk\", value: \"nn\"}, {\n \"name\": \"Norwegian\",\n value: \"no\"\n }, {\"name\": \"Nuosu\", value: \"ii\"}, {\n \"name\": \"Southern Ndebele\",\n value: \"nr\"\n }, {\"name\": \"Occitan\", value: \"oc\"}, {\n \"name\": \"Ojibwe\",\n value: \"oj\"\n }, {\"name\": \"Old Church Slavonic\", value: \"cu\"}, {\"name\": \"Oromo\", value: \"om\"}, {\n \"name\": \"Oriya\",\n value: \"or\"\n }, {\"name\": \"Ossetian\", value: \"os\"}, {\"name\": \"Panjabi\", value: \"pa\"}, {\n \"name\": \"Pāli\",\n value: \"pi\"\n }, {\"name\": \"Persian\", value: \"fa\"}, {\n \"name\": \"Polish\",\n value: \"pl\"\n }, {\"name\": \"Pashto\", value: \"ps\"}, {\n \"name\": \"Portuguese\",\n value: \"pt\"\n }, {\"name\": \"Quechua\", value: \"qu\"}, {\"name\": \"Romansh\", value: \"rm\"}, {\n \"name\": \"Kirundi\",\n value: \"rn\"\n }, {\"name\": \"Romanian\", value: \"ro\"}, {\n \"name\": \"Russian\",\n value: \"ru\"\n }, {\"name\": \"Sanskrit\", value: \"sa\"}, {\"name\": \"Sardinian\", value: \"sc\"}, {\n \"name\": \"Sindhi\",\n value: \"sd\"\n }, {\"name\": \"Northern Sami\", value: \"se\"}, {\n \"name\": \"Samoan\",\n value: \"sm\"\n }, {\"name\": \"Sango\", value: \"sg\"}, {\"name\": \"Serbian\", value: \"sr\"}, {\n \"name\": \"Gaelic\",\n value: \"gd\"\n }, {\"name\": \"Shona\", value: \"sn\"}, {\"name\": \"Sinhala\", value: \"si\"}, {\n \"name\": \"Slovak\",\n value: \"sk\"\n }, {\"name\": \"Slovene\", value: \"sl\"}, {\n \"name\": \"Somali\",\n value: \"so\"\n }, {\"name\": \"Southern Sotho\", value: \"st\"}, {\n \"name\": \"Spanish\",\n value: \"es\"\n }, {\"name\": \"Sundanese\", value: \"su\"}, {\"name\": \"Swahili\", value: \"sw\"}, {\n \"name\": \"Swati\",\n value: \"ss\"\n }, {\"name\": \"Swedish\", value: \"sv\"}, {\"name\": \"Tamil\", value: \"ta\"}, {\n \"name\": \"Telugu\",\n value: \"te\"\n }, {\"name\": \"Tajik\", value: \"tg\"}, {\n \"name\": \"Thai\",\n value: \"th\"\n }, {\"name\": \"Tigrinya\", value: \"ti\"}, {\n \"name\": \"Tibetan Standard\",\n value: \"bo\"\n }, {\"name\": \"Turkmen\", value: \"tk\"}, {\"name\": \"Tagalog\", value: \"tl\"}, {\n \"name\": \"Tswana\",\n value: \"tn\"\n }, {\"name\": \"Tonga\", value: \"to\"}, {\"name\": \"Turkish\", value: \"tr\"}, {\n \"name\": \"Tsonga\",\n value: \"ts\"\n }, {\"name\": \"Tatar\", value: \"tt\"}, {\n \"name\": \"Twi\",\n value: \"tw\"\n }, {\"name\": \"Tahitian\", value: \"ty\"}, {\n \"name\": \"Uyghur\",\n value: \"ug\"\n }, {\"name\": \"Ukrainian\", value: \"uk\"}, {\"name\": \"Urdu\", value: \"ur\"}, {\n \"name\": \"Uzbek\",\n value: \"uz\"\n }, {\"name\": \"Venda\", value: \"ve\"}, {\n \"name\": \"Vietnamese\",\n value: \"vi\"\n }, {\"name\": \"Volapük\", value: \"vo\"}, {\"name\": \"Walloon\", value: \"wa\"}, {\n \"name\": \"Welsh\",\n value: \"cy\"\n }, {\"name\": \"Wolof\", value: \"wo\"}, {\n \"name\": \"Western Frisian\",\n value: \"fy\"\n }, {\"name\": \"Xhosa\", value: \"xh\"}, {\"name\": \"Yiddish\", value: \"yi\"}, {\n \"name\": \"Yoruba\",\n value: \"yo\"\n }, {\"name\": \"Zhuang\", value: \"za\"}, {\"name\": \"Zulu\", value: \"zu\"}]\n }\n },\n {\n key: 'replaceUmlauts',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Replace umlauts',\n help: 'Replace german umlauts and special characters (ä, ö, ü and ß) in external request queries.'\n }\n }\n ]\n },\n {\n wrapper: 'fieldset',\n templateOptions: {\n label: 'Result filters',\n tooltip: 'This section allows you to define global filters which will be applied to all search results. You can define words and regexes which must or must not be matched for a search result to be matched. You can also exclude certain usenet posters and groups which are known for spamming. You can define forbidden and required words for categories in the next tab (Categories). Usually required or forbidden words are applied on a word base, so they must form a complete word in a title. Only if they contain a dash or a dot they may appear anywhere in the title. Example: \"ea\" matches \"something.from.ea\" but not \"release.from.other\". \"web-dl\" matches \"title.web-dl\" and \"someweb-dl\".'\n },\n fieldGroup: [\n {\n key: 'applyRestrictions',\n type: 'horizontalSelect',\n templateOptions: {\n label: 'Apply word filters',\n options: [\n {name: 'All searches', value: 'BOTH'},\n {name: 'Internal searches', value: 'INTERNAL'},\n {name: 'API searches', value: 'API'},\n {name: 'Never', value: 'NONE'}\n ],\n help: \"For which type of search word/regex filters will be applied\"\n }\n },\n {\n key: 'forbiddenWords',\n type: 'horizontalChips',\n templateOptions: {\n type: 'text',\n label: 'Forbidden words',\n help: \"Results with any of these words in the title will be ignored. Title is converted to lowercase before. Apply words with return key.\",\n tooltip: 'One forbidden word in a result title dismisses the result.'\n },\n hideExpression: function () {\n return rootModel.searching.applyRestrictions === \"NONE\";\n }\n },\n {\n key: 'forbiddenRegex',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n label: 'Forbidden regex',\n help: 'Must not be present in a title (case is ignored).',\n advanced: true\n },\n hideExpression: function () {\n return rootModel.searching.applyRestrictions === \"NONE\";\n }\n },\n {\n key: 'requiredWords',\n type: 'horizontalChips',\n templateOptions: {\n type: 'text',\n label: 'Required words',\n help: \"Only results with titles that contain *all* words will be used. Title is converted to lowercase before. Apply words with return key.\",\n tooltip: 'If any of the required words is not found anywhere in a result title it\\'s also dismissed.'\n },\n hideExpression: function () {\n return rootModel.searching.applyRestrictions === \"NONE\";\n }\n },\n {\n key: 'requiredRegex',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n label: 'Required regex',\n help: 'Must be present in a title (case is ignored).',\n advanced: true\n },\n hideExpression: function () {\n return rootModel.searching.applyRestrictions === \"NONE\";\n }\n },\n\n {\n key: 'forbiddenGroups',\n type: 'horizontalChips',\n templateOptions: {\n type: 'text',\n label: 'Forbidden groups',\n help: 'Posts from any groups containing any of these words will be ignored. Apply words with return key.',\n advanced: true\n },\n hideExpression: function () {\n return rootModel.searching.applyRestrictions === \"NONE\";\n }\n },\n {\n key: 'forbiddenPosters',\n type: 'horizontalChips',\n templateOptions: {\n type: 'text',\n label: 'Forbidden posters',\n help: 'Posts from any posters containing any of these words will be ignored. Apply words with return key.',\n advanced: true\n }\n },\n {\n key: 'languagesToKeep',\n type: 'horizontalChips',\n templateOptions: {\n type: 'text',\n label: 'Languages to keep',\n help: 'If an indexer returns the language in the results only those results with configured languages will be used. Apply words with return key.'\n }\n },\n {\n key: 'maxAge',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'Maximum results age',\n help: 'Results older than this are ignored. Can be overwritten per search. Apply words with return key.',\n addonRight: {\n text: 'days'\n }\n }\n },\n {\n key: 'minSeeders',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'Minimum # seeders',\n help: 'Torznab results with fewer seeders will be ignored.'\n }\n },\n {\n key: 'ignorePassworded',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Ignore passworded releases',\n help: \"Not all indexers provide this information\",\n tooltip: 'Some indexers provide information if a release is passworded. If you select to ignore these releases only those will be ignored of which I know for sure that they\\'re actually passworded.'\n }\n }\n ]\n },\n {\n wrapper: 'fieldset',\n templateOptions: {\n label: 'Result processing'\n },\n fieldGroup: [\n {\n key: 'wrapApiErrors',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'text',\n label: 'Wrap API errors in empty results page',\n help: 'When enabled accessing tools will think the search was completed successfully but without results.',\n tooltip: 'In (hopefully) rare cases Hydra may crash when processing an API search request. You can enable to return an empty search page in these cases (if Hydra hasn\\'t crashed altogether ). This means that the calling tool (e.g. Sonarr) will think that the indexer (Hydra) is fine but just didn\\'t return a result. That way Hydra won\\'t be disabled as indexer but on the downside you may not be directly notified that an error occurred.',\n advanced: true\n }\n },\n {\n key: 'removeTrailing',\n type: 'horizontalChips',\n templateOptions: {\n type: 'text',\n label: 'Remove trailing...',\n help: 'Removed from title if it ends with either of these. Case insensitive and disregards leading/trailing spaces. Allows wildcards (\"*\"). Apply words with return key.',\n tooltip: 'Hydra contains a predefined list of words which will be removed if a search result title ends with them. This allows better duplicate detection and cleans up the titles. Trailing words will be removed until none of the defined strings are found at the end of the result title.'\n }\n },\n {\n key: 'useOriginalCategories',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Use original categories',\n help: 'Enable to use the category descriptions provided by the indexer.',\n tooltip: 'Hydra attempts to parse the provided newznab category IDs for results and map them to the configured categories. In some cases this may lead to category names which are not quite correct. You can select to use the original category name used by the indexer. This will only affect which category name is shown in the results.',\n advanced: true\n }\n }\n ]\n },\n {\n type: 'repeatSection',\n key: 'customMappings',\n model: rootModel.searching,\n templateOptions: {\n tooltip: 'Here you can define mappings to modify either queries or titles for search requests or to dynamically change the titles of found results. The former allows you, for example, to change requests made by external tools, the latter to clean up results by indexers in a more advanced way.',\n btnText: 'Add new custom mapping',\n altLegendText: 'Mapping',\n headline: 'Custom mappings of queries, search titles and result titles',\n advanced: true,\n fields: [\n {\n key: 'affectedValue',\n type: 'horizontalSelect',\n templateOptions: {\n label: 'Affected value',\n options: [\n {name: 'Query', value: 'QUERY'},\n {name: 'Search title', value: 'TITLE'},\n {name: 'Result title', value: 'RESULT_TITLE'},\n ],\n required: true,\n help: \"Determines which value of the search request or result will be processed\"\n }\n },\n {\n key: 'searchType',\n type: 'horizontalSelect',\n hideExpression: 'model.affectedValue === \"RESULT_TITLE\"',\n templateOptions: {\n label: 'Search type',\n options: [\n {name: 'General', value: 'SEARCH'},\n {name: 'Audio', value: 'MUSIC'},\n {name: 'EBook', value: 'BOOK'},\n {name: 'Movie', value: 'MOVIE'},\n {name: 'TV', value: 'TVSEARCH'}\n ],\n help: \"Determines in what context the mapping will be executed\"\n }\n },\n {\n key: 'matchAll',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Match whole string',\n help: 'If true then the input pattern must match the whole affected value. If false then any match will be replaced, even if it\\'s only part of the affected value.'\n }\n },\n {\n key: 'from',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n label: 'Input pattern',\n help: 'Pattern which must match the query or title of a search request (completely or in part, depending on the previous setting). You may use regexes in groups which can be referenced in the output puttern by using {group:regex}. Case insensitive.',\n required: true\n }\n },\n {\n key: 'to',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n label: 'Output pattern',\n required: true,\n help: 'If a query or title matches the input pattern it will be replaced using this. You may reference groups from the input pattern by using {group}. Additionally you may use {season:0} or {season:00} or {episode:0} or {episode:00} (with and without leading zeroes). Use <remove> to remove the match.'\n }\n },\n {\n type: 'customMappingTest',\n }\n ],\n defaultModel: {\n searchType: null,\n affectedValue: null,\n matchAll: true,\n from: null,\n to: null\n }\n }\n },\n\n\n {\n wrapper: 'fieldset',\n templateOptions: {\n label: 'Result display'\n },\n fieldGroup: [\n {\n key: 'loadAllCachedOnInternal',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Display all retrieved results',\n help: 'Load all results already retrieved from indexers. Might make sorting / filtering a bit slower. Will still be paged according to the limit set above.',\n advanced: true\n }\n },\n {\n key: 'loadLimitInternal',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'Display...',\n addonRight: {\n text: 'results per page'\n },\n max: 500,\n required: true,\n help: 'Determines the number of results shown on one page. This might also cause more API hits because indexers are queried until the number of results is matched or all indexers are exhausted. Limit is 500.',\n advanced: true\n }\n },\n {\n key: 'coverSize',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'Cover width',\n addonRight: {\n text: 'px'\n },\n required: true,\n help: 'Determines width of covers in search results (when enabled in display options).'\n }\n }\n ]\n }, {\n wrapper: 'fieldset',\n templateOptions: {\n label: 'Quick filters'\n },\n fieldGroup: [\n {\n key: 'showQuickFilterButtons',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Show quick filters',\n help: 'Show quick filter buttons for movie and TV results.'\n }\n },\n {\n key: 'alwaysShowQuickFilterButtons',\n type: 'horizontalSwitch',\n hideExpression: '!model.showQuickFilterButtons',\n templateOptions: {\n type: 'switch',\n label: 'Always show quick filters',\n help: 'Show all quick filter buttons for all types of searches.',\n advanced: true\n }\n },\n {\n key: 'customQuickFilterButtons',\n type: 'horizontalChips',\n hideExpression: '!model.showQuickFilterButtons',\n templateOptions: {\n type: 'text',\n label: 'Custom quick filters',\n help: 'Enter in the format DisplayName=Required1,Required2. Prefix words with ! to exclude them. Apply values with enter key.',\n tooltip: 'E.g. use WEB=webdl,web-dl. for a quick filter with the name \"WEB\" to be displayed that searches for \"webdl\" and \"web-dl\" in lowercase search results.',\n advanced: true\n }\n },\n {\n key: 'preselectQuickFilterButtons',\n type: 'horizontalMultiselect',\n hideExpression: '!model.showQuickFilterButtons',\n templateOptions: {\n label: 'Preselect quickfilters',\n help: 'Choose which quickfilters will be selected by default.',\n options: [\n {id: 'source|camts', label: 'CAM / TS'},\n {id: 'source|tv', label: 'TV'},\n {id: 'source|web', label: 'WEB'},\n {id: 'source|dvd', label: 'DVD'},\n {id: 'source|bluray', label: 'Blu-Ray'},\n {id: 'quality|q480p', label: '480p'},\n {id: 'quality|q720p', label: '720p'},\n {id: 'quality|q1080p', label: '1080p'},\n {id: 'quality|q2160p', label: '2160p'},\n {id: 'other|q3d', label: '3D'},\n {id: 'other|qx265', label: 'x265'},\n {id: 'other|qhevc', label: 'HEVC'},\n ],\n optionsFunction: function (model) {\n var customQuickFilters = [];\n _.each(model.customQuickFilterButtons, function (entry) {\n var split1 = entry.split(\"=\");\n var displayName = split1[0];\n customQuickFilters.push({id: \"custom|\" + displayName, label: displayName})\n })\n return customQuickFilters;\n },\n tooltip: 'To select custom quickfilters you just entered please save the config first.',\n buttonText: \"None\",\n advanced: true\n }\n }\n ]\n },\n {\n wrapper: 'fieldset',\n templateOptions: {\n label: 'Duplicate detection',\n tooltip: 'Hydra tries to find duplicate results from different indexers using heuristics. You can control the parameters for that but usually the default values work quite well.',\n advanced: true\n },\n fieldGroup: [\n {\n key: 'duplicateSizeThresholdInPercent',\n type: 'horizontalPercentInput',\n templateOptions: {\n type: 'text',\n label: 'Duplicate size threshold',\n required: true,\n addonRight: {\n text: '%'\n }\n\n }\n },\n {\n key: 'duplicateAgeThreshold',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'Duplicate age threshold',\n required: true,\n addonRight: {\n text: 'hours'\n }\n }\n }\n\n ]\n },\n {\n wrapper: 'fieldset',\n templateOptions: {\n label: 'Other',\n advanced: true\n },\n fieldGroup: [\n {\n key: 'keepSearchResultsForDays',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'Store results for ...',\n addonRight: {\n text: 'days'\n },\n required: true,\n tooltip: 'Found results are stored in the database for this long until they\\'re deleted. After that any links to Hydra results still stored elsewhere become invalid. You can increase the limit if you want, the disc space needed is negligible (about 75 MB for 7 days on my server).'\n }\n },\n {\n key: 'globalCacheTimeMinutes',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'Results cache time',\n help: 'When set search results will be cached for this time. Any search with the same parameters will return the cached results. API cache time parameters will be preferred. See wiki.',\n addonRight: {\n text: 'minutes'\n }\n }\n }\n ]\n }\n ],\n\n categoriesConfig: [\n {\n key: 'enableCategorySizes',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Category sizes',\n help: \"Preset min and max sizes depending on the selected category\",\n tooltip: 'Preset range of minimum and maximum sizes for its categories. When you select a category in the search area the appropriate fields are filled with these values.'\n }\n },\n {\n key: 'defaultCategory',\n type: 'horizontalSelect',\n templateOptions: {\n label: 'Default category',\n options: [],\n help: \"Set a default category. Reload page to set a category you just added.\"\n },\n controller: function ($scope) {\n var options = [];\n options.push({name: 'All', value: 'All'});\n _.each($scope.model.categories, function (cat) {\n options.push({name: cat.name, value: cat.name});\n });\n $scope.to.options = options;\n }\n },\n {\n type: 'help',\n templateOptions: {\n type: 'help',\n lines: [\n \"The category configuration is not validated in any way. You can seriously fuck up Hydra's results and overall behavior so take care.\",\n \"Restrictions will taken from a result's category, not the search request category which may not always be the same.\"\n ],\n marginTop: '50px',\n advanced: true\n }\n },\n {\n type: 'repeatSection',\n key: 'categories',\n model: rootModel.categoriesConfig,\n templateOptions: {\n btnText: 'Add new category',\n headline: 'Categories',\n advanced: true,\n fields: [\n {\n key: 'name',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n label: 'Name',\n help: 'Renaming categories might cause problems with repeating searches from the history.',\n required: true\n }\n },\n {\n key: 'searchType',\n type: 'horizontalSelect',\n templateOptions: {\n label: 'Search type',\n options: [\n {name: 'General', value: 'SEARCH'},\n {name: 'Audio', value: 'MUSIC'},\n {name: 'EBook', value: 'BOOK'},\n {name: 'Movie', value: 'MOVIE'},\n {name: 'TV', value: 'TVSEARCH'}\n ],\n help: \"Determines how indexers will be searched and if autocompletion is available in the GUI\"\n }\n },\n {\n key: 'subtype',\n type: 'horizontalSelect',\n templateOptions: {\n label: 'Sub type',\n options: [\n {name: 'Anime', value: 'ANIME'},\n {name: 'Audiobook', value: 'AUDIOBOOK'},\n {name: 'Comic', value: 'COMIC'},\n {name: 'Ebook', value: 'EBOOK'},\n {name: 'None', value: 'NONE'}\n ],\n help: \"Special search type. Used for indexer specific mappings between categories and newznab IDs\"\n }\n },\n {\n key: 'applyRestrictionsType',\n type: 'horizontalSelect',\n templateOptions: {\n label: 'Apply restrictions',\n options: [\n {name: 'All searches', value: 'BOTH'},\n {name: 'Internal searches', value: 'INTERNAL'},\n {name: 'API searches', value: 'API'},\n {name: 'Never', value: 'NONE'}\n ],\n help: \"For which type of search word restrictions will be applied\"\n }\n },\n {\n key: 'requiredWords',\n type: 'horizontalChips',\n templateOptions: {\n type: 'text',\n label: 'Required words',\n help: \"Must *all* be present in a title which is converted to lowercase before. Apply words with return key.\"\n }\n },\n {\n key: 'requiredRegex',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n label: 'Required regex',\n help: 'Must be present in a title (case is ignored).'\n }\n },\n {\n key: 'forbiddenWords',\n type: 'horizontalChips',\n templateOptions: {\n type: 'text',\n label: 'Forbidden words',\n help: \"None may be present in a title which is converted to lowercase before. Apply words with return key.\"\n }\n },\n {\n key: 'forbiddenRegex',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n label: 'Forbidden regex',\n help: 'Must not be present in a title (case is ignored).'\n }\n },\n {\n wrapper: 'settingWrapper',\n templateOptions: {\n label: 'Size preset',\n help: \"Will set these values on the search page\"\n },\n fieldGroup: [\n {\n key: 'minSizePreset',\n type: 'duoSetting',\n templateOptions: {\n addonRight: {\n text: 'MB'\n }\n\n }\n },\n {\n type: 'duolabel'\n },\n {\n key: 'maxSizePreset',\n type: 'duoSetting', templateOptions: {addonRight: {text: 'MB'}}\n }\n ]\n },\n {\n key: 'applySizeLimitsToApi',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Limit API results size',\n help: \"Enable to apply the size preset to API results from this category\"\n }\n },\n {\n key: 'newznabCategories',\n type: 'horizontalChips',\n templateOptions: {\n type: 'text',\n label: 'Newznab categories',\n help: 'Map newznab categories to Hydra categories. Used for parsing and when searching internally. Apply categories with return key.',\n tooltip: 'Hydra tries to map API search (newnzab) categories to its internal list of categories, going from specific to general. Example: If an API search is done with a catagory that matches those of \"Movies HD\" the settings for that category are used. Otherwise it checks if it matches the \"Movies\" category and, if yes, uses that one. If that one doesn\\'t match no category settings are used.
' +\n 'Related to that you must also define the newznab categories for every Hydra category, e.g. decide if the category for foreign movies (2010) is used for movie searches. This also controls the category mapping described above. You may combine newznab categories using \"&\" to require multiple numbers to be present in a result. For example \"2010&11000\" would require a search result to contain both 2010 and 11000 for that category to match.
' +\n 'Note: When an API search defines categories the internal mapping is only used for the forbidden and required words. The search requests to your newznab indexers will still use the categories from the original request, not the ones configured here.'\n }\n },\n {\n key: 'ignoreResultsFrom',\n type: 'horizontalSelect',\n templateOptions: {\n label: 'Ignore results',\n options: [\n {name: 'For all searches', value: 'BOTH'},\n {name: 'For internal searches', value: 'INTERNAL'},\n {name: 'For API searches', value: 'API'},\n {name: 'Never', value: 'NONE'}\n ],\n help: \"Ignore results from this category\",\n tooltip: 'If you want you can entirely ignore results from categories. Results from these categories will not show in the searches. If you select \"Internal\" or \"Always\" this category will also not be selectable on the search page.'\n }\n }\n\n ],\n defaultModel: {\n name: null,\n applySizeLimitsToApi: false,\n applyRestrictionsType: \"NONE\",\n forbiddenRegex: null,\n forbiddenWords: [],\n ignoreResultsFrom: \"NONE\",\n mayBeSelected: true,\n maxSizePreset: null,\n minSizePreset: null,\n newznabCategories: [],\n preselect: true,\n requiredRegex: null,\n requiredWords: [],\n searchType: \"SEARCH\",\n subtype: \"NONE\"\n }\n }\n }\n ],\n downloading: [\n {\n wrapper: 'fieldset',\n templateOptions: {\n label: 'General',\n tooltip: 'Hydra allows sending NZB search results directly to downloaders (NZBGet, sabnzbd). Torrent downloaders are not supported.'\n },\n fieldGroup: [\n {\n key: 'saveTorrentsTo',\n type: 'fileInput',\n templateOptions: {\n label: 'Torrent black hole',\n help: 'Allow torrents to be saved in this folder from the search results. Ignored if not set.',\n type: \"folder\"\n }\n },\n {\n key: 'saveNzbsTo',\n type: 'fileInput',\n templateOptions: {\n label: 'NZB black hole',\n help: 'Allow NZBs to be saved in this folder from the search results. Ignored if not set.',\n type: \"folder\"\n }\n },\n {\n key: 'nzbAccessType',\n type: 'horizontalSelect',\n templateOptions: {\n type: 'select',\n label: 'NZB access type',\n options: [\n {name: 'Proxy NZBs from indexer', value: 'PROXY'},\n {name: 'Redirect to the indexer', value: 'REDIRECT'}\n ],\n help: \"How access to NZBs is provided when NZBs are downloaded (by the user or external tools). Proxying is recommended as it allows fallback for failed downloads (see below)..\",\n tooltip: 'NZB downloads from Hydra can either be achieved by redirecting the requester to the original indexer or by downloading the NZB from the indexer and serving this. Redirecting has the advantage that it causes the least load on Hydra but also the disadvantage that the requester might be forwarded to an indexer link that contains the indexer\\'s API key. To prevent that select to proxy NZBs. It also allows fallback for failed downloads (next option).',\n advanced: true\n\n }\n },\n {\n key: 'externalUrl',\n type: 'horizontalInput',\n hideExpression: function ($viewValue, $modelValue, scope) {\n return !_.any(scope.model.downloaders, function (downloader) {\n return downloader.nzbAddingType === \"SEND_LINK\";\n });\n },\n templateOptions: {\n label: 'External URL',\n help: 'Used for links when sending links to the downloader.',\n tooltip: 'When using \"Add links\" to add NZBs to your downloader the links are usually calculated using the URL with which you accessed NZBHydra. This might be a URL that\\'s not accessible by the downloader (e.g. when it\\'s inside a docker container). Set the URL for NZBHydra that\\'s accessible by the downloader here and it will be used instead. ',\n advanced: true\n }\n },\n\n {\n key: 'fallbackForFailed',\n type: 'horizontalSelect',\n hideExpression: 'model.nzbAccessType === \"REDIRECT\"',\n templateOptions: {\n label: 'Fallback for failed downloads',\n options: [\n {name: 'GUI downloads', value: 'INTERNAL'},\n {name: 'API downloads', value: 'API'},\n {name: 'All downloads', value: 'BOTH'},\n {name: 'Never', value: 'NONE'}\n ],\n help: \"Fallback to similar results when a download fails. Only available when proxying NZBs (see above).\",\n tooltip: \"When you or an external program tries to download an NZB from NZBHydra the download may fail because the indexer is offline or its download limit has been reached. You can use this setting for NZBHydra to try and fall back on results from other indexers. It will search for results with the same name that were the result from the same search as where the download originated from. It will *not* execute another search.\"\n }\n },\n {\n key: 'sendMagnetLinks',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Send magnet links',\n help: \"Enable to send magnet links to the associated program on the server machine. Won't work with docker\"\n }\n },\n {\n key: 'updateStatuses',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Update statuses',\n help: \"Query your downloader for status updates of downloads\",\n advanced: true\n }\n },\n {\n key: 'showDownloaderStatus',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Show downloader footer',\n help: \"Show footer with downloader status\",\n advanced: true\n }\n },\n {\n key: 'primaryDownloader',\n type: 'horizontalSelect',\n hideExpression: 'model.downloaders.length <= 1 || !model.showDownloaderStatus',\n templateOptions: {\n label: 'Primary downloader',\n options: [],\n help: \"This downloader's state will be shown in the footer.\",\n tooltip: \"To select a downloader you just added please save the config first.\",\n optionsFunction: function (model) {\n var downloaders = [];\n _.each(model.downloaders, function (downloader) {\n downloaders.push({name: downloader.name, value: downloader.name})\n })\n return downloaders;\n },\n optionsFunctionAfter: function (model) {\n if (!model.primaryDownloader) {\n model.primaryDownloader = model.downloaders[0].name;\n }\n }\n }\n },\n ]\n },\n {\n wrapper: 'fieldset',\n key: 'downloaders',\n templateOptions: {label: 'Downloaders'},\n fieldGroup: [\n {\n type: \"downloaderConfig\",\n data: {}\n }\n ]\n }\n ],\n\n indexers: [\n {\n type: \"indexers\",\n data: {}\n },\n {\n type: 'recheckAllCaps'\n }\n ],\n auth: [\n {\n wrapper: 'fieldset',\n templateOptions: {\n label: 'Main',\n\n },\n fieldGroup: [\n {\n key: 'authType',\n type: 'horizontalSelect',\n templateOptions: {\n label: 'Auth type',\n options: [\n {name: 'None', value: 'NONE'},\n {name: 'HTTP Basic auth', value: 'BASIC'},\n {name: 'Login form', value: 'FORM'}\n ],\n tooltip: '
' +\n '
With auth type \"None\" all areas are unrestricted.
' +\n '
With auth type \"Form\" the basic page is loaded and login is done via a form.
' +\n '
With auth type \"Basic\" you login via basic HTTP authentication. With all areas restricted this is the most secure as nearly no data is loaded from the server before you auth. Logging out is not supported with basic auth.
' +\n '
'\n }\n },\n {\n key: 'authHeader',\n type: 'horizontalInput',\n templateOptions: {\n type: 'string',\n label: 'Auth header',\n help: 'Name of header that provides the username in requests from secure sources.',\n advanced: true\n },\n hideExpression: function () {\n return rootModel.auth.authType === \"NONE\";\n }\n },\n {\n key: 'authHeaderIpRanges',\n type: 'horizontalChips',\n templateOptions: {\n type: 'text',\n label: 'Secure IP ranges',\n help: 'IP ranges from which the auth header will be accepted. Apply with return key. Use values like \"192.168.0.1-192.168.0.100\" or single IP addresses like \"127.0.0.1\".',\n advanced: true\n },\n hideExpression: function () {\n return rootModel.auth.authType === \"NONE\" || _.isNullOrEmpty(rootModel.auth.authHeader);\n }\n },\n {\n key: 'rememberUsers',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Remember users',\n help: 'Remember users with cookie for 14 days.'\n },\n hideExpression: function () {\n return rootModel.auth.authType === \"NONE\";\n }\n },\n {\n key: 'rememberMeValidityDays',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'Cookie expiry',\n help: 'How long users are remembered.',\n addonRight: {\n text: 'days'\n },\n advanced: true\n }\n }\n\n ]\n },\n\n {\n wrapper: 'fieldset',\n templateOptions: {\n label: 'Restrictions',\n tooltip: 'Select which areas/features can only be accessed by logged in users (i.e. are restricted). If you don\\'t to allow anonymous users to do anything just leave everything selected. You can decide for every user if he is allowed to: ' +\n '
\\n' +\n '
view the search page at all
\\n' +\n '
view the stats
\\n' +\n '
access the admin area (config and control)
\\n' +\n '
view links for downloading NZBs and see their details
\\n' +\n '
may select which indexers are used for search.
\\n' +\n '
'\n },\n hideExpression: function () {\n return rootModel.auth.authType === \"NONE\";\n },\n fieldGroup: [\n {\n key: 'restrictSearch',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Restrict searching',\n help: 'Restrict access to searching.'\n }\n },\n {\n key: 'restrictStats',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Restrict stats',\n help: 'Restrict access to stats.'\n }\n },\n {\n key: 'restrictAdmin',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Restrict admin',\n help: 'Restrict access to admin functions.'\n }\n },\n {\n key: 'restrictDetailsDl',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Restrict NZB details & DL',\n help: 'Restrict NZB details, comments and download links.'\n }\n },\n {\n key: 'restrictIndexerSelection',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Restrict indexer selection box',\n help: 'Restrict visibility of indexer selection box in search. Affects only GUI.'\n }\n },\n {\n key: 'allowApiStats',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Allow stats access',\n help: 'Allow access to stats via external API.'\n }\n }\n ]\n },\n\n {\n type: 'repeatSection',\n key: 'users',\n model: rootModel.auth,\n hideExpression: function () {\n return rootModel.auth.authType === \"NONE\";\n },\n templateOptions: {\n btnText: 'Add new user',\n altLegendText: 'Authless',\n headline: 'Users',\n fields: [\n {\n key: 'username',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n label: 'Username',\n required: true\n }\n },\n {\n key: 'password',\n type: 'passwordSwitch',\n templateOptions: {\n type: 'password',\n label: 'Password',\n required: true\n }\n },\n {\n key: 'maySeeAdmin',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'May see admin area'\n }\n },\n {\n key: 'maySeeStats',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'May see stats'\n },\n hideExpression: 'model.maySeeAdmin'\n },\n {\n key: 'maySeeDetailsDl',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'May see NZB details & DL links'\n },\n hideExpression: 'model.maySeeAdmin'\n },\n {\n key: 'showIndexerSelection',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'May see indexer selection box'\n },\n hideExpression: 'model.maySeeAdmin'\n }\n ],\n defaultModel: {\n username: null,\n password: null,\n token: null,\n maySeeStats: true,\n maySeeAdmin: true,\n maySeeDetailsDl: true,\n showIndexerSelection: true\n }\n }\n }\n ],\n notificationConfig: [\n {\n type: 'help',\n templateOptions: {\n type: 'help',\n lines: [\n \"NZBHydra supports sending and displaying notifications for certain events. You can enable notifications for each event by adding entries below.\",\n 'NZBHydra uses Apprise to communicate with the actual notification providers. You need either a) an instance of Apprise API running or b) an Apprise runnable accessible by NZBHydra. Either are not part of NZBHydra.',\n \"NZBHydra will also show notifications on the GUI if enabled.\"\n ]\n }\n },\n {\n wrapper: 'fieldset',\n templateOptions: {\n label: 'Main'\n },\n fieldGroup: [\n\n {\n key: 'appriseType',\n type: 'horizontalSelect',\n templateOptions: {\n type: 'select',\n label: 'Apprise type',\n options: [\n {name: 'None', value: 'NONE'},\n {name: 'API', value: 'API'},\n {name: 'CLI', value: 'CLI'}\n ]\n }\n },\n {\n key: 'appriseApiUrl',\n type: 'horizontalInput',\n templateOptions: {\n type: 'string',\n label: 'Apprise API URL',\n help: 'URL of Apprise API to send notifications to.'\n },\n hideExpression: 'model.appriseType !== \"API\"'\n },\n {\n key: 'appriseCliPath',\n type: 'fileInput',\n templateOptions: {\n type: 'file',\n label: 'Apprise runnable',\n help: 'Full path of of Apprise runnable to execute.'\n },\n hideExpression: 'model.appriseType !== \"CLI\"'\n },\n {\n key: 'displayNotifications',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Display notifications',\n help: 'If enabled notifications will be shown on the GUI.'\n }\n },\n {\n key: 'displayNotificationsMax',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'Show max notifications',\n help: 'Max number of notifications to show on the GUI. If more have piled up a notification will indicate this and link to the notification history.'\n },\n hideExpression: '!model.displayNotifications'\n },\n {\n key: 'filterOuts',\n type: 'horizontalChips',\n templateOptions: {\n type: 'text',\n label: 'Hide if message contains...',\n help: 'Apply values with return key. Surround with \"/\" for regex (e.g. /contains[0-9]This/). Case insensitive.',\n\n },\n hideExpression: '!model.displayNotifications'\n }\n ]\n },\n\n {\n type: 'notificationSection',\n key: 'entries',\n model: rootModel.notificationConfig,\n templateOptions: {\n btnText: 'Add new notification',\n altLegendText: 'Notification',\n headline: 'Notifications',\n fields: [\n {\n key: 'appriseUrls',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n label: 'URLs',\n help: 'One or more URLs identifying where the notification should be sent to, comma-separated.'\n }\n },\n {\n key: 'titleTemplate',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n label: 'Title template'\n },\n controller: notificationTemplateHelpController\n },\n {\n key: 'bodyTemplate',\n type: 'horizontalTextArea',\n templateOptions: {\n type: 'text',\n label: 'Body template',\n required: true\n },\n controller: notificationTemplateHelpController\n },\n {\n key: 'messageType',\n type: 'horizontalSelect',\n templateOptions: {\n label: 'Message type',\n options: [\n {name: 'Info', value: 'INFO'},\n {name: 'Success', value: 'SUCCESS'},\n {name: 'Warning', value: 'WARNING'},\n {name: 'Failure', value: 'FAILURE'}\n ],\n help: \"Select the message type to use.\"\n }\n },\n {\n key: 'bodyTemplate',\n type: 'horizontalTestNotification'\n }\n\n ],\n defaultModel: {\n eventType: null,\n appriseUrls: null,\n titleTemplate: null,\n bodyTemplate: null,\n messageType: 'WARNING'\n }\n }\n }\n ]\n\n }\n\n function notificationTemplateHelpController($scope, NotificationService) {\n $scope.model.eventTypeReadable = NotificationService.humanize($scope.model.eventType);\n $scope.to.help = NotificationService.getTemplateHelp($scope.model.eventType);\n }\n }\n}\n\nfunction handleConnectionCheckFail(ModalService, data, model, whatFailed, deferred) {\n var message;\n var yesText;\n if (data.checked) {\n message = \"The connection to the \" + whatFailed + \" failed: \" + data.message + \" Do you want to add it anyway?\";\n yesText = \"I know what I'm doing\";\n } else {\n message = \"The connection to the \" + whatFailed + \" could not be tested, sorry. Please check the log.\";\n yesText = \"I'll risk it\";\n }\n ModalService.open(\"Connection check failed\", message, {\n yes: {\n onYes: function () {\n deferred.resolve();\n },\n text: yesText\n },\n no: {\n onNo: function () {\n model.enabled = false;\n deferred.resolve();\n },\n text: \"Add it, but disabled\"\n },\n cancel: {\n onCancel: function () {\n deferred.reject();\n },\n text: \"Aahh, let me try again\"\n }\n });\n}\n","\nConfigController.$inject = [\"$scope\", \"$http\", \"activeTab\", \"ConfigService\", \"config\", \"DownloaderCategoriesService\", \"ConfigFields\", \"ConfigModel\", \"ModalService\", \"RestartService\", \"localStorageService\", \"$state\", \"growl\", \"$window\"];angular\n .module('nzbhydraApp')\n .factory('ConfigModel', function () {\n return {};\n });\n\nangular\n .module('nzbhydraApp')\n .factory('ConfigWatcher', function () {\n var $scope;\n\n return {\n watch: watch\n };\n\n function watch(scope) {\n $scope = scope;\n $scope.$watchGroup([\"config.main.host\"], function () {\n }, true);\n }\n });\n\n\nangular\n .module('nzbhydraApp')\n .controller('ConfigController', ConfigController);\n\nfunction ConfigController($scope, $http, activeTab, ConfigService, config, DownloaderCategoriesService, ConfigFields, ConfigModel, ModalService, RestartService, localStorageService, $state, growl, $window) {\n $scope.config = config;\n $scope.submit = submit;\n $scope.activeTab = activeTab;\n\n $scope.restartRequired = false;\n $scope.ignoreSaveNeeded = false;\n console.log(localStorageService.get(\"showAdvanced\"));\n if (localStorageService.get(\"showAdvanced\") === null) {\n $scope.showAdvanced = false;\n localStorageService.set(\"showAdvanced\", false);\n } else {\n $scope.showAdvanced = localStorageService.get(\"showAdvanced\");\n }\n\n\n $scope.toggleShowAdvanced = function () {\n $scope.showAdvanced = !$scope.showAdvanced;\n var wasDirty = $scope.form.$dirty === true;\n\n $scope.allTabs[$scope.activeTab].model.showAdvanced = $scope.showAdvanced === true;\n //Also save in main tab where it will be stored to file\n $scope.allTabs[0].model.showAdvanced = $scope.allTabs[$scope.activeTab].model.showAdvanced === true;\n $scope.form.$dirty = wasDirty;\n localStorageService.set(\"showAdvanced\", $scope.showAdvanced);\n }\n\n function updateAndAskForRestartIfNecessary(responseData) {\n if (angular.isUndefined($scope.form)) {\n console.error(\"Unable to determine if a restart is necessary\");\n return;\n }\n\n $scope.form.$setPristine();\n DownloaderCategoriesService.invalidate();\n if ($scope.restartRequired) {\n ModalService.open(\"Restart required\", \"The changes you have made may require a restart to be effective. Do you want to restart now?\", {\n yes: {\n onYes: function () {\n RestartService.restart();\n }\n },\n no: {\n onNo: function ($uibModalInstance) {\n //Needs to be clicked twice for some reason\n $scope.restartRequired = false;\n $uibModalInstance.dismiss();\n $uibModalInstance.dismiss();\n $scope.config = responseData.newConfig;\n $window.location.reload();\n }\n }\n });\n } else {\n $scope.config = responseData.newConfig;\n $window.location.reload();\n }\n }\n\n function handleConfigSetResponse(response, ignoreWarnings, restartNeeded) {\n if (angular.isUndefined(ignoreWarnings)) {\n ignoreWarnings = localStorageService.get(\"ignoreWarnings\") !== null ? localStorageService.get(\"ignoreWarnings\") : false;\n }\n //Communication with server was successful but there might be validation errors and/or warnings\n var warningMessages = response.data.warningMessages;\n var errorMessages = response.data.errorMessages;\n $scope.restartRequired = response.data.restartNeeded || (angular.isDefined(restartNeeded) ? restartNeeded : false);\n var showMessage = errorMessages.length > 0 || (warningMessages.length > 0 && !ignoreWarnings);\n\n function extendMessageWithList(message, messages) {\n _.forEach(messages, function (x) {\n message += \"
\" + x + \"
\";\n });\n message += \"\";\n return message;\n }\n\n if (showMessage) {\n var options;\n var message;\n var title;\n if (errorMessages.length > 0) { //Actual errors which cannot be ignored\n title = \"Config validation failed\";\n message = 'The following errors have been found in your config. They need to be fixed.
';\n message = extendMessageWithList(message, response.data.errorMessages);\n if (warningMessages.length > 0) {\n message += ' The following warnings were found. You can ignore them if you wish.
';\n message = extendMessageWithList(message, response.data.warningMessages);\n }\n options = {\n yes: {\n onYes: function () {\n },\n text: \"OK\"\n }\n };\n } else if (warningMessages.length > 0) {\n title = \"Config validation warnings\";\n message = ' The following warnings have been found. You can ignore them if you wish. The config was already saved.
',\n controller: [\"$scope\", \"url\", function ($scope, url) {\n $scope.url = url;\n }],\n resolve: {\n url: function () {\n return url;\n }\n },\n size: \"md\",\n keyboard: true,\n windowTopClass: 'cover-modal-dialog'\n });\n };\n\n }\n}\n\nangular\n .module('nzbhydraApp')\n .controller('NfoModalInstanceCtrl', NfoModalInstanceCtrl);\n\nfunction NfoModalInstanceCtrl($scope, $uibModalInstance, nfo) {\n\n $scope.nfo = nfo;\n\n $scope.ok = function () {\n $uibModalInstance.close($scope.selected.item);\n };\n\n $scope.cancel = function () {\n $uibModalInstance.dismiss();\n };\n}\n\nangular\n .module('nzbhydraApp')\n .filter('kify', function () {\n return function (number) {\n if (number > 1000) {\n return Math.round(number / 1000) + \"k\";\n }\n return number;\n }\n });\n","angular\r\n .module('nzbhydraApp')\r\n .directive('saveOrSendFile', saveOrSendFile);\r\n\r\nfunction saveOrSendFile() {\r\n controller.$inject = [\"$scope\", \"$http\", \"growl\", \"ConfigService\"];\r\n return {\r\n templateUrl: 'static/html/directives/save-or-send-file.html',\r\n scope: {\r\n searchResultId: \"<\",\r\n isFile: \"<\",\r\n type: \"<\"\r\n },\r\n controller: controller\r\n };\r\n\r\n function controller($scope, $http, growl, ConfigService) {\r\n $scope.cssClass = \"glyphicon-save-file\";\r\n var endpoint;\r\n if ($scope.type === \"TORRENT\") {\r\n $scope.enableButton = !_.isNullOrEmpty(ConfigService.getSafe().downloading.saveTorrentsTo) || ConfigService.getSafe().downloading.sendMagnetLinks;\r\n $scope.tooltip = \"Save torrent to black hole or send magnet link\";\r\n endpoint = \"internalapi/saveOrSendTorrent\";\r\n } else {\r\n $scope.tooltip = \"Save NZB to black hole\";\r\n $scope.enableButton = !_.isNullOrEmpty(ConfigService.getSafe().downloading.saveNzbsTo);\r\n endpoint = \"internalapi/saveNzbToBlackhole\";\r\n }\r\n $scope.add = function () {\r\n $scope.cssClass = \"nzb-spinning\";\r\n $http.put(endpoint, $scope.searchResultId).then(function (response) {\r\n if (response.data.successful) {\r\n $scope.cssClass = \"glyphicon-ok\";\r\n } else {\r\n $scope.cssClass = \"glyphicon-remove\";\r\n growl.error(response.data.message);\r\n }\r\n });\r\n };\r\n }\r\n}\r\n","//Can be used in an ng-repeat directive to call a function when the last element was rendered\n//We use it to mark the end of sorting / filtering so we can stop blocking the UI\n\nonFinishRender.$inject = [\"$timeout\"];\nangular\n .module('nzbhydraApp')\n .directive('onFinishRender', onFinishRender);\n\nfunction onFinishRender($timeout) {\n function linkFunction(scope, element, attr) {\n\n if (scope.$last === true) {\n console.log(\"Render finished\");\n // console.timeEnd(\"Presenting\");\n // console.timeEnd(\"searchall\");\n scope.$emit(\"onFinishRender\")\n }\n }\n\n return {\n link: linkFunction\n }\n}","//Fork of https://github.com/dotansimha/angularjs-dropdown-multiselect to make it compatible with formly\nangular\n .module('nzbhydraApp')\n .directive('multiselectDropdown',\n\n dropdownMultiselectDirective\n );\n\nfunction dropdownMultiselectDirective() {\n return {\n scope: {\n selectedModel: '=',\n options: '=',\n settings: '=?',\n events: '=?'\n },\n transclude: {\n toggleDropdown: '?toggleDropdown'\n },\n templateUrl: 'static/html/directives/multiselect-dropdown.html',\n controller: [\"$scope\", \"$element\", \"$filter\", \"$document\", function dropdownMultiselectController($scope, $element, $filter, $document) {\n var $dropdownTrigger = $element.children()[0];\n\n var settings = {\n showSelectedValues: true,\n showSelectAll: true,\n showDeselectAll: true,\n noSelectedText: 'None selected'\n };\n var events = {\n onToggleItem: angular.noop\n };\n angular.extend(events, $scope.events || []);\n angular.extend(settings, $scope.settings || []);\n angular.extend($scope, {settings: settings, events: events});\n\n $scope.buttonText = \"\";\n if (settings.buttonText) {\n $scope.buttonText = settings.buttonText;\n } else {\n $scope.$watch(\"selectedModel\", function () {\n if (angular.isDefined($scope.selectedModel) && settings.showSelectedValues) {\n if ($scope.selectedModel.length === 0) {\n if ($scope.settings.noSelectedText) {\n $scope.buttonText = $scope.settings.noSelectedText;\n } else {\n $scope.buttonText = \"None selected\";\n }\n } else if ($scope.selectedModel.length === $scope.options.length) {\n $scope.buttonText = \"All selected\";\n } else {\n var selected = [];\n _.each($scope.options, function (x) {\n if ($scope.selectedModel.indexOf(x.id) > -1) {\n selected.push(x.label);\n }\n })\n $scope.buttonText = selected.join(\", \");\n }\n } else {\n if (angular.isUndefined($scope.selectedModel) || ($scope.settings.noSelectedText && $scope.selectedModel.length === 0)) {\n $scope.buttonText = $scope.settings.noSelectedText;\n } else {\n $scope.buttonText = $scope.selectedModel.length + \" / \" + $scope.options.length + \" selected\";\n }\n }\n }, true);\n }\n $scope.open = false;\n\n $scope.toggleDropdown = function () {\n $scope.open = !$scope.open;\n };\n\n $scope.toggleItem = function (option) {\n var index = $scope.selectedModel.indexOf(option.id);\n var oldValue = index > -1;\n if (oldValue) {\n $scope.selectedModel.splice(index, 1);\n } else {\n $scope.selectedModel.push(option.id);\n }\n $scope.events.onToggleItem(option, !oldValue);\n };\n\n $scope.selectAll = function () {\n $scope.selectedModel = _.pluck($scope.options, \"id\");\n };\n\n $scope.deselectAll = function () {\n $scope.selectedModel.splice(0, $scope.selectedModel.length);\n };\n\n //Close when clicked outside\n\n $document.on('click', function (e) {\n function contains(collection, target) {\n var containsTarget = false;\n collection.some(function (object) {\n if (object === target) {\n containsTarget = true;\n return true;\n }\n return false;\n });\n return containsTarget;\n }\n\n if ($scope.open) {\n var target = e.target.parentElement;\n var parentFound = false;\n\n while (angular.isDefined(target) && target !== null && !parentFound) {\n if (!!target.className.split && contains(target.className.split(' '), 'multiselect-parent') && !parentFound) {\n if (target === $dropdownTrigger) {\n parentFound = true;\n }\n }\n target = target.parentElement;\n }\n\n if (!parentFound) {\n $scope.$apply(function () {\n $scope.open = false;\n });\n }\n }\n });\n\n\n }]\n\n }\n}","angular\r\n .module('nzbhydraApp').directive(\"keepFocus\", ['$timeout', function ($timeout) {\r\n /*\r\n Intended use:\r\n \r\n */\r\n return {\r\n restrict: 'A',\r\n require: 'ngModel',\r\n link: function ($scope, $element, attrs, ngModel) {\r\n\r\n ngModel.$parsers.unshift(function (value) {\r\n $timeout(function () {\r\n $element[0].focus();\r\n });\r\n return value;\r\n });\r\n\r\n }\r\n };\r\n}]);","/*\r\n * (C) Copyright 2017 TheOtherP (theotherp@posteo.net)\r\n *\r\n * Licensed under the Apache License, Version 2.0 (the \"License\");\r\n * you may not use this file except in compliance with the License.\r\n * You may obtain a copy of the License at\r\n *\r\n * http://www.apache.org/licenses/LICENSE-2.0\r\n *\r\n * Unless required by applicable law or agreed to in writing, software\r\n * distributed under the License is distributed on an \"AS IS\" BASIS,\r\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\r\n * See the License for the specific language governing permissions and\r\n * limitations under the License.\r\n */\r\n\r\nangular\r\n .module('nzbhydraApp')\r\n .directive('indexerStateSwitch', indexerStateSwitch);\r\n\r\nfunction indexerStateSwitch() {\r\n controller.$inject = [\"$scope\"];\r\n return {\r\n templateUrl: 'static/html/directives/indexer-state-switch.html',\r\n scope: {\r\n indexer: \"=\",\r\n handleWidth: \"@\"\r\n },\r\n replace: true,\r\n controller: controller\r\n };\r\n\r\n function controller($scope) {\r\n $scope.value = $scope.indexer.state === \"ENABLED\";\r\n $scope.handleWidth = $scope.handleWidth || \"130px\";\r\n var initialized = false;\r\n\r\n function calculateTextAndColor() {\r\n if ($scope.indexer.state === \"DISABLED_USER\") {\r\n $scope.offText = \"Disabled by user\";\r\n $scope.offColor = \"default\";\r\n } else if ($scope.indexer.state === \"DISABLED_SYSTEM_TEMPORARY\") {\r\n $scope.offText = \"Temporary disabled\";\r\n $scope.offColor = \"warning\";\r\n } else if ($scope.indexer.state === \"DISABLED_SYSTEM\") {\r\n $scope.offText = \"Disabled by system\";\r\n $scope.offColor = \"danger\";\r\n }\r\n }\r\n\r\n calculateTextAndColor();\r\n\r\n $scope.onChange = function () {\r\n if (initialized) {\r\n //Skip on first call when initial value is set\r\n $scope.indexer.state = $scope.value ? \"ENABLED\" : \"DISABLED_USER\";\r\n calculateTextAndColor();\r\n }\r\n initialized = true;\r\n }\r\n }\r\n}","/*\r\n * (C) Copyright 2017 TheOtherP (theotherp@posteo.net)\r\n *\r\n * Licensed under the Apache License, Version 2.0 (the \"License\");\r\n * you may not use this file except in compliance with the License.\r\n * You may obtain a copy of the License at\r\n *\r\n * http://www.apache.org/licenses/LICENSE-2.0\r\n *\r\n * Unless required by applicable law or agreed to in writing, software\r\n * distributed under the License is distributed on an \"AS IS\" BASIS,\r\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\r\n * See the License for the specific language governing permissions and\r\n * limitations under the License.\r\n */\r\n\r\nangular\r\n .module('nzbhydraApp')\r\n .directive('indexerSelectionButton', indexerSelectionButton);\r\n\r\nfunction indexerSelectionButton() {\r\n controller.$inject = [\"$scope\"];\r\n return {\r\n templateUrl: 'static/html/directives/indexer-selection-button.html',\r\n scope: {\r\n selectedIndexers: \"=\",\r\n availableIndexers: \"=\",\r\n btn: \"@\"\r\n },\r\n controller: controller\r\n };\r\n\r\n function controller($scope) {\r\n\r\n $scope.anyTorrentIndexersSelectable = _.any($scope.availableIndexers,\r\n function (indexer) {\r\n return indexer.searchModuleType === \"TORZNAB\";\r\n }\r\n );\r\n\r\n $scope.invertSelection = function () {\r\n _.forEach($scope.availableIndexers, function (x) {\r\n var index = _.indexOf($scope.selectedIndexers, x.name);\r\n if (index === -1) {\r\n $scope.selectedIndexers.push(x.name);\r\n } else {\r\n $scope.selectedIndexers.splice(index, 1);\r\n }\r\n });\r\n };\r\n\r\n $scope.selectAll = function () {\r\n $scope.deselectAll();\r\n $scope.selectedIndexers.push.apply($scope.selectedIndexers, _.pluck($scope.availableIndexers, \"name\"));\r\n };\r\n\r\n $scope.deselectAll = function () {\r\n $scope.selectedIndexers.splice(0, $scope.selectedIndexers.length);\r\n };\r\n\r\n function selectByPredicate(predicate) {\r\n $scope.deselectAll();\r\n $scope.selectedIndexers.push.apply($scope.selectedIndexers,\r\n _.pluck(\r\n _.filter($scope.availableIndexers,\r\n predicate\r\n ), \"name\")\r\n );\r\n }\r\n\r\n $scope.reset = function () {\r\n selectByPredicate(function (indexer) {\r\n return indexer.preselect;\r\n });\r\n };\r\n\r\n $scope.selectAllUsenet = function () {\r\n selectByPredicate(function (indexer) {\r\n return indexer.searchModuleType !== \"TORZNAB\";\r\n });\r\n };\r\n\r\n $scope.selectAllTorrent = function () {\r\n selectByPredicate(function (indexer) {\r\n return indexer.searchModuleType === \"TORZNAB\";\r\n });\r\n }\r\n }\r\n}\r\n\r\n","angular\r\n .module('nzbhydraApp')\r\n .directive('indexerInput', indexerInput);\r\n\r\nfunction indexerInput() {\r\n controller.$inject = [\"$scope\"];\r\n return {\r\n templateUrl: 'static/html/directives/indexer-input.html',\r\n scope: {\r\n indexer: \"=\",\r\n model: \"=\",\r\n onClick: \"=\"\r\n },\r\n replace: true,\r\n controller: controller\r\n };\r\n\r\n function controller($scope) {\r\n $scope.isFocused = false;\r\n\r\n $scope.onFocus = function () {\r\n $scope.isFocused = true;\r\n };\r\n\r\n $scope.onBlur = function () {\r\n $scope.isFocused = false;\r\n };\r\n\r\n var expiryWarning;\r\n if ($scope.indexer.vipExpirationDate != null && $scope.indexer.vipExpirationDate !== \"Lifetime\") {\r\n var expiryDate = moment($scope.indexer.vipExpirationDate, \"YYYY-MM-DD\");\r\n if (expiryDate < moment()) {\r\n console.log(\"Expiry date reached for indexer \" + $scope.indexer.name);\r\n expiryWarning = \"VIP access expired on \" + $scope.indexer.vipExpirationDate;\r\n } else if (expiryDate.subtract(7, 'days') < moment()) {\r\n console.log(\"Expiry date near for indexer \" + $scope.indexer.name);\r\n expiryWarning = \"VIP access will expire on \" + $scope.indexer.vipExpirationDate;\r\n }\r\n }\r\n\r\n $scope.expiryWarning = expiryWarning;\r\n if ($scope.indexer.color !== null) {\r\n $scope.style = \"background-color: \" + $scope.indexer.color.replace(\"rgb\", \"rgba\").replace(\")\", \",0.5)\")\r\n }\r\n }\r\n\r\n}\r\n\r\n","angular\n .module('nzbhydraApp')\n .directive('hydraupdates', hydraupdates);\n\nfunction hydraupdates() {\n controller.$inject = [\"$scope\", \"UpdateService\"];\n return {\n templateUrl: 'static/html/directives/updates.html',\n controller: controller\n };\n\n function controller($scope, UpdateService) {\n\n $scope.loadingPromise = UpdateService.getInfos().then(function (response) {\n $scope.currentVersion = response.data.currentVersion;\n $scope.latestVersion = response.data.latestVersion;\n $scope.latestVersionIsBeta = response.data.latestVersionIsBeta;\n $scope.betaVersion = response.data.betaVersion;\n $scope.updateAvailable = response.data.updateAvailable;\n $scope.betaUpdateAvailable = response.data.betaUpdateAvailable;\n $scope.latestVersionIgnored = response.data.latestVersionIgnored;\n $scope.changelog = response.data.changelog;\n $scope.updatedExternally = response.data.updatedExternally;\n $scope.wrapperOutdated = response.data.wrapperOutdated;\n $scope.showUpdateBannerOnUpdatedExternally = response.data.showUpdateBannerOnUpdatedExternally;\n if ($scope.updatedExternally && !$scope.showUpdateBannerOnUpdatedExternally) {\n $scope.updateAvailable = false;\n }\n });\n\n UpdateService.getVersionHistory().then(function (response) {\n $scope.versionHistory = response.data;\n });\n\n\n $scope.update = function (version) {\n UpdateService.update(version);\n };\n\n $scope.showChangelog = function (version) {\n UpdateService.showChanges(version);\n };\n\n $scope.forceUpdate = function () {\n UpdateService.update($scope.latestVersion)\n };\n }\n}\n\n","angular\r\n .module('nzbhydraApp')\r\n .directive('hydraNews', hydraNews);\r\n\r\nfunction hydraNews() {\r\n controller.$inject = [\"$scope\", \"$http\"];\r\n return {\r\n templateUrl: \"static/html/directives/news.html\",\r\n controller: controller\r\n };\r\n\r\n function controller($scope, $http) {\r\n\r\n return $http.get(\"internalapi/news\").then(function (response) {\r\n $scope.news = response.data;\r\n });\r\n\r\n\r\n }\r\n}\r\n\r\n","\r\nLogModalInstanceCtrl.$inject = [\"$scope\", \"$uibModalInstance\", \"entry\"];\r\nescapeHtml.$inject = [\"$sanitize\"];angular\r\n .module('nzbhydraApp')\r\n .directive('hydralog', hydralog);\r\n\r\nfunction hydralog() {\r\n controller.$inject = [\"$scope\", \"$http\", \"$interval\", \"$uibModal\", \"$sce\", \"localStorageService\", \"growl\"];\r\n return {\r\n templateUrl: \"static/html/directives/log.html\",\r\n controller: controller\r\n };\r\n\r\n function controller($scope, $http, $interval, $uibModal, $sce, localStorageService, growl) {\r\n $scope.tailInterval = null;\r\n $scope.doUpdateLog = localStorageService.get(\"doUpdateLog\") !== null ? localStorageService.get(\"doUpdateLog\") : false;\r\n $scope.doTailLog = localStorageService.get(\"doTailLog\") !== null ? localStorageService.get(\"doTailLog\") : false;\r\n\r\n $scope.active = 0;\r\n $scope.currentJsonIndex = 0;\r\n $scope.hasMoreJsonLines = true;\r\n\r\n function getLog(index) {\r\n if ($scope.active === 0) {\r\n return $http.get(\"internalapi/debuginfos/jsonlogs\", {\r\n params: {\r\n offset: index,\r\n limit: 500\r\n }\r\n }).then(function (response) {\r\n var data = response.data;\r\n $scope.jsonLogLines = angular.fromJson(data.lines);\r\n $scope.hasMoreJsonLines = data.hasMore;\r\n });\r\n } else if ($scope.active === 1) {\r\n return $http.get(\"internalapi/debuginfos/currentlogfile\").then(function (response) {\r\n var data = response.data;\r\n $scope.log = $sce.trustAsHtml(data.replace(/&/g, \"&\")\r\n .replace(//g, \">\")\r\n .replace(/\"/g, \""\")\r\n .replace(/'/g, \"'\"));\r\n }, function (data) {\r\n growl.error(data)\r\n });\r\n } else if ($scope.active === 2) {\r\n return $http.get(\"internalapi/debuginfos/logfilenames\").then(function (response) {\r\n $scope.logfilenames = response.data;\r\n });\r\n }\r\n }\r\n\r\n $scope.logPromise = getLog();\r\n\r\n $scope.select = function (index) {\r\n $scope.active = index;\r\n $scope.update();\r\n };\r\n\r\n $scope.scrollToBottom = function () {\r\n document.getElementById(\"logfile\").scrollTop = 10000000;\r\n document.getElementById(\"logfile\").scrollTop = 100001000;\r\n };\r\n\r\n $scope.update = function () {\r\n getLog($scope.currentJsonIndex);\r\n if ($scope.active === 1) {\r\n $scope.scrollToBottom();\r\n }\r\n };\r\n\r\n $scope.getOlderFormatted = function () {\r\n getLog($scope.currentJsonIndex + 500).then(function () {\r\n $scope.currentJsonIndex += 500;\r\n });\r\n\r\n };\r\n\r\n $scope.getNewerFormatted = function () {\r\n var index = Math.max($scope.currentJsonIndex - 500, 0);\r\n getLog(index);\r\n $scope.currentJsonIndex = index;\r\n };\r\n\r\n function startUpdateLogInterval() {\r\n $scope.tailInterval = $interval(function () {\r\n if ($scope.active === 1) {\r\n $scope.update();\r\n if ($scope.doTailLog && $scope.active === 1) {\r\n $scope.scrollToBottom();\r\n }\r\n }\r\n }, 5000);\r\n }\r\n\r\n $scope.toggleUpdate = function (doUpdateLog) {\r\n $scope.doUpdateLog = doUpdateLog;\r\n if ($scope.doUpdateLog) {\r\n startUpdateLogInterval();\r\n } else if ($scope.tailInterval !== null) {\r\n console.log(\"Cancelling\");\r\n $interval.cancel($scope.tailInterval);\r\n localStorageService.set(\"doTailLog\", false);\r\n $scope.doTailLog = false;\r\n }\r\n localStorageService.set(\"doUpdateLog\", $scope.doUpdateLog);\r\n };\r\n\r\n $scope.toggleTailLog = function () {\r\n localStorageService.set(\"doTailLog\", $scope.doTailLog);\r\n };\r\n\r\n $scope.openModal = function openModal(entry) {\r\n var modalInstance = $uibModal.open({\r\n templateUrl: 'log-entry.html',\r\n controller: LogModalInstanceCtrl,\r\n size: \"xl\",\r\n resolve: {\r\n entry: function () {\r\n return entry;\r\n }\r\n }\r\n });\r\n\r\n modalInstance.result.then();\r\n };\r\n\r\n $scope.$on('$destroy', function () {\r\n if ($scope.tailInterval !== null) {\r\n $interval.cancel($scope.tailInterval);\r\n }\r\n });\r\n\r\n if ($scope.doUpdateLog) {\r\n startUpdateLogInterval();\r\n }\r\n\r\n\r\n }\r\n}\r\n\r\nangular\r\n .module('nzbhydraApp')\r\n .controller('LogModalInstanceCtrl', LogModalInstanceCtrl);\r\n\r\nfunction LogModalInstanceCtrl($scope, $uibModalInstance, entry) {\r\n\r\n $scope.entry = entry;\r\n\r\n $scope.ok = function () {\r\n $uibModalInstance.dismiss();\r\n };\r\n}\r\n\r\nangular\r\n .module('nzbhydraApp')\r\n .filter('formatTimestamp', formatTimestamp);\r\n\r\nfunction formatTimestamp() {\r\n return function (date) {\r\n //1579392000\r\n //1579374757\r\n if (date === null || date === undefined) {\r\n return null;\r\n }\r\n if (date < 1979374757) {\r\n date *= 1000;\r\n }\r\n return moment(date).local().format(\"YYYY-MM-DD HH:mm\");\r\n }\r\n}\r\n\r\nangular\r\n .module('nzbhydraApp')\r\n .filter('escapeHtml', escapeHtml);\r\n\r\nfunction escapeHtml($sanitize) {\r\n return function (text) {\r\n return $sanitize(text);\r\n }\r\n}\r\n\r\nangular\r\n .module('nzbhydraApp')\r\n .filter('formatClassname', formatClassname);\r\n\r\nfunction formatClassname() {\r\n return function (fqn) {\r\n return fqn.substr(fqn.lastIndexOf(\".\") + 1);\r\n\r\n }\r\n}","/*\n * (C) Copyright 2017 TheOtherP (theotherp@posteo.net)\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nNewsModalInstanceCtrl.$inject = [\"$scope\", \"$uibModalInstance\", \"news\"];\nWelcomeModalInstanceCtrl.$inject = [\"$scope\", \"$uibModalInstance\", \"$state\", \"MigrationService\"];\nangular\n .module('nzbhydraApp')\n .directive('hydraChecksFooter', hydraChecksFooter);\n\nfunction hydraChecksFooter() {\n controller.$inject = [\"$scope\", \"UpdateService\", \"RequestsErrorHandler\", \"HydraAuthService\", \"$http\", \"$uibModal\", \"ConfigService\", \"GenericStorageService\", \"ModalService\", \"growl\", \"NotificationService\", \"bootstrapped\"];\n return {\n templateUrl: 'static/html/directives/checks-footer.html',\n controller: controller\n };\n\n function controller($scope, UpdateService, RequestsErrorHandler, HydraAuthService, $http, $uibModal, ConfigService, GenericStorageService, ModalService, growl, NotificationService, bootstrapped) {\n $scope.updateAvailable = false;\n $scope.checked = false;\n var welcomeIsBeingShown = false;\n\n $scope.mayUpdate = HydraAuthService.getUserInfos().maySeeAdmin;\n\n $scope.$on(\"user:loggedIn\", function () {\n if (HydraAuthService.getUserInfos().maySeeAdmin && !$scope.checked) {\n retrieveUpdateInfos();\n }\n });\n\n function checkForOutOfMemoryException() {\n GenericStorageService.get(\"outOfMemoryDetected\", false).then(function (response) {\n if (response.data !== \"\" && response.data) {\n //headline, message, params, size, textAlign\n ModalService.open(\"Out of memory error detected\", 'The log indicates that the process ran out of memory. Please increase the XMX value in the main config and restart.', {\n yes: {\n text: \"OK\"\n }\n }, undefined, \"left\");\n GenericStorageService.put(\"outOfMemoryDetected\", false, false);\n }\n });\n }\n\n function checkForOpenToInternet() {\n GenericStorageService.get(\"showOpenToInternetWithoutAuth\", false).then(function (response) {\n if (response.data !== \"\" && response.data) {\n //headline, message, params, size, textAlign\n ModalService.open(\"Security issue - open to internet\", 'It looks like NZBHydra is exposed to the internet without any authentication enable. Please make sure it cannot be reached from outside your network or enable an authentication method.', {\n yes: {\n text: \"OK\"\n }\n }, undefined, \"left\");\n GenericStorageService.put(\"showOpenToInternetWithoutAuth\", false, false);\n }\n });\n }\n\n console.log(\"Checking for below Java 17.\");\n\n function checkForJavaBelow17() {\n GenericStorageService.get(\"belowJava17\", false).then(function (response) {\n if (response.data !== \"\" && response.data) {\n console.log(\"Java below 17\");\n //headline, message, params, size, textAlign\n ModalService.open(\"Java version below 17\", 'You\\'re currently running NZBHydra2 with an older java version. A future update will require Java 17. Please install Java 17 (not higher) from here.', {\n yes: {\n text: \"OK\"\n }\n }, undefined, \"left\");\n GenericStorageService.put(\"belowJava17\", false, false);\n }\n });\n }\n\n console.log(\"Checking for failed backup.\");\n\n function checkForFailedBackup() {\n GenericStorageService.get(\"FAILED_BACKUP\", false).then(function (response) {\n if (response.data !== \"\" && response.data && !response.data) {\n console.log(\"Failed backup detected\");\n //headline, message, params, size, textAlign\n ModalService.open(\"Failed backup\", 'The creation of a backup file has failed. Error message: \\\"' + response.data.message + '.\" For details please check the log around ' + response.data.time + '.', {\n yes: {\n text: \"OK\"\n }\n }, undefined, \"left\");\n GenericStorageService.put(\"FAILED_BACKUP\", false, null);\n }\n });\n }\n\n function checkForOutdatedWrapper() {\n $http.get(\"internalapi/updates/isDisplayWrapperOutdated\").then(function (response) {\n var data = response.data;\n if (data !== undefined && data !== null && data) {\n ModalService.open(\"Outdated wrappers detected\", 'The NZBHydra wrappers (i.e. the executables or python scripts you use to run NZBHydra) seem to be outdated. Please update them.
\\n' +\n ' Shut down NZBHydra, download the latest version and extract all the relevant wrapper files into your main NZBHydra folder. \\n' +\n ' For Windows these files are:\\n' +\n '
\\n' +\n '
NZBHydra2.exe
\\n' +\n '
NZBHydra2 Console.exe
\\n' +\n '
\\n' +\n ' For linux these files are:\\n' +\n '
\\n' +\n '
nzbhydra2
\\n' +\n '
nzbhydra2wrapper.py
\\n' +\n '
nzbhydra2wrapperPy3.py
\\n' +\n '
\\n' +\n ' Make sure to overwrite all of these files that already exist - you don\\'t need to update any files that aren\\'t already present.\\n' +\n '
\\n' +\n ' Afterwards start NZBHydra again.', {\n yes: {\n text: \"OK\",\n onYes: function () {\n $http.put(\"internalapi/updates/setOutdatedWrapperDetectedWarningShown\")\n }\n }\n }, undefined, \"left\");\n\n }\n });\n }\n\n if ($scope.mayUpdate) {\n retrieveUpdateInfos();\n checkForOutOfMemoryException();\n checkForOutdatedWrapper();\n checkForOpenToInternet();\n checkForJavaBelow17();\n checkForFailedBackup();\n }\n\n function retrieveUpdateInfos() {\n $scope.checked = true;\n UpdateService.getInfos().then(function (response) {\n if (response) {\n $scope.currentVersion = response.data.currentVersion;\n $scope.latestVersion = response.data.latestVersion;\n $scope.latestVersionIsBeta = response.data.latestVersionIsBeta;\n $scope.updateAvailable = response.data.updateAvailable;\n $scope.changelog = response.data.changelog;\n $scope.updatedExternally = response.data.updatedExternally;\n $scope.showUpdateBannerOnUpdatedExternally = response.data.showUpdateBannerOnUpdatedExternally;\n $scope.showWhatsNewBanner = response.data.showWhatsNewBanner;\n if ($scope.updatedExternally && !$scope.showUpdateBannerOnUpdatedExternally) {\n $scope.updateAvailable = false;\n }\n $scope.automaticUpdateToNotice = response.data.automaticUpdateToNotice;\n\n\n $scope.$emit(\"showUpdateFooter\", $scope.updateAvailable);\n $scope.$emit(\"showAutomaticUpdateFooter\", $scope.automaticUpdateToNotice);\n } else {\n $scope.$emit(\"showUpdateFooter\", false);\n }\n });\n }\n\n $scope.update = function () {\n UpdateService.update($scope.latestVersion);\n };\n\n $scope.ignore = function () {\n UpdateService.ignore($scope.latestVersion);\n $scope.updateAvailable = false;\n $scope.$emit(\"showUpdateFooter\", $scope.updateAvailable);\n };\n\n $scope.showChangelog = function () {\n UpdateService.showChanges($scope.latestVersion);\n };\n\n $scope.showChangesFromAutomaticUpdate = function () {\n UpdateService.showChangesFromAutomaticUpdate();\n $scope.automaticUpdateToNotice = null;\n $scope.$emit(\"showAutomaticUpdateFooter\", false);\n };\n\n $scope.dismissChangesFromAutomaticUpdate = function () {\n $scope.automaticUpdateToNotice = null;\n $scope.$emit(\"showAutomaticUpdateFooter\", false);\n console.log(\"Dismissing showAutomaticUpdateFooter\");\n return $http.get(\"internalapi/updates/ackAutomaticUpdateVersionHistory\").then(function (response) {\n });\n };\n\n function checkAndShowNews() {\n RequestsErrorHandler.specificallyHandled(function () {\n if (ConfigService.getSafe().showNews) {\n $http.get(\"internalapi/news/forcurrentversion\").then(function (response) {\n var data = response.data;\n if (data && data.length > 0) {\n $uibModal.open({\n templateUrl: 'static/html/news-modal.html',\n controller: NewsModalInstanceCtrl,\n size: \"lg\",\n resolve: {\n news: function () {\n return data;\n }\n }\n });\n $http.put(\"internalapi/news/saveshown\");\n }\n });\n }\n });\n }\n\n function checkExpiredIndexers() {\n _.each(ConfigService.getSafe().indexers, function (indexer) {\n if (indexer.vipExpirationDate != null && indexer.vipExpirationDate !== \"Lifetime\") {\n var expiryWarning;\n var expiryDate = moment(indexer.vipExpirationDate, \"YYYY-MM-DD\");\n var messagePrefix = \"VIP access for indexer \" + indexer.name;\n if (expiryDate < moment()) {\n expiryWarning = messagePrefix + \" expired on \" + indexer.vipExpirationDate;\n } else if (expiryDate.subtract(7, 'days') < moment()) {\n expiryWarning = messagePrefix + \" will expire on \" + indexer.vipExpirationDate;\n }\n if (expiryWarning) {\n console.log(expiryWarning);\n growl.warning(expiryWarning);\n }\n }\n });\n }\n\n function checkAndShowWelcome() {\n RequestsErrorHandler.specificallyHandled(function () {\n $http.get(\"internalapi/welcomeshown\").then(function (response) {\n if (!response.data) {\n $http.put(\"internalapi/welcomeshown\");\n var promise = $uibModal.open({\n templateUrl: 'static/html/welcome-modal.html',\n controller: WelcomeModalInstanceCtrl,\n size: \"md\"\n });\n promise.opened.then(function () {\n welcomeIsBeingShown = true;\n });\n promise.closed.then(function () {\n welcomeIsBeingShown = false;\n });\n } else {\n if (HydraAuthService.getUserInfos().maySeeAdmin) {\n _.defer(checkAndShowNews);\n _.defer(checkExpiredIndexers);\n }\n }\n }, function () {\n console.log(\"Error while checking for welcome\")\n });\n });\n }\n\n checkAndShowWelcome();\n\n function showUnreadNotifications(unreadNotifications, stompClient) {\n if (unreadNotifications.length > ConfigService.getSafe().notificationConfig.displayNotificationsMax) {\n growl.info(unreadNotifications.length + ' notifications have piled up. Go to the notification history to view them.', {disableCountDown: true});\n for (var i = 0; i < unreadNotifications.length; i++) {\n if (unreadNotifications[i].id === undefined) {\n console.log(\"Undefined ID found for notification \" + unreadNotifications[i]);\n continue;\n }\n stompClient.send(\"/app/markNotificationRead\", {}, unreadNotifications[i].id);\n }\n return;\n }\n for (var j = 0; j < unreadNotifications.length; j++) {\n var notification = unreadNotifications[j];\n var body = notification.body.replace(\"\\n\", \" \");\n switch (notification.messageType) {\n case \"INFO\":\n growl.info(body);\n break;\n case \"SUCCESS\":\n growl.success(body);\n break;\n case \"WARNING\":\n growl.warning(body);\n break;\n case \"FAILURE\":\n growl.danger(body);\n break;\n }\n if (notification.id === undefined) {\n console.log(\"Undefined ID found for notification \" + unreadNotifications[i]);\n continue;\n }\n stompClient.send(\"/app/markNotificationRead\", {}, notification.id);\n }\n }\n\n if (ConfigService.getSafe().notificationConfig.displayNotifications && HydraAuthService.getUserInfos().maySeeAdmin) {\n var socket = new SockJS(bootstrapped.baseUrl + 'websocket');\n var stompClient = Stomp.over(socket);\n stompClient.debug = null;\n stompClient.connect({}, function (frame) {\n stompClient.subscribe('/topic/notifications', function (message) {\n showUnreadNotifications(JSON.parse(message.body), stompClient);\n });\n });\n }\n\n }\n}\n\nangular\n .module('nzbhydraApp')\n .controller('NewsModalInstanceCtrl', NewsModalInstanceCtrl);\n\nfunction NewsModalInstanceCtrl($scope, $uibModalInstance, news) {\n $scope.news = news;\n $scope.close = function () {\n $uibModalInstance.dismiss();\n };\n}\n\nangular\n .module('nzbhydraApp')\n .controller('WelcomeModalInstanceCtrl', WelcomeModalInstanceCtrl);\n\nfunction WelcomeModalInstanceCtrl($scope, $uibModalInstance, $state, MigrationService) {\n $scope.close = function () {\n $uibModalInstance.dismiss();\n };\n\n $scope.startMigration = function () {\n $uibModalInstance.dismiss();\n MigrationService.migrate();\n };\n\n $scope.goToConfig = function () {\n $uibModalInstance.dismiss();\n $state.go(\"root.config.main\");\n }\n}\n","/*\n * (C) Copyright 2017 TheOtherP (theotherp@posteo.net)\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nangular\n .module('nzbhydraApp')\n .directive('footer', footer);\n\nfunction footer() {\n controller.$inject = [\"$scope\", \"$http\", \"$uibModal\", \"ConfigService\", \"GenericStorageService\", \"bootstrapped\"];\n return {\n templateUrl: 'static/html/directives/footer.html',\n controller: controller\n };\n\n function controller($scope, $http, $uibModal, ConfigService, GenericStorageService, bootstrapped) {\n $scope.updateFooterBottom = 0;\n\n var safeConfig = bootstrapped.safeConfig;\n $scope.showDownloaderStatus = safeConfig.downloading.showDownloaderStatus && _.filter(safeConfig.downloading.downloaders, function (x) {\n return x.enabled\n }).length > 0;\n $scope.showUpdateFooter = false;\n\n $scope.$on(\"showDownloaderStatus\", function (event, doShow) {\n $scope.showDownloaderStatus = doShow;\n updateFooterBottom();\n updatePaddingBottom();\n });\n $scope.$on(\"showUpdateFooter\", function (event, doShow) {\n $scope.showUpdateFooter = doShow;\n updateFooterBottom();\n updatePaddingBottom();\n });\n $scope.$on(\"showAutomaticUpdateFooter\", function (event, doShow) {\n $scope.showAutomaticUpdateFooter = doShow;\n updateFooterBottom();\n updatePaddingBottom();\n });\n\n function updateFooterBottom() {\n\n if ($scope.showDownloaderStatus) {\n if ($scope.showAutomaticUpdateFooter) {\n $scope.updateFooterBottom = 20;\n } else {\n $scope.updateFooterBottom = 38;\n }\n } else {\n $scope.updateFooterBottom = 0;\n }\n }\n\n function updatePaddingBottom() {\n var paddingBottom = 0;\n if ($scope.showDownloaderStatus) {\n paddingBottom += 30;\n }\n if ($scope.showUpdateFooter) {\n paddingBottom += 40;\n }\n $scope.paddingBottom = paddingBottom;\n document.getElementById(\"wrap\").classList.remove(\"padding-bottom-0\");\n document.getElementById(\"wrap\").classList.remove(\"padding-bottom-30\");\n document.getElementById(\"wrap\").classList.remove(\"padding-bottom-40\");\n document.getElementById(\"wrap\").classList.remove(\"padding-bottom-70\");\n var paddingBottomClass = \"padding-bottom-\" + paddingBottom;\n document.getElementById(\"wrap\").classList.add(paddingBottomClass);\n }\n\n updatePaddingBottom();\n\n updateFooterBottom();\n\n\n }\n}\n\n","angular\r\n .module('nzbhydraApp').directive('focusOn', focusOn);\r\n\r\nfunction focusOn() {\r\n return directive;\r\n\r\n function directive(scope, elem, attr) {\r\n scope.$on('focusOn', function (e, name) {\r\n if (name === attr.focusOn) {\r\n elem[0].focus();\r\n }\r\n });\r\n }\r\n}\r\n","/*\n * (C) Copyright 2017 TheOtherP (theotherp@posteo.net)\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nangular\n .module('nzbhydraApp')\n .directive('downloaderStatusFooter', downloaderStatusFooter);\n\nfunction downloaderStatusFooter() {\n controller.$inject = [\"$scope\", \"$http\", \"RequestsErrorHandler\", \"HydraAuthService\", \"$interval\", \"bootstrapped\"];\n return {\n templateUrl: 'static/html/directives/downloader-status-footer.html',\n controller: controller\n };\n\n function controller($scope, $http, RequestsErrorHandler, HydraAuthService, $interval, bootstrapped) {\n\n var downloaderStatus;\n var updateInterval = null;\n console.log(\"websocket\");\n var socket = new SockJS(bootstrapped.baseUrl + 'websocket');\n var stompClient = Stomp.over(socket);\n stompClient.debug = null;\n stompClient.connect({}, function (frame) {\n stompClient.subscribe('/topic/downloaderStatus', function (message) {\n downloaderStatus = JSON.parse(message.body);\n updateFooter(downloaderStatus);\n });\n stompClient.send(\"/app/connectDownloaderStatus\", function (message) {\n downloaderStatus = JSON.parse(message.body);\n updateFooter(downloaderStatus);\n })\n });\n\n\n $scope.$emit(\"showDownloaderStatus\", true);\n var downloadRateCounter = 0;\n\n $scope.downloaderChart = {\n options: {\n chart: {\n type: 'stackedAreaChart',\n height: 35,\n width: 300,\n margin: {\n top: 5,\n right: 0,\n bottom: 0,\n left: 0\n },\n x: function (d) {\n return d.x;\n },\n y: function (d) {\n return d.y;\n },\n interactive: true,\n useInteractiveGuideline: false,\n transitionDuration: 0,\n showControls: false,\n showLegend: false,\n showValues: false,\n duration: 0,\n tooltip: {\n valueFormatter: function (d, i) {\n return d + \" kb/s\";\n },\n keyFormatter: function () {\n return \"\";\n },\n id: \"downloader-status-tooltip\"\n },\n css: \"float:right;\"\n }\n },\n data: [{values: [], key: \"Bla\", color: '#00a950'}],\n config: {\n refreshDataOnly: true,\n deepWatchDataDepth: 0,\n deepWatchData: false,\n deepWatchOptions: false\n }\n };\n\n function updateFooter() {\n if (downloaderStatus.lastUpdateForNow && updateInterval === null) {\n //Server will send no new status updates for a while because the last two retrieved statuses are the same.\n //We must still update the footer so that the graph doesn't stand still\n console.debug(\"Retrieved last update for now, starting update interval\");\n updateInterval = $interval(function () {\n //Just put the last known rate at the end to keep it going\n $scope.downloaderChart.data[0].values.splice(0, 1);\n $scope.downloaderChart.data[0].values.push({x: downloadRateCounter++, y: downloaderStatus.lastDownloadRate});\n try {\n $scope.api.update();\n } catch (ignored) {\n }\n if (_.every($scope.downloaderChart.data[0].values, function (value) {\n return value === downloaderStatus.lastDownloadRate\n })) {\n //The bar has been filled with the latest known value, we can now stop until we get a new update\n console.debug(\"Filled the bar with last known value, stopping update interval\");\n $interval.cancel(updateInterval);\n updateInterval = null;\n }\n }, 1000);\n } else if (updateInterval !== null && !downloaderStatus.lastUpdateForNow) {\n //New data is incoming, cancel interval\n console.debug(\"Got new update, stopping update interval\")\n $interval.cancel(updateInterval);\n updateInterval = null;\n }\n\n $scope.foo = downloaderStatus;\n $scope.foo.downloaderImage = downloaderStatus.downloaderType === 'NZBGET' ? 'nzbgetlogo' : 'sabnzbdlogo';\n $scope.foo.url = downloaderStatus.url;\n //We need to splice the variable with the rates because it's watched by angular and when overwriting it we would lose the watch and it wouldn't be updated\n var maxEntriesHistory = 200;\n if ($scope.downloaderChart.data[0].values.length < maxEntriesHistory) {\n //Not yet full, just fill up\n console.debug(\"Adding data, filling bar with initial values\")\n for (var i = $scope.downloaderChart.data[0].values.length; i < maxEntriesHistory; i++) {\n if (i >= downloaderStatus.downloadingRatesInKilobytes.length) {\n break;\n }\n $scope.downloaderChart.data[0].values.push({x: downloadRateCounter++, y: downloaderStatus.downloadingRatesInKilobytes[i]});\n }\n } else {\n console.debug(\"Adding data, moving bar\")\n //Remove first one, add to the end\n $scope.downloaderChart.data[0].values.splice(0, 1);\n $scope.downloaderChart.data[0].values.push({x: downloadRateCounter++, y: downloaderStatus.lastDownloadRate});\n }\n try {\n $scope.api.update();\n } catch (ignored) {\n }\n if ($scope.foo.state === \"DOWNLOADING\") {\n $scope.foo.buttonClass = \"play\";\n } else if ($scope.foo.state === \"PAUSED\") {\n $scope.foo.buttonClass = \"pause\";\n } else if ($scope.foo.state === \"OFFLINE\") {\n $scope.foo.buttonClass = \"off\";\n } else {\n $scope.foo.buttonClass = \"time\";\n }\n $scope.foo.state = $scope.foo.state.substr(0, 1) + $scope.foo.state.substr(1).toLowerCase();\n //Bad but without the state isn't updated\n $scope.$apply();\n }\n\n }\n}\n\n","angular\r\n .module('nzbhydraApp')\r\n .directive('downloadNzbzipButton', downloadNzbzipButton);\r\n\r\nfunction downloadNzbzipButton() {\r\n controller.$inject = [\"$scope\", \"growl\", \"$http\", \"FileDownloadService\"];\r\n return {\r\n templateUrl: 'static/html/directives/download-nzbzip-button.html',\r\n require: ['^searchResults'],\r\n scope: {\r\n searchResults: \"<\",\r\n searchTitle: \"<\",\r\n callback: \"&\"\r\n },\r\n controller: controller\r\n };\r\n\r\n\r\n function controller($scope, growl, $http, FileDownloadService) {\r\n $scope.download = function () {\r\n if (angular.isUndefined($scope.searchResults) || $scope.searchResults.length === 0) {\r\n growl.info(\"You should select at least one result...\");\r\n } else {\r\n var values = _.map($scope.searchResults, function (value) {\r\n return value.searchResultId;\r\n });\r\n var link = \"internalapi/nzbzip\";\r\n\r\n var searchTitle;\r\n if (angular.isDefined($scope.searchTitle)) {\r\n searchTitle = \" for \" + $scope.searchTitle.replace(\"[^a-zA-Z0-9.-]\", \"_\");\r\n } else {\r\n searchTitle = \"\";\r\n }\r\n var filename = \"NZBHydra NZBs\" + searchTitle + \".zip\";\r\n $http({method: \"post\", url: link, data: values}).then(function (response) {\r\n if (response.data.successful && response.data.zip !== null) {\r\n link = \"internalapi/nzbzipDownload\";\r\n FileDownloadService.downloadFile(link, filename, \"POST\", response.data.zipFilepath);\r\n if (angular.isDefined($scope.callback)) {\r\n $scope.callback({result: response.data.addedIds});\r\n }\r\n if (response.data.missedIds.length > 0) {\r\n growl.error(\"Unable to add \" + response.missedIds.length + \" out of \" + values.length + \" NZBs to ZIP\");\r\n }\r\n } else {\r\n growl.error(response.data.message);\r\n }\r\n }, function (data, status, headers, config) {\r\n growl.error(status);\r\n });\r\n }\r\n }\r\n }\r\n}\r\n\r\n","angular\r\n .module('nzbhydraApp')\r\n .directive('downloadNzbsButton', downloadNzbsButton);\r\n\r\nfunction downloadNzbsButton() {\r\n controller.$inject = [\"$scope\", \"$http\", \"NzbDownloadService\", \"ConfigService\", \"growl\"];\r\n return {\r\n templateUrl: 'static/html/directives/download-nzbs-button.html',\r\n require: ['^searchResults'],\r\n scope: {\r\n searchResults: \"<\",\r\n callback: \"&\"\r\n },\r\n controller: controller\r\n };\r\n\r\n function controller($scope, $http, NzbDownloadService, ConfigService, growl) {\r\n\r\n $scope.downloaders = NzbDownloadService.getEnabledDownloaders();\r\n $scope.blackholeEnabled = ConfigService.getSafe().downloading.saveTorrentsTo !== null;\r\n\r\n $scope.download = function (downloader) {\r\n if (angular.isUndefined($scope.searchResults) || $scope.searchResults.length === 0) {\r\n growl.info(\"You should select at least one result...\");\r\n } else {\r\n\r\n var didFilterOutResults = false;\r\n var didKeepAnyResults = false;\r\n var searchResults = _.filter($scope.searchResults, function (value) {\r\n if (value.downloadType === \"NZB\") {\r\n didKeepAnyResults = true;\r\n return true;\r\n } else {\r\n console.log(\"Not sending torrent result to downloader\");\r\n didFilterOutResults = true;\r\n return false;\r\n }\r\n });\r\n if (didFilterOutResults && !didKeepAnyResults) {\r\n growl.info(\"None of the selected results were NZBs. Adding aborted\");\r\n if (angular.isDefined($scope.callback)) {\r\n $scope.callback({result: []});\r\n }\r\n return;\r\n } else if (didFilterOutResults && didKeepAnyResults) {\r\n growl.info(\"Some the selected results are torrent results which were skipped\");\r\n }\r\n\r\n var tos = _.map(searchResults, function (entry) {\r\n return {searchResultId: entry.searchResultId, originalCategory: entry.originalCategory}\r\n });\r\n\r\n NzbDownloadService.download(downloader, tos).then(function (response) {\r\n if (angular.isDefined(response.data)) {\r\n if (response !== \"dismissed\") {\r\n if (response.data.successful) {\r\n if (response.data.message == null) {\r\n growl.info(\"Successfully added all NZBs\");\r\n } else {\r\n growl.warning(response.data.message);\r\n }\r\n } else {\r\n growl.error(response.data.message);\r\n }\r\n } else {\r\n growl.error(\"Error while adding NZBs\");\r\n }\r\n if (angular.isDefined($scope.callback)) {\r\n $scope.callback({result: response.data.addedIds});\r\n }\r\n }\r\n }, function () {\r\n growl.error(\"Error while adding NZBs\");\r\n });\r\n }\r\n };\r\n\r\n $scope.sendToBlackhole = function () {\r\n var didFilterOutResults = false;\r\n var didKeepAnyResults = false;\r\n var searchResults = _.filter($scope.searchResults, function (value) {\r\n if (value.downloadType === \"TORRENT\") {\r\n didKeepAnyResults = true;\r\n return true;\r\n } else {\r\n console.log(\"Not sending NZB result to black hole\");\r\n didFilterOutResults = true;\r\n return false;\r\n }\r\n });\r\n if (didFilterOutResults && !didKeepAnyResults) {\r\n growl.info(\"None of the selected results were torrents. Adding aborted\");\r\n if (angular.isDefined($scope.callback)) {\r\n $scope.callback({result: []});\r\n }\r\n return;\r\n } else if (didFilterOutResults && didKeepAnyResults) {\r\n growl.info(\"Some the selected results are NZB results which were skipped\");\r\n }\r\n var searchResultIds = _.pluck(searchResults, \"searchResultId\");\r\n $http.put(\"internalapi/saveTorrent\", searchResultIds).then(function (response) {\r\n if (response.data.successful) {\r\n growl.info(\"Successfully saved all torrents\");\r\n } else {\r\n growl.error(response.data.message);\r\n }\r\n if (angular.isDefined($scope.callback)) {\r\n $scope.callback({result: response.data.addedIds});\r\n }\r\n });\r\n }\r\n\r\n }\r\n}\r\n\r\n","\r\nfreetextFilter.$inject = [\"DebugService\"];\r\nbooleanFilter.$inject = [\"DebugService\"];angular\r\n .module('nzbhydraApp').directive(\"columnFilterWrapper\", columnFilterWrapper);\r\n\r\nfunction columnFilterWrapper() {\r\n controller.$inject = [\"$scope\", \"DebugService\"];\r\n return {\r\n restrict: \"E\",\r\n templateUrl: 'static/html/dataTable/columnFilterOuter.html',\r\n transclude: true,\r\n controllerAs: 'columnFilterWrapperCtrl',\r\n scope: {\r\n inline: \"@\"\r\n },\r\n bindToController: true,\r\n controller: controller,\r\n link: function (scope, element, attr, ctrl) {\r\n scope.element = element;\r\n }\r\n };\r\n\r\n function controller($scope, DebugService) {\r\n var vm = this;\r\n\r\n vm.open = false;\r\n vm.isActive = false;\r\n\r\n vm.toggle = function () {\r\n vm.open = !vm.open;\r\n if (vm.open) {\r\n $scope.$broadcast(\"opened\");\r\n }\r\n };\r\n\r\n vm.clear = function () {\r\n if (vm.open) {\r\n $scope.$broadcast(\"clear\");\r\n }\r\n };\r\n\r\n $scope.$on(\"filter\", function (event, column, filterModel, isActive, open) {\r\n vm.open = open || false;\r\n vm.isActive = isActive;\r\n });\r\n\r\n DebugService.log(\"filter-wrapper\");\r\n }\r\n\r\n}\r\n\r\n\r\nangular\r\n .module('nzbhydraApp').directive(\"freetextFilter\", freetextFilter);\r\n\r\nfunction freetextFilter(DebugService) {\r\n controller.$inject = [\"$scope\", \"focus\"];\r\n return {\r\n template: '',\r\n require: \"^columnFilterWrapper\",\r\n controllerAs: 'innerController',\r\n scope: {\r\n column: \"@\",\r\n onKey: \"@\",\r\n placeholder: \"@\",\r\n tooltip: \"@\"\r\n },\r\n controller: controller\r\n };\r\n\r\n function controller($scope, focus) {\r\n $scope.inline = $scope.$parent.$parent.columnFilterWrapperCtrl.inline; //Hacky way of getting the value from the outer wrapper\r\n $scope.data = {};\r\n $scope.tooltip = $scope.tooltip || \"\";\r\n\r\n $scope.$on(\"opened\", function () {\r\n focus(\"freetext-filter-input\");\r\n });\r\n\r\n function emitFilterEvent(isOpen) {\r\n isOpen = $scope.inline || isOpen;\r\n $scope.$emit(\"filter\", $scope.column, {\r\n filterValue: $scope.data.filter,\r\n filterType: \"freetext\"\r\n }, angular.isDefined($scope.data.filter) && $scope.data.filter.length > 0, isOpen);\r\n }\r\n\r\n $scope.$on(\"clear\", function () {\r\n //Don't clear but close window (event is fired when clicked outside)\r\n emitFilterEvent(false);\r\n });\r\n\r\n $scope.onKeyUp = function (keyEvent) {\r\n if (keyEvent.which === 13 || $scope.onKey) {\r\n emitFilterEvent($scope.onKey && keyEvent.which !== 13); //Keep open if triggered by key, close always when enter pressed\r\n }\r\n };\r\n DebugService.log(\"filter-freetext\");\r\n }\r\n}\r\n\r\nangular\r\n .module('nzbhydraApp').directive(\"checkboxesFilter\", checkboxesFilter);\r\n\r\nfunction checkboxesFilter() {\r\n controller.$inject = [\"$scope\", \"DebugService\"];\r\n return {\r\n template: '',\r\n controllerAs: 'checkboxesFilterController',\r\n scope: {\r\n column: \"@\",\r\n entries: \"<\",\r\n preselect: \"<\",\r\n showInvert: \"<\",\r\n isBoolean: \"<\"\r\n },\r\n controller: controller\r\n };\r\n\r\n function controller($scope, DebugService) {\r\n $scope.selected = {\r\n entries: []\r\n };\r\n $scope.active = false;\r\n\r\n if ($scope.preselect) {\r\n $scope.selected.entries.push.apply($scope.selected.entries, $scope.entries);\r\n }\r\n\r\n $scope.invert = function () {\r\n $scope.selected.entries = _.difference($scope.entries, $scope.selected.entries);\r\n };\r\n\r\n $scope.selectAll = function () {\r\n $scope.selected.entries.push.apply($scope.selected.entries, $scope.entries);\r\n };\r\n\r\n $scope.deselectAll = function () {\r\n $scope.selected.entries.splice(0, $scope.selected.entries.length);\r\n };\r\n\r\n $scope.apply = function () {\r\n $scope.active = $scope.selected.entries.length < $scope.entries.length;\r\n $scope.$emit(\"filter\", $scope.column, {\r\n filterValue: _.pluck($scope.selected.entries, \"id\"),\r\n filterType: \"checkboxes\",\r\n isBoolean: $scope.isBoolean\r\n }, $scope.active)\r\n };\r\n $scope.clear = function () {\r\n $scope.selectAll();\r\n $scope.active = false;\r\n $scope.$emit(\"filter\", $scope.column, {\r\n filterValue: undefined,\r\n filterType: \"checkboxes\",\r\n isBoolean: $scope.isBoolean\r\n }, $scope.active)\r\n };\r\n $scope.$on(\"clear\", $scope.clear);\r\n DebugService.log(\"filter-checkboxes\");\r\n }\r\n}\r\n\r\nangular\r\n .module('nzbhydraApp').directive(\"booleanFilter\", booleanFilter);\r\n\r\nfunction booleanFilter(DebugService) {\r\n controller.$inject = [\"$scope\"];\r\n return {\r\n template: '',\r\n controllerAs: 'booleanFilterController',\r\n scope: {\r\n column: \"@\",\r\n options: \"<\",\r\n preselect: \"@\"\r\n },\r\n controller: controller\r\n };\r\n\r\n\r\n function controller($scope) {\r\n $scope.selected = {value: $scope.options[$scope.preselect].value};\r\n $scope.active = false;\r\n\r\n $scope.apply = function () {\r\n $scope.active = $scope.selected.value !== $scope.options[0].value;\r\n $scope.$emit(\"filter\", $scope.column, {\r\n filterValue: $scope.selected.value,\r\n filterType: \"boolean\"\r\n }, $scope.active)\r\n };\r\n $scope.clear = function () {\r\n $scope.selected.value = true;\r\n $scope.active = false;\r\n $scope.$emit(\"filter\", $scope.column, {filterValue: undefined, filterType: \"boolean\"}, $scope.active)\r\n };\r\n $scope.$on(\"clear\", $scope.clear);\r\n DebugService.log(\"filter-boolean\");\r\n }\r\n}\r\n\r\nangular\r\n .module('nzbhydraApp').directive(\"timeFilter\", timeFilter);\r\n\r\nfunction timeFilter() {\r\n controller.$inject = [\"$scope\", \"DebugService\"];\r\n return {\r\n template: '',\r\n scope: {\r\n column: \"@\",\r\n selected: \"<\"\r\n },\r\n controller: controller\r\n };\r\n\r\n function controller($scope, DebugService) {\r\n\r\n $scope.dateOptions = {\r\n dateDisabled: false,\r\n formatYear: 'yy',\r\n startingDay: 1\r\n };\r\n\r\n $scope.formats = ['dd-MMMM-yyyy', 'yyyy/MM/dd', 'dd.MM.yyyy', 'shortDate'];\r\n $scope.format = $scope.formats[0];\r\n $scope.altInputFormats = ['M!/d!/yyyy'];\r\n $scope.active = false;\r\n\r\n $scope.openAfter = function () {\r\n $scope.after.opened = true;\r\n };\r\n\r\n $scope.openBefore = function () {\r\n $scope.before.opened = true;\r\n };\r\n\r\n $scope.after = {\r\n opened: false\r\n };\r\n\r\n $scope.before = {\r\n opened: false\r\n };\r\n\r\n $scope.apply = function () {\r\n $scope.active = $scope.selected.beforeDate || $scope.selected.afterDate;\r\n $scope.$emit(\"filter\", $scope.column, {\r\n filterValue: {\r\n after: $scope.selected.afterDate,\r\n before: $scope.selected.beforeDate\r\n }, filterType: \"time\"\r\n }, $scope.active)\r\n };\r\n $scope.clear = function () {\r\n $scope.selected.beforeDate = undefined;\r\n $scope.selected.afterDate = undefined;\r\n $scope.active = false;\r\n $scope.$emit(\"filter\", $scope.column, {filterValue: undefined, filterType: \"time\"}, $scope.active)\r\n };\r\n $scope.$on(\"clear\", $scope.clear);\r\n DebugService.log(\"filter-time\");\r\n }\r\n}\r\n\r\nangular\r\n .module('nzbhydraApp').directive(\"numberRangeFilter\", numberRangeFilter);\r\n\r\nfunction numberRangeFilter() {\r\n controller.$inject = [\"$scope\", \"DebugService\"];\r\n return {\r\n template: '',\r\n scope: {\r\n column: \"@\",\r\n min: \"<\",\r\n max: \"<\",\r\n addon: \"@\",\r\n tooltip: \"@\"\r\n },\r\n controller: controller\r\n };\r\n\r\n function controller($scope, DebugService) {\r\n $scope.filterValue = {min: undefined, max: undefined};\r\n $scope.active = false;\r\n\r\n function apply() {\r\n $scope.active = $scope.filterValue.min || $scope.filterValue.max;\r\n $scope.$emit(\"filter\", $scope.column, {\r\n filterValue: $scope.filterValue,\r\n filterType: \"numberRange\"\r\n }, $scope.active)\r\n }\r\n\r\n $scope.clear = function () {\r\n $scope.filterValue = {min: undefined, max: undefined};\r\n $scope.active = false;\r\n $scope.$emit(\"filter\", $scope.column, {\r\n filterValue: undefined,\r\n filterType: \"numberRange\",\r\n isBoolean: $scope.isBoolean\r\n }, $scope.active)\r\n };\r\n $scope.$on(\"clear\", $scope.clear);\r\n\r\n $scope.apply = function () {\r\n apply();\r\n };\r\n\r\n $scope.onKeypress = function (keyEvent) {\r\n if (keyEvent.which === 13) {\r\n apply();\r\n }\r\n };\r\n\r\n DebugService.log(\"filter-number\");\r\n }\r\n}\r\n\r\n\r\nangular\r\n .module('nzbhydraApp').directive(\"columnSortable\", columnSortable);\r\n\r\nfunction columnSortable() {\r\n controller.$inject = [\"$scope\"];\r\n return {\r\n restrict: \"E\",\r\n templateUrl: \"static/html/dataTable/columnSortable.html\",\r\n transclude: true,\r\n scope: {\r\n sortMode: \"<\", //0: no sorting, 1: asc, 2: desc\r\n column: \"@\",\r\n reversed: \"<\",\r\n startMode: \"<\"\r\n },\r\n controller: controller\r\n };\r\n\r\n function controller($scope) {\r\n if (angular.isUndefined($scope.sortMode)) {\r\n $scope.sortMode = 0;\r\n }\r\n\r\n if (angular.isUndefined($scope.startMode)) {\r\n $scope.startMode = 1;\r\n }\r\n\r\n $scope.sortModel = {\r\n sortMode: $scope.sortMode,\r\n column: $scope.column,\r\n reversed: $scope.reversed,\r\n startMode: $scope.startMode,\r\n active: false\r\n };\r\n\r\n $scope.$on(\"newSortColumn\", function (event, column, sortMode) {\r\n $scope.sortModel.active = column === $scope.sortModel.column;\r\n if (column !== $scope.sortModel.column) {\r\n $scope.sortModel.sortMode = 0;\r\n } else {\r\n $scope.sortModel.sortMode = sortMode;\r\n }\r\n });\r\n\r\n $scope.sort = function () {\r\n if ($scope.sortModel.sortMode === 0 || angular.isUndefined($scope.sortModel.sortMode)) {\r\n $scope.sortModel.sortMode = $scope.sortModel.startMode;\r\n } else if ($scope.sortModel.sortMode === 1) {\r\n $scope.sortModel.sortMode = 2;\r\n } else {\r\n $scope.sortModel.sortMode = 1;\r\n }\r\n $scope.$emit(\"sort\", $scope.sortModel.column, $scope.sortModel.sortMode, $scope.sortModel.reversed)\r\n };\r\n\r\n }\r\n}\r\n","angular\r\n .module('nzbhydraApp')\r\n .directive('connectionTest', connectionTest);\r\n\r\nfunction connectionTest() {\r\n controller.$inject = [\"$scope\"];\r\n return {\r\n templateUrl: 'static/html/directives/connection-test.html',\r\n require: ['^type', '^data'],\r\n scope: {\r\n type: \"=\",\r\n id: \"=\",\r\n data: \"=\",\r\n downloader: \"=\"\r\n },\r\n controller: controller\r\n };\r\n\r\n function controller($scope) {\r\n $scope.message = \"\";\r\n\r\n\r\n var testButton = \"#button-test-connection\";\r\n var testMessage = \"#message-test-connection\";\r\n\r\n function showSuccess() {\r\n angular.element(testButton).removeClass(\"btn-default\");\r\n angular.element(testButton).removeClass(\"btn-danger\");\r\n angular.element(testButton).addClass(\"btn-success\");\r\n }\r\n\r\n function showError() {\r\n angular.element(testButton).removeClass(\"btn-default\");\r\n angular.element(testButton).removeClass(\"btn-success\");\r\n angular.element(testButton).addClass(\"btn-danger\");\r\n }\r\n\r\n $scope.testConnection = function () {\r\n angular.element(testButton).addClass(\"glyphicon-refresh-animate\");\r\n var myInjector = angular.injector([\"ng\"]);\r\n var $http = myInjector.get(\"$http\");\r\n var url;\r\n var params;\r\n if ($scope.type === \"downloader\") {\r\n url = \"internalapi/test_downloader\";\r\n params = {name: $scope.downloader, username: $scope.data.username, password: $scope.data.password};\r\n if ($scope.downloader === \"SABNZBD\") {\r\n params.apiKey = $scope.data.apiKey;\r\n params.url = $scope.data.url;\r\n } else {\r\n params.host = $scope.data.host;\r\n params.port = $scope.data.port;\r\n params.ssl = $scope.data.ssl;\r\n }\r\n } else if ($scope.data.type === \"newznab\") {\r\n url = \"internalapi/test_newznab\";\r\n params = {host: $scope.data.host, apiKey: $scope.data.apiKey};\r\n if (angular.isDefined($scope.data.username)) {\r\n params[\"username\"] = $scope.data.username;\r\n params[\"password\"] = $scope.data.password;\r\n }\r\n }\r\n $http.get(url, {params: params}).then(function (result) {\r\n //Using ng-class and a scope variable doesn't work for some reason, is only updated at second click\r\n if (result.successful) {\r\n angular.element(testMessage).text(\"\");\r\n showSuccess();\r\n } else {\r\n angular.element(testMessage).text(result.message);\r\n showError();\r\n }\r\n\r\n }, function () {\r\n angular.element(testMessage).text(result.message);\r\n showError();\r\n }\r\n ).finally(function () {\r\n angular.element(testButton).removeClass(\"glyphicon-refresh-animate\");\r\n })\r\n }\r\n\r\n }\r\n}\r\n\r\n","//Taken from https://github.com/IamAdamJowett/angular-click-outside\r\n\r\nclickOutside.$inject = [\"$document\", \"$parse\", \"$timeout\"];\r\nfunction childOf(/*child node*/c, /*parent node*/p) { //returns boolean\r\n while ((c = c.parentNode) && c !== p) ;\r\n return !!c;\r\n}\r\n\r\nangular\r\n .module('nzbhydraApp').directive(\"clickOutside\", clickOutside);\r\n\r\n/**\r\n * @ngdoc directive\r\n * @name angular-click-outside.directive:clickOutside\r\n * @description Directive to add click outside capabilities to DOM elements\r\n * @requires $document\r\n * @requires $parse\r\n * @requires $timeout\r\n **/\r\nfunction clickOutside($document, $parse, $timeout) {\r\n return {\r\n restrict: 'A',\r\n link: function ($scope, elem, attr) {\r\n\r\n // postpone linking to next digest to allow for unique id generation\r\n $timeout(function () {\r\n var classList = (attr.outsideIfNot !== undefined) ? attr.outsideIfNot.split(/[ ,]+/) : [],\r\n fn;\r\n\r\n function eventHandler(e) {\r\n var i,\r\n element,\r\n r,\r\n id,\r\n classNames,\r\n l;\r\n\r\n // check if our element already hidden and abort if so\r\n if (angular.element(elem).hasClass(\"ng-hide\")) {\r\n return;\r\n }\r\n\r\n // if there is no click target, no point going on\r\n if (!e || !e.target) {\r\n return;\r\n }\r\n\r\n if (angular.isDefined(attr.outsideIgnore) && $scope.$eval(attr.outsideIgnore)) {\r\n return;\r\n }\r\n var isChild = childOf(e.target, elem.context);\r\n if (isChild) {\r\n return;\r\n }\r\n // loop through the available elements, looking for classes in the class list that might match and so will eat\r\n for (element = e.target; element; element = element.parentNode) {\r\n // check if the element is the same element the directive is attached to and exit if so (props @CosticaPuntaru)\r\n if (element === elem[0]) {\r\n return;\r\n }\r\n\r\n // now we have done the initial checks, start gathering id's and classes\r\n id = element.id,\r\n classNames = element.className,\r\n l = classList.length;\r\n\r\n // Unwrap SVGAnimatedString classes\r\n if (classNames && classNames.baseVal !== undefined) {\r\n classNames = classNames.baseVal;\r\n }\r\n\r\n // if there are no class names on the element clicked, skip the check\r\n if (classNames || id) {\r\n\r\n // loop through the elements id's and classnames looking for exceptions\r\n for (i = 0; i < l; i++) {\r\n //prepare regex for class word matching\r\n r = new RegExp('\\\\b' + classList[i] + '\\\\b');\r\n\r\n // check for exact matches on id's or classes, but only if they exist in the first place\r\n if ((id !== undefined && id === classList[i]) || (classNames && r.test(classNames))) {\r\n // now let's exit out as it is an element that has been defined as being ignored for clicking outside\r\n return;\r\n }\r\n }\r\n }\r\n }\r\n\r\n // if we have got this far, then we are good to go with processing the command passed in via the click-outside attribute\r\n $timeout(function () {\r\n fn = $parse(attr['clickOutside']);\r\n fn($scope, {event: e});\r\n });\r\n }\r\n\r\n // if the devices has a touchscreen, listen for this event\r\n if (_hasTouch()) {\r\n $document.on('touchstart', eventHandler);\r\n }\r\n\r\n // still listen for the click event even if there is touch to cater for touchscreen laptops\r\n $document.on('click', eventHandler);\r\n\r\n // when the scope is destroyed, clean up the documents event handlers as we don't want it hanging around\r\n $scope.$on('$destroy', function () {\r\n if (_hasTouch()) {\r\n $document.off('touchstart', eventHandler);\r\n }\r\n\r\n $document.off('click', eventHandler);\r\n });\r\n\r\n /**\r\n * @description Private function to attempt to figure out if we are on a touch device\r\n * @private\r\n **/\r\n function _hasTouch() {\r\n // works on most browsers, IE10/11 and Surface\r\n return 'ontouchstart' in window || navigator.maxTouchPoints;\r\n }\r\n });\r\n }\r\n };\r\n}\r\n","angular\r\n .module('nzbhydraApp')\r\n .directive('cfgFormEntry', cfgFormEntry);\r\n\r\nfunction cfgFormEntry() {\r\n return {\r\n templateUrl: 'static/html/directives/cfg-form-entry.html',\r\n require: [\"^title\", \"^cfg\"],\r\n scope: {\r\n title: \"@\",\r\n cfg: \"=\",\r\n help: \"@\",\r\n type: \"@?\",\r\n options: \"=?\"\r\n },\r\n controller: [\"$scope\", \"$element\", \"$attrs\", function ($scope, $element, $attrs) {\r\n $scope.type = angular.isDefined($scope.type) ? $scope.type : 'text';\r\n $scope.options = angular.isDefined($scope.type) ? $scope.$eval($attrs.options) : [];\r\n }]\r\n };\r\n}","angular\n .module('nzbhydraApp')\n .directive('hydrabackup', hydrabackup);\n\nfunction hydrabackup() {\n controller.$inject = [\"$scope\", \"BackupService\", \"Upload\", \"FileDownloadService\", \"$http\", \"RequestsErrorHandler\", \"growl\", \"RestartService\"];\n return {\n templateUrl: 'static/html/directives/backup.html',\n controller: controller\n };\n\n function controller($scope, BackupService, Upload, FileDownloadService, $http, RequestsErrorHandler, growl, RestartService) {\n $scope.refreshBackupList = function () {\n BackupService.getBackupsList().then(function (backups) {\n $scope.backups = backups;\n });\n };\n\n $scope.refreshBackupList();\n\n $scope.uploadActive = false;\n\n\n $scope.createBackupFile = function () {\n $http.get(\"internalapi/backup/backuponly\", {params: {dontdownload: true}}).then(function () {\n $scope.refreshBackupList();\n });\n };\n $scope.createAndDownloadBackupFile = function () {\n FileDownloadService.downloadFile(\"internalapi/backup/backup\", \"nzbhydra-backup-\" + moment().format(\"YYYY-MM-DD-HH-mm\") + \".zip\", \"GET\").then(function () {\n $scope.refreshBackupList();\n });\n };\n\n $scope.uploadBackupFile = function (file, errFiles) {\n RequestsErrorHandler.specificallyHandled(function () {\n\n $scope.file = file;\n $scope.errFile = errFiles && errFiles[0];\n if (file) {\n $scope.uploadActive = true;\n file.upload = Upload.upload({\n url: 'internalapi/backup/restorefile',\n file: file\n });\n\n file.upload.then(function (response) {\n if (response.data.successful) {\n $scope.uploadActive = false;\n RestartService.startCountdown(\"Upload successful. Restarting for wrapper to restore data.\");\n } else {\n file.progress = 0;\n growl.error(response.data.message)\n }\n\n }, function (response) {\n growl.error(response.data.message)\n }, function (evt) {\n file.progress = Math.min(100, parseInt(100.0 * evt.loaded / evt.total));\n file.loaded = Math.floor(evt.loaded / 1024);\n file.total = Math.floor(evt.total / 1024);\n });\n }\n });\n };\n\n $scope.restoreFromFile = function (filename) {\n BackupService.restoreFromFile(filename).then(function () {\n RestartService.startCountdown(\"Extraction of backup successful. Restarting for wrapper to restore data.\");\n },\n function (response) {\n growl.error(response.data);\n })\n }\n\n }\n}\n\n","\naddableNzbs.$inject = [\"DebugService\"];angular\n .module('nzbhydraApp')\n .directive('addableNzbs', addableNzbs);\n\nfunction addableNzbs(DebugService) {\n controller.$inject = [\"$scope\", \"NzbDownloadService\"];\n return {\n templateUrl: 'static/html/directives/addable-nzbs.html',\n require: [],\n scope: {\n searchresult: \"<\",\n alwaysAsk: \"<\"\n },\n controller: controller\n };\n\n function controller($scope, NzbDownloadService) {\n $scope.alwaysAsk = $scope.alwaysAsk === \"true\";\n $scope.downloaders = _.filter(NzbDownloadService.getEnabledDownloaders(), function (downloader) {\n if ($scope.searchresult.downloadType !== \"NZB\") {\n return downloader.downloadType === $scope.searchresult.downloadType\n }\n return true;\n });\n }\n}\n","\r\naddableNzb.$inject = [\"DebugService\"];angular\r\n .module('nzbhydraApp')\r\n .directive('addableNzb', addableNzb);\r\n\r\nfunction addableNzb(DebugService) {\r\n controller.$inject = [\"$scope\", \"NzbDownloadService\", \"growl\"];\r\n return {\r\n templateUrl: 'static/html/directives/addable-nzb.html',\r\n scope: {\r\n searchresult: \"=\",\r\n downloader: \"<\",\r\n alwaysAsk: \"<\"\r\n },\r\n controller: controller\r\n };\r\n\r\n function controller($scope, NzbDownloadService, growl) {\r\n if (!_.isNullOrEmpty($scope.downloader.iconCssClass)) {\r\n $scope.cssClass = \"fa fa-\" + $scope.downloader.iconCssClass.replace(\"fa-\", \"\").replace(\"fa \", \"\");\r\n } else {\r\n $scope.cssClass = $scope.downloader.downloaderType === \"SABNZBD\" ? \"sabnzbd\" : \"nzbget\";\r\n }\r\n\r\n $scope.add = function () {\r\n var originalClass = $scope.cssClass;\r\n $scope.cssClass = \"nzb-spinning\";\r\n NzbDownloadService.download($scope.downloader, [{\r\n searchResultId: $scope.searchresult.searchResultId ? $scope.searchresult.searchResultId : $scope.searchresult.id,\r\n originalCategory: $scope.searchresult.originalCategory,\r\n mappedCategory: $scope.searchresult.category\r\n }], $scope.alwaysAsk).then(function (response) {\r\n if (response !== \"dismissed\") {\r\n if (response.data.successful && (response.data.addedIds != null && response.data.addedIds.indexOf(Number($scope.searchresult.searchResultId)) > -1)) {\r\n $scope.cssClass = $scope.downloader.downloaderType === \"SABNZBD\" ? \"sabnzbd-success\" : \"nzbget-success\";\r\n } else {\r\n $scope.cssClass = $scope.downloader.downloaderType === \"SABNZBD\" ? \"sabnzbd-error\" : \"nzbget-error\";\r\n growl.error(response.data.message);\r\n }\r\n } else {\r\n $scope.cssClass = originalClass;\r\n }\r\n }, function () {\r\n $scope.cssClass = $scope.downloader.downloaderType === \"SABNZBD\" ? \"sabnzbd-error\" : \"nzbget-error\";\r\n growl.error(\"An unexpected error occurred while trying to contact NZBHydra or add the NZB.\");\r\n })\r\n };\r\n }\r\n}","/*\n * (C) Copyright 2017 TheOtherP (theotherp@posteo.net)\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nCheckCapsModalInstanceCtrl.$inject = [\"$scope\", \"$interval\", \"$http\", \"$timeout\", \"growl\", \"capsCheckRequest\"];\nIndexerConfigBoxService.$inject = [\"$http\", \"$q\", \"$uibModal\"];\nIndexerCheckBeforeCloseService.$inject = [\"$q\", \"ModalService\", \"IndexerConfigBoxService\", \"growl\", \"blockUI\"];\nfunction regexValidator(regex, message, prefixViewValue, preventEmpty) {\n return {\n expression: function ($viewValue, $modelValue) {\n var value = $modelValue || $viewValue;\n if (value) {\n if (Array.isArray(value)) {\n for (var i = 0; i < value.length; i++) {\n if (!regex.test(value[i])) {\n return false;\n }\n }\n return true;\n } else {\n return regex.test(value);\n }\n }\n return !preventEmpty;\n },\n message: (prefixViewValue ? '$viewValue + \" ' : '\" ') + message + '\"'\n };\n}\n\nfunction getIndexerBoxFields(indexerModel, parentModel, isInitial, CategoriesService) {\n var fieldset = [];\n if (indexerModel.searchModuleType === \"TORZNAB\") {\n fieldset.push({\n type: 'help',\n templateOptions: {\n type: 'help',\n lines: [\"Torznab indexers can only be used for internal searches or dedicated searches using /torznab/api\"]\n }\n });\n }\n if ((indexerModel.searchModuleType === \"NEWZNAB\" || indexerModel.searchModuleType === \"TORZNAB\") && !isInitial && indexerModel.searchModuleType !== 'JACKETT_CONFIG') {\n var message;\n var cssClass;\n if (!indexerModel.configComplete) {\n message = \"The config of this indexer is incomplete. Please click the button at the bottom to check its capabilities and complete its configuration.\";\n cssClass = \"alert alert-danger\";\n } else {\n message = \"The capabilities of this indexer were not checked completely. Some actually supported search types or IDs may not be usable.\";\n cssClass = \"alert alert-warning\";\n }\n fieldset.push({\n type: 'help',\n hideExpression: 'model.allCapsChecked && model.configComplete',\n templateOptions: {\n type: 'help',\n lines: [message],\n class: cssClass\n }\n });\n }\n\n var stateHelp = \"\";\n if (indexerModel.state === \"DISABLED_SYSTEM_TEMPORARY\" || indexerModel.state === \"DISABLED_SYSTEM\") {\n if (indexerModel.state === \"DISABLED_SYSTEM_TEMPORARY\") {\n stateHelp = \"The indexer was disabled by the program due to an error. It will be reenabled automatically or you can enable it manually\";\n } else {\n stateHelp = \"The indexer was disabled by the program due to error from which it cannot recover by itself. Try checking the caps to make sure it works or just enable it and see what happens.\";\n }\n }\n\n if (indexerModel.searchModuleType === 'NEWZNAB' || indexerModel.searchModuleType === 'TORZNAB') {\n fieldset.push(\n {\n key: 'name',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n label: 'Name',\n required: true\n },\n validators: {\n uniqueName: {\n expression: function (viewValue) {\n if (isInitial || viewValue !== indexerModel.name) {\n return _.pluck(parentModel, \"name\").indexOf(viewValue) === -1;\n }\n return true;\n },\n message: '\"Indexer \\\\\"\" + $viewValue + \"\\\\\" already exists\"'\n },\n noComma:\n {\n expression: function ($viewValue, $modelValue) {\n var value = $modelValue || $viewValue;\n if (value) {\n return value.indexOf(\",\") === -1;\n }\n return true;\n },\n message: '\"Name may not contain a comma\"'\n }\n }\n })\n }\n\n if (indexerModel.searchModuleType !== 'JACKETT_CONFIG') {\n fieldset.push({\n key: 'state',\n type: 'horizontalIndexerStateSwitch',\n templateOptions: {\n type: 'switch',\n label: 'State',\n help: stateHelp\n }\n });\n }\n\n if (['WTFNZB', 'NEWZNAB', 'TORZNAB', 'JACKETT_CONFIG'].includes(indexerModel.searchModuleType)) {\n var hostField = {\n key: 'host',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n label: 'Host',\n required: true,\n placeholder: 'http://www.someindexer.com'\n },\n watcher: {\n listener: function (field, newValue, oldValue, scope) {\n if (newValue !== oldValue) {\n scope.$parent.needsConnectionTest = true;\n }\n }\n }\n };\n if (indexerModel.searchModuleType === 'TORZNAB') {\n hostField.templateOptions.help = 'If you use Jackett and have an external URL use that one';\n }\n fieldset.push(\n hostField\n );\n }\n\n if (['WTFNZB', 'NEWZNAB', 'TORZNAB', 'JACKETT_CONFIG', 'NZBINDEX_API'].includes(indexerModel.searchModuleType) && indexerModel.host !== 'https://feed.animetosho.org') {\n fieldset.push(\n {\n key: 'apiKey',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n label: 'API Key'\n },\n watcher: {\n listener: function (field, newValue, oldValue, scope) {\n if (newValue !== oldValue) {\n scope.$parent.needsConnectionTest = true;\n }\n }\n }\n }\n )\n }\n\n if (['NEWZNAB', 'TORZNAB'].includes(indexerModel.searchModuleType)) {\n fieldset.push(\n {\n key: 'apiPath',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n label: 'API path',\n help: 'Path to the API. If empty /api is used',\n required: false,\n advanced: true\n },\n watcher: {\n listener: function (field, newValue, oldValue, scope) {\n if (newValue !== oldValue) {\n scope.$parent.needsConnectionTest = true;\n }\n }\n }\n }\n )\n }\n\n if (['NEWZNAB', 'TORZNAB', 'JACKETT_CONFIG'].includes(indexerModel.searchModuleType)) {\n fieldset.push(\n {\n key: 'username',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n required: false,\n label: 'Username',\n help: 'Only needed if indexer requires HTTP auth for API access (rare).'\n },\n watcher: {\n listener: function (field, newValue, oldValue, scope) {\n if (newValue !== oldValue) {\n scope.$parent.needsConnectionTest = true;\n }\n }\n }\n }\n );\n }\n\n if ('WTFNZB' === indexerModel.searchModuleType) {\n fieldset.push(\n {\n key: 'username',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n required: true,\n label: 'Username',\n help: 'See the API help on the website. Copy the user ID from the example API request where it says i=<yourUserId> (e.g. ABg4Cd==)'\n },\n watcher: {\n listener: function (field, newValue, oldValue, scope) {\n if (newValue !== oldValue) {\n scope.$parent.needsConnectionTest = true;\n }\n }\n }\n }\n );\n fieldset.push(\n {\n key: 'password',\n type: 'passwordSwitch',\n hideExpression: '!model.username',\n templateOptions: {\n type: 'text',\n required: false,\n label: 'Password',\n help: 'Only needed if indexer requires HTTP auth for API access (rare).'\n }\n }\n )\n }\n\n\n if (indexerModel.searchModuleType !== 'JACKETT_CONFIG') {\n fieldset.push(\n {\n key: 'score',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'Priority',\n required: true,\n help: 'When duplicate search results are found the result from the indexer with the highest number will be selected.',\n tooltip: 'The priority determines which indexer is used if duplicate results are found (i.e. results that link to the same upload, not just results with the same name). The result from the indexer with the highest number is shown first in the GUI and returned for API searches.'\n\n }\n });\n }\n\n fieldset.push(\n {\n key: 'timeout',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'Timeout',\n min: 1,\n help: 'Supercedes the general timeout in \"Searching\".',\n advanced: true\n }\n },\n {\n key: 'schedule',\n type: 'horizontalChips',\n templateOptions: {\n type: 'text',\n label: 'Schedule',\n help: 'Determines when an indexer should be selected. See wiki. You can enter multiple time spans. Apply values with return key.',\n advanced: true\n }\n }\n );\n\n if (['NEWZNAB', 'TORZNAB'].includes(indexerModel.searchModuleType)) {\n fieldset.push(\n {\n key: 'hitLimit',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'API hit limit',\n help: 'Maximum number of API hits since \"API hit reset time\".',\n tooltip: 'When the maximum number of API hits is reached the indexer isn\\'t used anymore. Only API hits done by NZBHydra are taken into account.'\n },\n validators: {\n greaterThanZero: {\n expression: function ($viewValue, $modelValue) {\n var value = $modelValue || $viewValue;\n return _.isNullOrEmpty(value) || value > 0;\n },\n message: '\"Value must be greater than 0\"'\n }\n }\n },\n {\n key: 'downloadLimit',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'Download limit',\n help: 'When # of downloads since \"Hit reset time\" is reached indexer will not be searched.'\n },\n validators: {\n greaterThanZero: {\n expression: function ($viewValue, $modelValue) {\n var value = $modelValue || $viewValue;\n return _.isNullOrEmpty(value) || value > 0;\n },\n message: '\"Value must be greater than 0\"'\n }\n }\n }\n );\n fieldset.push(\n {\n key: 'hitLimitResetTime',\n type: 'horizontalInput',\n hideExpression: '!model.hitLimit && !model.downloadLimit',\n templateOptions: {\n type: 'number',\n label: 'Hit reset time',\n help: 'UTC hour of day at which the API hit counter is reset (0-23). Leave empty for a rolling reset counter.',\n tooltip: 'Either define the time of day when the counter is reset by the indexer or leave it empty to use a rolling reset counter, meaning the number of hits for the last 24h at the time of the search is limited.'\n },\n validators: {\n timeOfDay: {\n expression: function ($viewValue, $modelValue) {\n var value = $modelValue || $viewValue;\n return value >= 0 && value <= 23;\n },\n message: '$viewValue + \" is not a valid hour of day (0-23)\"'\n }\n }\n },\n {\n key: 'loadLimitOnRandom',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'Load limiting',\n help: 'If set indexer will only be picked for one out of x API searches (on average).',\n tooltip: 'For indexers with a low API hit limit you can enable load limiting. Define any number n so that the indexer will only be used for searches in 1/n cases (on average). For example if you define a load limit of 5 the indexer will only be picked every fifth search.',\n advanced: true\n },\n validators: {\n greaterThanZero: {\n expression: function ($viewValue, $modelValue) {\n var value = $modelValue || $viewValue;\n return _.isNullOrEmpty(value) || value > 1;\n },\n message: '\"Value must be greater than 1\"'\n }\n }\n }\n );\n }\n if (indexerModel.searchModuleType === 'TORZNAB') {\n fieldset.push({\n key: 'minSeeders',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'Minimum # seeders',\n help: 'Torznab results with fewer seeders will be ignored. Supercedes any setting made in the searching config.'\n }\n })\n }\n\n if (['NEWZNAB', 'TORZNAB', 'WTFNZB'].includes(indexerModel.searchModuleType)) {\n fieldset.push(\n {\n key: 'userAgent',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n required: false,\n label: 'User agent',\n help: 'Rarely needed. Will supercede the one in the main searching settings.',\n advanced: true\n }\n }\n )\n }\n\n if (['NEWZNAB', 'TORZNAB'].includes(indexerModel.searchModuleType)) {\n fieldset.push(\n {\n key: 'customParameters',\n type: 'horizontalChips',\n templateOptions: {\n type: 'text',\n required: false,\n label: 'Custom parameters',\n help: 'Define custom parameters to be sent to the indexer when searching. Use the format \"name=value\"Apply values with return key.',\n advanced: 'true'\n }\n }\n )\n }\n\n fieldset.push(\n {\n key: 'preselect',\n type: 'horizontalSwitch',\n hideExpression: 'model.enabledForSearchSource===\"EXTERNAL\"',\n templateOptions: {\n type: 'switch',\n label: 'Preselect',\n help: 'Preselect this indexer on the search page.'\n }\n }\n );\n fieldset.push(\n {\n key: 'enabledForSearchSource',\n type: 'horizontalSelect',\n templateOptions: {\n label: 'Enable for...',\n options: [\n {name: 'Internal searches only', value: 'INTERNAL'},\n {name: 'API searches only', value: 'API'},\n {name: 'All but API update queries ', value: 'ALL_BUT_RSS'},\n {name: 'Only API update queries ', value: 'ONLY_RSS'},\n {name: 'Internal and any API searches', value: 'BOTH'}\n ],\n help: 'Select for which searches this indexer will be used. \"Update queries\" are searches without query or ID (e.g. done by Sonarr periodically).',\n advanced: true\n }\n }\n );\n\n fieldset.push(\n {\n key: 'color',\n type: 'colorInput',\n templateOptions: {\n label: 'Color',\n help: 'If set it will be used in the search results to mark the indexer\\'s results.',\n tooltip: 'To mark expanded results they\\'re shown in a darker shade so it\\'s recommended to use indexer colors which not only differ in lightness',\n advanced: true\n }\n }\n );\n\n fieldset.push(\n {\n key: 'vipExpirationDate',\n type: 'horizontalInput',\n templateOptions: {\n required: false,\n label: 'VIP expiry',\n help: 'Enter when your VIP access expires and NZBHydra will track it and warn you when close to expiry. Enter as YYYY-MM-DD or \"Lifetime\".'\n },\n validators: {\n port: regexValidator(/^(\\d{4}-\\d{2}-\\d{2})|Lifetime$/, \"is no valid date (must be 'YYYY-MM-DD' or 'Lifetime')\", true, false)\n }\n }\n );\n\n if (indexerModel.searchModuleType !== \"ANIZB\" && indexerModel.searchModuleType !== 'JACKETT_CONFIG') {\n var cats = CategoriesService.getWithoutAll();\n var options = _.map(cats, function (x) {\n return {id: x.name, label: x.name}\n });\n fieldset.push(\n {\n key: 'enabledCategories',\n type: 'horizontalMultiselect',\n templateOptions: {\n label: 'Categories',\n help: 'Only use indexer when searching for these and also reject results from others. Selecting none equals selecting all.',\n options: options,\n settings: {\n showSelectedValues: false,\n noSelectedText: \"None/All\"\n },\n advanced: true\n }\n }\n );\n }\n\n\n if ((['NEWZNAB', 'TORZNAB'].includes(indexerModel.searchModuleType)) && !isInitial && indexerModel.searchModuleType !== 'JACKETT_CONFIG') {\n fieldset.push(\n {\n key: 'supportedSearchIds',\n type: 'horizontalMultiselect',\n templateOptions: {\n label: 'Search IDs',\n options: [\n {label: 'IMDB (TV)', id: 'TVIMDB'},\n {label: 'TVDB', id: 'TVDB'},\n {label: 'TVRage', id: 'TVRAGE'},\n {label: 'Trakt', id: 'TRAKT'},\n {label: 'TVMaze', id: 'TVMAZE'},\n {label: 'IMDB', id: 'IMDB'},\n {label: 'TMDB', id: 'TMDB'}\n ],\n noSelectedText: \"None\",\n advanced: true\n }\n }\n );\n fieldset.push(\n {\n key: 'supportedSearchTypes',\n type: 'horizontalMultiselect',\n templateOptions: {\n label: 'Search types',\n options: [\n {label: 'Audio', id: 'AUDIO'},\n {label: 'Ebooks', id: 'BOOK'},\n {label: 'Movies', id: 'MOVIE'},\n {label: 'Search', id: 'SEARCH'},\n {label: 'TV', id: 'TVSEARCH'}\n ],\n buttonText: \"None\",\n advanced: true\n }\n }\n );\n fieldset.push(\n {\n type: 'horizontalCheckCaps',\n hideExpression: '!model.host || !model.name',\n templateOptions: {\n label: 'Check capabilities',\n help: 'Find out what search types and IDs the indexer supports.',\n tooltip: 'The first time an indexer is added the connection is tested. When successful the supported search IDs and types are checked. These determine if indexers allow searching for movies, shows or ebooks using meta data like the IMDB id or the author and title. Newznab indexers cannot be used until this check was completed. Click this button to execute the caps check again.'\n }\n }\n )\n }\n\n if (indexerModel.searchModuleType === 'NZBINDEX') {\n fieldset.push(\n {\n key: 'generalMinSize',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'Min size',\n help: 'NZBIndex returns a lot of crap with small file sizes. Set this value and all smaller results will be filtered out no matter the category'\n }\n }\n );\n }\n\n if (indexerModel.searchModuleType === 'BINSEARCH') {\n fieldset.push({\n key: 'binsearchOtherGroups',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Search in other groups',\n help: 'If disabled binsearch will only search in the most popular usenet groups'\n }\n })\n }\n\n return fieldset;\n}\n\nfunction _showBox(indexerModel, parentModel, isInitial, $uibModal, CategoriesService, mode, form, callback) {\n var modalInstance = $uibModal.open({\n templateUrl: 'static/html/config/indexer-config-box.html',\n controller: 'IndexerConfigBoxInstanceController',\n size: 'lg',\n resolve: {\n model: function () {\n indexerModel.showAdvanced = parentModel.showAdvanced;\n return indexerModel;\n },\n fields: function () {\n return getIndexerBoxFields(indexerModel, parentModel, isInitial, CategoriesService, mode);\n },\n form: function () {\n return form;\n },\n isInitial: function () {\n return isInitial\n },\n parentModel: function () {\n return parentModel;\n }\n ,\n info: function () {\n return indexerModel.info;\n }\n }\n });\n\n\n modalInstance.result.then(function (returnedModel) {\n form.$setDirty(true);\n if (angular.isDefined(callback)) {\n callback(true, returnedModel);\n }\n }, function () {\n if (angular.isDefined(callback)) {\n callback(false);\n }\n });\n}\n\nangular\n .module('nzbhydraApp')\n .config([\"formlyConfigProvider\", function config(formlyConfigProvider) {\n\n formlyConfigProvider.setType({\n name: 'indexers',\n templateUrl: 'static/html/config/indexer-config.html',\n controller: function ($scope, $uibModal, growl, CategoriesService) {\n $scope.showBox = showBox;\n $scope.formOptions = {formState: $scope.formState};\n $scope.showPresetSelection = showPresetSelection;\n\n function showPresetSelection() {\n $uibModal.open({\n templateUrl: 'static/html/config/indexer-config-selection.html',\n controller: 'IndexerConfigSelectionBoxInstanceController',\n size: 'lg',\n resolve: {\n model: function () {\n return $scope.model;\n },\n form: function () {\n return $scope.form;\n }\n }\n });\n }\n\n //Called when clicking the box of an existing indexer\n function showBox(indexerModel, model) {\n _showBox(indexerModel, model, false, $uibModal, CategoriesService, \"indexer\", $scope.form)\n }\n\n }\n });\n }]);\n\n\nangular.module('nzbhydraApp').controller('IndexerConfigSelectionBoxInstanceController', [\"$scope\", \"$q\", \"$uibModalInstance\", \"$uibModal\", \"$http\", \"model\", \"form\", \"growl\", \"CategoriesService\", \"$timeout\", \"ModalService\", \"RequestsErrorHandler\", function ($scope, $q, $uibModalInstance, $uibModal, $http, model, form, growl, CategoriesService, $timeout, ModalService, RequestsErrorHandler) {\n\n $scope.showBox = showBox;\n $scope.isInitial = false;\n\n $scope.select = function (modelPreset) {\n\n addEntry(modelPreset);\n $timeout(function () {\n $uibModalInstance.close();\n },\n 200);\n };\n\n $scope.readJackettConfig = function () {\n var indexerModel = createIndexerModel();\n indexerModel.searchModuleType = \"JACKETT_CONFIG\";\n indexerModel.isInitial = false;\n indexerModel.host = \"http://127.0.0.1:9117\";\n indexerModel.name = \"Jackett config\";\n _showBox(indexerModel, model, true, $uibModal, CategoriesService, \"jackettConfig\", form, function (isSubmitted, returnedModel) {\n if (isSubmitted) {\n //User pushed button, now we read the config\n RequestsErrorHandler.specificallyHandled(function () {\n $http.post(\"internalapi/indexer/readJackettConfig\", {existingIndexers: model, jackettConfig: returnedModel}, {\n headers: {\n \"Accept\": \"application/json;charset=utf-8\",\n \"Accept-Charset\": \"charset=utf-8\"\n }\n }).then(function (response) {\n //Replace model with new result\n model.splice(0, model.length);\n _.each(response.data.newIndexersConfig, function (x) {\n model.push(x);\n });\n growl.info(\"Added \" + response.data.addedTrackers + \" new trackers from Jackett\");\n growl.info(\"Updated \" + response.data.updatedTrackers + \" trackers from Jackett\");\n\n }, function (response) {\n ModalService.open(\"Error reading jackett config\", response.data, {}, \"md\", \"left\");\n });\n });\n }\n });\n\n $timeout(function () {\n $uibModalInstance.close();\n },\n 200);\n };\n\n function showBox(indexerModel, model) {\n _showBox(indexerModel, model, false, $uibModal, CategoriesService, \"indexer\", form)\n }\n\n function createIndexerModel() {\n return angular.copy({\n allCapsChecked: false,\n apiKey: null,\n backend: 'NEWZNAB',\n color: null,\n configComplete: false,\n categoryMapping: null,\n downloadLimit: null,\n enabledCategories: [],\n enabledForSearchSource: \"BOTH\",\n generalMinSize: null,\n hitLimit: null,\n hitLimitResetTime: 0,\n host: null,\n loadLimitOnRandom: null,\n name: null,\n password: null,\n preselect: true,\n score: 0,\n searchModuleType: 'NEWZNAB',\n showOnSearch: true,\n state: \"ENABLED\",\n supportedSearchIds: undefined,\n supportedSearchTypes: undefined,\n timeout: null,\n username: null,\n userAgent: null\n });\n }\n\n function addEntry(preset) {\n if (checkAddingAllowed(model, preset)) {\n var indexerModel = createIndexerModel();\n if (angular.isDefined(preset)) {\n _.extend(indexerModel, preset);\n }\n\n $scope.isInitial = true;\n\n _showBox(indexerModel, model, true, $uibModal, CategoriesService, \"indexer\", form, function (isSubmitted, returnedModel) {\n if (isSubmitted) {\n //Here is where the entry is actually added to the model\n model.push(angular.isDefined(returnedModel) ? returnedModel : indexerModel);\n }\n });\n } else {\n growl.error(\"That predefined indexer is already configured.\"); //For now this is the only case where adding is forbidden so we use this hardcoded message \"for now\"... (;-))\n }\n }\n\n function checkAddingAllowed(existingIndexers, preset) {\n if (!preset || !(preset.searchModuleType === \"ANIZB\" || preset.searchModuleType === \"BINSEARCH\" || preset.searchModuleType === \"NZBINDEX\" || preset.searchModuleType === \"NZBCLUB\")) {\n return true;\n }\n return !_.any(existingIndexers, function (existingEntry) {\n return existingEntry.name === preset.name;\n });\n }\n\n $scope.newznabPresets = [\n {\n name: \"abNZB\",\n host: \"https://abnzb.com/\"\n },\n {\n name: \"altHUB\",\n host: \"https://api.althub.co.za\"\n },\n {\n name: \"Animetosho (Newznab)\",\n host: \"https://feed.animetosho.org\",\n categories: [\"Anime\"],\n supportedSearchIds: [],\n supportedSearchTypes: [\"SEARCH\"],\n allCapsChecked: true,\n configComplete: true,\n categoryMapping: {\n anime: 5070,\n audiobook: null,\n comic: null,\n ebook: null,\n magazine: null,\n categories: [\n {\n id: 5070,\n name: \"Anime\",\n subCategories: []\n }\n ]\n }\n },\n {\n name: \"DogNZB\",\n host: \"https://api.dognzb.cr\"\n },\n {\n name: \"Drunken Slug\",\n host: \"https://api.drunkenslug.com\"\n },\n {\n name: \"FastNZB\",\n host: \"https://fastnzb.com\"\n },\n {\n name: \"LuluNZB\",\n host: \"https://lulunzb.com\"\n },\n {\n name: \"miatrix\",\n host: \"https://www.miatrix.com\"\n },\n {\n name: \"NZB Finder\",\n host: \"https://nzbfinder.ws\"\n },\n {\n name: \"NZBCat\",\n host: \"https://nzb.cat\"\n },\n {\n name: \"nzb.su\",\n host: \"https://api.nzb.su\"\n },\n {\n name: \"NZBGeek\",\n host: \"https://api.nzbgeek.info\"\n },\n {\n name: \"NzbNdx\",\n host: \"https://www.nzbndx.com\"\n },\n {\n name: \"NzBNooB\",\n host: \"https://www.nzbnoob.com\"\n },\n {\n name: \"NzbNation\",\n host: \"http://www.nzbnation.com/\"\n },\n {\n name: \"nzbplanet\",\n host: \"https://nzbplanet.net\"\n },\n {\n name: \"omgwtfnzbs\",\n host: \"https://api.omgwtfnzbs.org\"\n },\n {\n name: \"SceneNZBs\",\n host: \"https://scenenzbs.com\",\n info: \"If you want german or spanish results make sure to add the newznab IDs in the categories config. For example for german UHD movies add 2145. You can find out the IDs by browsing the categories on the indexer website.\"\n },\n {\n name: \"spotweb.com\",\n host: \"https://spotweb.me\"\n },\n {\n name: \"Tabula-Rasa\",\n host: \"https://www.tabula-rasa.pw/api/v1/\"\n },\n {\n allCapsChecked: true,\n enabledForSearchSource: \"INTERNAL\",\n categories: [],\n configComplete: true,\n downloadLimit: null,\n hitLimit: null,\n hitLimitResetTime: null,\n host: \"https://binsearch.info\",\n loadLimitOnRandom: null,\n name: \"Binsearch\",\n password: null,\n preselect: true,\n score: 0,\n showOnSearch: true,\n state: \"ENABLED\",\n supportedSearchIds: [],\n supportedSearchTypes: [],\n timeout: null,\n searchModuleType: \"BINSEARCH\",\n username: null\n },\n {\n allCapsChecked: true,\n enabledForSearchSource: \"INTERNAL\",\n categories: [],\n configComplete: true,\n downloadLimit: null,\n generalMinSize: 1,\n hitLimit: null,\n hitLimitResetTime: null,\n host: \"https://nzbindex.com\",\n loadLimitOnRandom: null,\n name: \"NZBIndex\",\n password: null,\n preselect: true,\n score: 0,\n showOnSearch: true,\n state: \"ENABLED\",\n supportedSearchIds: [],\n supportedSearchTypes: [],\n timeout: null,\n searchModuleType: \"NZBINDEX\",\n username: null\n },\n {\n allCapsChecked: true,\n enabledForSearchSource: \"INTERNAL\",\n categories: [],\n configComplete: true,\n downloadLimit: null,\n generalMinSize: 1,\n hitLimit: null,\n hitLimitResetTime: null,\n host: \"https://api.nzbindex.com\",\n loadLimitOnRandom: null,\n name: \"NZBIndex API\",\n password: null,\n preselect: true,\n score: 0,\n showOnSearch: true,\n state: \"ENABLED\",\n supportedSearchIds: [],\n supportedSearchTypes: [],\n timeout: null,\n searchModuleType: \"NZBINDEX_API\",\n username: null\n },\n {\n allCapsChecked: true,\n enabledForSearchSource: \"INTERNAL\",\n categories: [],\n configComplete: true,\n downloadLimit: null,\n generalMinSize: 1,\n hitLimit: null,\n hitLimitResetTime: null,\n host: \"https://beta.nzbindex.com/search\",\n loadLimitOnRandom: null,\n name: \"NZBIndex Beta\",\n password: null,\n preselect: true,\n score: 0,\n showOnSearch: true,\n state: \"ENABLED\",\n supportedSearchIds: [],\n supportedSearchTypes: [],\n timeout: null,\n searchModuleType: \"NZBINDEX_BETA\",\n username: null\n },\n {\n allCapsChecked: true,\n enabledForSearchSource: \"INTERNAL\",\n categories: [],\n configComplete: true,\n downloadLimit: null,\n hitLimit: null,\n hitLimitResetTime: null,\n host: \"https://www.nzbking.com/search\",\n loadLimitOnRandom: null,\n name: \"NZBKing.com\",\n password: null,\n preselect: true,\n score: 0,\n showOnSearch: true,\n state: \"ENABLED\",\n supportedSearchIds: [],\n supportedSearchTypes: [],\n timeout: null,\n searchModuleType: \"NZBKING\",\n username: null\n },\n {\n allCapsChecked: true,\n enabledForSearchSource: \"INTERNAL\",\n categories: [],\n configComplete: true,\n downloadLimit: null,\n generalMinSize: 1,\n hitLimit: null,\n hitLimitResetTime: null,\n host: null,\n loadLimitOnRandom: null,\n name: \"WtfNzb\",\n password: null,\n preselect: true,\n score: 0,\n showOnSearch: true,\n state: \"ENABLED\",\n supportedSearchIds: [],\n supportedSearchTypes: [],\n timeout: null,\n searchModuleType: \"WTFNZB\",\n username: null,\n userAgent: null\n }\n ];\n\n $scope.newznabPresets = _.sortBy($scope.newznabPresets, function (entry) {\n return entry.name.toLowerCase()\n });\n\n $scope.torznabPresets = [\n {\n allCapsChecked: false,\n configComplete: false,\n name: \"Jackett/Cardigann\",\n host: \"http://127.0.0.1:9117/api/v2.0/indexers/YOURTRACKER/results/torznab/\",\n supportedSearchIds: undefined,\n supportedSearchTypes: undefined,\n searchModuleType: \"TORZNAB\",\n state: \"ENABLED\",\n enabledForSearchSource: \"BOTH\"\n },\n {\n categories: [\"Anime\"],\n allCapsChecked: true,\n configComplete: true,\n name: \"Animetosho (Torznab)\",\n host: \"https://feed.animetosho.org\",\n supportedSearchIds: [],\n supportedSearchTypes: [\"SEARCH\"],\n searchModuleType: \"TORZNAB\",\n state: \"ENABLED\",\n enabledForSearchSource: \"BOTH\"\n }\n ];\n\n $scope.emptyTorznabPreset = {\n allCapsChecked: false,\n configComplete: false,\n supportedSearchIds: undefined,\n supportedSearchTypes: undefined,\n searchModuleType: \"TORZNAB\",\n state: \"ENABLED\",\n enabledForSearchSource: \"BOTH\"\n };\n $scope.torznabPresets = _.sortBy($scope.torznabPresets, function (entry) {\n return entry.name.toLowerCase()\n });\n}]);\n\n\nangular.module('nzbhydraApp').controller('IndexerConfigBoxInstanceController', [\"$scope\", \"$q\", \"$uibModalInstance\", \"$http\", \"model\", \"form\", \"fields\", \"isInitial\", \"parentModel\", \"growl\", \"IndexerCheckBeforeCloseService\", function ($scope, $q, $uibModalInstance, $http, model, form, fields, isInitial, parentModel, growl, IndexerCheckBeforeCloseService) {\n\n $scope.model = model;\n $scope.fields = fields;\n $scope.isInitial = isInitial;\n $scope.spinnerActive = false;\n $scope.needsConnectionTest = false;\n\n $scope.obSubmit = function () {\n if (model.searchModuleType === 'JACKETT_CONFIG') {\n $uibModalInstance.close(model);\n } else if (form.$valid) {\n var a = IndexerCheckBeforeCloseService.checkBeforeClose($scope, model).then(function (data) {\n if (angular.isDefined(data)) {\n $scope.model = data;\n }\n $uibModalInstance.close(data);\n });\n } else {\n growl.error(\"Config invalid. Please check your settings.\");\n angular.forEach(form.$error, function (error) {\n angular.forEach(error, function (field) {\n field.$setTouched();\n });\n });\n }\n };\n\n $scope.cancel = function () {\n $uibModalInstance.dismiss();\n };\n\n $scope.deleteEntry = function () {\n parentModel.splice(parentModel.indexOf(model), 1);\n $uibModalInstance.close($scope);\n };\n\n $scope.reset = function () {\n //Reset the model twice (for some reason when we do it once the search types / ids fields are empty, resetting again fixes that... (wtf))\n $scope.options.resetModel();\n $scope.options.resetModel();\n };\n\n $scope.$on(\"modal.closing\", function (targetScope, reason) {\n if (reason === \"backdrop click\") {\n $scope.reset($scope);\n }\n });\n}]);\n\n\nangular\n .module('nzbhydraApp')\n .controller('CheckCapsModalInstanceCtrl', CheckCapsModalInstanceCtrl);\n\nfunction CheckCapsModalInstanceCtrl($scope, $interval, $http, $timeout, growl, capsCheckRequest) {\n\n var updateMessagesInterval = undefined;\n\n $scope.messages = undefined;\n $http.post(\"internalapi/indexer/checkCaps\", capsCheckRequest).then(function (response) {\n $scope.$close([response.data, capsCheckRequest.indexerConfig]);\n if (response.data.length === 0) {\n growl.info(\"No indexers were checked\");\n }\n }, function () {\n $scope.$dismiss(\"Unknown error\")\n });\n\n $timeout(\n updateMessagesInterval = $interval(function () {\n $http.get(\"internalapi/indexer/checkCapsMessages\").then(function (response) {\n var map = response.data;\n var messages = [];\n for (var name in map) {\n if (map.hasOwnProperty(name)) {\n for (var i = 0; i < map[name].length; i++) {\n var message = \"\";\n if (capsCheckRequest.checkType !== \"SINGLE\") {\n message += name + \": \";\n }\n message += map[name][i];\n messages.push(message);\n }\n }\n }\n $scope.messages = messages;\n });\n\n }, 500),\n 500);\n\n\n $scope.$on('$destroy', function () {\n if (angular.isDefined(updateMessagesInterval)) {\n $interval.cancel(updateMessagesInterval);\n }\n });\n}\n\nangular\n .module('nzbhydraApp')\n .factory('IndexerConfigBoxService', IndexerConfigBoxService);\n\nfunction IndexerConfigBoxService($http, $q, $uibModal) {\n\n return {\n checkConnection: checkConnection,\n checkCaps: checkCaps\n };\n\n function checkConnection(url, settings) {\n var deferred = $q.defer();\n\n $http.post(url, settings).then(function (result) {\n //Using ng-class and a scope variable doesn't work for some reason, is only updated at second click\n if (result.data.successful) {\n deferred.resolve({checked: true, message: null, model: result.data});\n } else {\n deferred.reject({checked: true, message: result.data.message});\n }\n }, function (result) {\n deferred.reject({checked: false, message: result.data.message});\n });\n\n return deferred.promise;\n }\n\n function checkCaps(capsCheckRequest) {\n var deferred = $q.defer();\n\n var result = $uibModal.open({\n templateUrl: 'static/html/checker-state.html',\n controller: CheckCapsModalInstanceCtrl,\n size: \"md\",\n backdrop: \"static\",\n backdropClass: \"waiting-cursor\",\n resolve: {\n capsCheckRequest: function () {\n return capsCheckRequest;\n }\n }\n });\n\n result.result.then(function (data) {\n deferred.resolve(data[0], data[1]);\n }, function (message) {\n deferred.reject(message);\n });\n\n return deferred.promise;\n }\n\n}\n\nangular\n .module('nzbhydraApp')\n .factory('IndexerCheckBeforeCloseService', IndexerCheckBeforeCloseService);\n\nfunction IndexerCheckBeforeCloseService($q, ModalService, IndexerConfigBoxService, growl, blockUI) {\n\n return {\n checkBeforeClose: checkBeforeClose\n };\n\n function checkBeforeClose(scope, model) {\n var deferred = $q.defer();\n if (model.searchModuleType === 'JACKETT_CONFIG') {\n deferred.resolve(model);\n } else if (!scope.isInitial && (!scope.needsConnectionTest || scope.form.capsChecked)) {\n checkCapsWhenClosing(scope, model).then(function () {\n deferred.resolve(model);\n }, function () {\n deferred.reject();\n });\n } else {\n scope.spinnerActive = true;\n blockUI.start(\"Testing connection...\");\n var url = \"internalapi/indexer/checkConnection\";\n IndexerConfigBoxService.checkConnection(url, model).then(function () {\n growl.info(\"Connection to the indexer tested successfully\");\n checkCapsWhenClosing(scope, model).then(function (data) {\n scope.spinnerActive = false;\n blockUI.reset();\n deferred.resolve(data);\n }, function () {\n scope.spinnerActive = false;\n blockUI.reset();\n deferred.reject();\n });\n },\n function (data) {\n scope.spinnerActive = false;\n blockUI.reset();\n handleConnectionCheckFail(ModalService, data, model, \"indexer\", deferred);\n });\n }\n return deferred.promise;\n }\n\n //Called when the indexer dialog is closed\n function checkCapsWhenClosing(scope, model) {\n var deferred = $q.defer();\n if (angular.isUndefined(model.supportedSearchIds) || angular.isUndefined(model.supportedSearchTypes)) {\n\n blockUI.start(\"New indexer found. Testing its capabilities. This may take a bit...\");\n IndexerConfigBoxService.checkCaps({indexerConfig: model, checkType: \"SINGLE\"}).then(\n function (data) {\n data = data[0]; //We get a list of results (with one result because the check type is single)\n blockUI.reset();\n scope.spinnerActive = false;\n if (data.allCapsChecked && data.configComplete) {\n growl.info(\"Successfully tested capabilites of indexer\");\n } else if (!data.allCapsChecked && data.configComplete) {\n ModalService.open(\"Incomplete caps check\", \"The capabilities of the indexer could not be checked completely. You may use it but it's recommended to repeat the check at another time. Until then some search types or IDs may not be usable.\", {}, \"md\", \"left\");\n } else if (!data.configComplete) {\n ModalService.open(\"Error testing capabilities\", \"An error occurred while contacting the indexer. It will not be usable until the caps check has been executed. You can trigger it manually from the indexer config box\", {}, \"md\", \"left\");\n }\n\n deferred.resolve(data.indexerConfig);\n },\n function () {\n blockUI.reset();\n scope.spinnerActive = false;\n model.supportedSearchIds = undefined;\n model.supportedSearchTypes = undefined;\n ModalService.open(\"Error testing capabilities\", \"An error occurred while contacting the indexer. It will not be usable until the caps check has been executed. You can trigger it manually using the button below.\", {}, \"md\", \"left\");\n deferred.resolve();\n }).finally(\n function () {\n scope.spinnerActive = false;\n })\n } else {\n deferred.resolve();\n }\n return deferred.promise;\n }\n}\n","/*\n * (C) Copyright 2017 TheOtherP (theotherp@posteo.net)\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\nDownloaderConfigBoxService.$inject = [\"$http\", \"$q\", \"$uibModal\"];\nDownloaderCheckBeforeCloseService.$inject = [\"$q\", \"DownloaderConfigBoxService\", \"growl\", \"ModalService\", \"blockUI\"];\nangular\n .module('nzbhydraApp')\n .config([\"formlyConfigProvider\", function config(formlyConfigProvider) {\n\n formlyConfigProvider.setType({\n name: 'downloaderConfig',\n templateUrl: 'static/html/config/downloader-config.html',\n controller: function ($scope, $uibModal, growl, CategoriesService, localStorageService) {\n $scope.formOptions = {formState: $scope.formState};\n $scope._showBox = _showBox;\n $scope.showBox = showBox;\n $scope.isInitial = false;\n $scope.presets = [\n {\n name: \"NZBGet\",\n downloaderType: \"NZBGET\",\n username: \"nzbgetx\",\n nzbAddingType: \"UPLOAD\",\n nzbAccessType: \"REDIRECT\",\n iconCssClass: \"\",\n downloadType: \"NZB\",\n url: \"http://nzbget:tegbzn6789@localhost:6789\"\n },\n {\n url: \"http://localhost:8080\",\n downloaderType: \"SABNZBD\",\n name: \"SABnzbd\",\n nzbAddingType: \"UPLOAD\",\n nzbAccessType: \"REDIRECT\",\n iconCssClass: \"\",\n downloadType: \"NZB\"\n }\n ];\n\n function _showBox(model, parentModel, isInitial, callback) {\n var modalInstance = $uibModal.open({\n templateUrl: 'static/html/config/downloader-config-box.html',\n controller: 'DownloaderConfigBoxInstanceController',\n size: 'lg',\n resolve: {\n model: function () {\n //Isn't properly stored in parentmodel for some reason, this works just as well\n model.showAdvanced = localStorageService.get(\"showAdvanced\");\n console.log(model.showAdvanced);\n return model;\n },\n fields: function () {\n return getDownloaderBoxFields(model, parentModel, isInitial, angular.injector(), CategoriesService);\n },\n isInitial: function () {\n return isInitial\n },\n parentModel: function () {\n return parentModel;\n },\n data: function () {\n return $scope.options.data;\n }\n }\n });\n\n\n modalInstance.result.then(function (returnedModel) {\n $scope.form.$setDirty(true);\n if (angular.isDefined(callback)) {\n callback(true, returnedModel);\n }\n }, function () {\n if (angular.isDefined(callback)) {\n callback(false);\n }\n });\n }\n\n function showBox(model, parentModel) {\n $scope._showBox(model, parentModel, false)\n }\n\n $scope.addEntry = function (entriesCollection, preset) {\n var model = angular.copy({\n enabled: true\n });\n if (angular.isDefined(preset)) {\n _.extend(model, preset);\n }\n\n $scope.isInitial = true;\n\n $scope._showBox(model, entriesCollection, true, function (isSubmitted, returnedModel) {\n if (isSubmitted) {\n //Here is where the entry is actually added to the model\n entriesCollection.push(angular.isDefined(returnedModel) ? returnedModel : model);\n }\n });\n };\n\n function getDownloaderBoxFields(model, parentModel, isInitial) {\n var fieldset = [];\n\n fieldset = _.union(fieldset, [\n {\n key: 'enabled',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Enabled'\n }\n },\n {\n key: 'name',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n label: 'Name',\n required: true\n },\n validators: {\n uniqueName: {\n expression: function (viewValue) {\n if (isInitial || viewValue !== model.name) {\n return _.pluck(parentModel, \"name\").indexOf(viewValue) === -1;\n }\n return true;\n },\n message: '\"Downloader \\\\\"\" + $viewValue + \"\\\\\" already exists\"'\n }\n }\n\n },\n {\n key: 'url',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n label: 'URL',\n help: 'URL with scheme and full path',\n required: true\n },\n watcher: {\n listener: function (field, newValue, oldValue, scope) {\n if (newValue !== oldValue) {\n scope.$parent.needsConnectionTest = true;\n }\n }\n }\n }\n ]);\n\n\n if (model.downloaderType === \"SABNZBD\") {\n fieldset.push({\n key: 'apiKey',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n label: 'API Key'\n },\n watcher: {\n listener: function (field, newValue, oldValue, scope) {\n if (newValue !== oldValue) {\n scope.$parent.needsConnectionTest = true;\n }\n }\n }\n })\n } else if (model.downloaderType === \"NZBGET\") {\n fieldset.push({\n key: 'username',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n label: 'Username'\n },\n watcher: {\n listener: function (field, newValue, oldValue, scope) {\n if (newValue !== oldValue) {\n scope.$parent.needsConnectionTest = true;\n }\n }\n }\n });\n fieldset.push({\n key: 'password',\n type: 'passwordSwitch',\n templateOptions: {\n type: 'text',\n label: 'Password'\n },\n watcher: {\n listener: function (field, newValue, oldValue, scope) {\n if (newValue !== oldValue) {\n scope.$parent.needsConnectionTest = true;\n }\n }\n }\n })\n }\n\n fieldset = _.union(fieldset, [\n {\n key: 'defaultCategory',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n label: 'Default category',\n help: 'When adding NZBs this category will be used instead of asking for the category. Write \"Use original category\", \"Use no category\" or \"Use mapped category\" to not be asked.',\n placeholder: 'Ask when downloading'\n }\n },\n {\n key: 'nzbAddingType',\n type: 'horizontalSelect',\n templateOptions: {\n type: 'select',\n label: 'NZB adding type',\n options: [\n {name: 'Send link', value: 'SEND_LINK'},\n {name: 'Upload NZB', value: 'UPLOAD'}\n ],\n help: \"How NZBs are added to the downloader, either by sending a link to the NZB or by uploading the NZB data.\",\n tooltip: 'You can select if you want to upload the NZB to the downloader or send a Hydra link. The downloader will do the download itself. This is a matter of taste, but adding a link and redirecting the downloader is the fastest way.' +\n ' Usually the links are determined using the URL via which you call it in your browser. If your downloader cannot access NZBHydra using that URL you can set a specific URL to be used in the main downloading config.',\n advanced: true\n }\n },\n {\n key: 'addPaused',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Add paused',\n help: 'Add NZBs paused',\n advanced: true\n }\n },\n {\n key: 'iconCssClass',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n label: 'Icon CSS class',\n help: 'Copy an icon name from https://fontawesome.com/v4.7.0/icons/ (e.g. \"film\")',\n placeholder: 'Default',\n tooltip: 'If you have multiple downloaders of the same type you can select an icon from the Font Awesome library. This icon will be shown in the search results and the NZB download history instead of the default downloader icon.',\n advanced: true\n }\n }\n ]);\n\n return fieldset;\n }\n }\n });\n }]);\n\n\nangular\n .module('nzbhydraApp')\n .factory('DownloaderConfigBoxService', DownloaderConfigBoxService);\n\nfunction DownloaderConfigBoxService($http, $q, $uibModal) {\n\n return {\n checkConnection: checkConnection,\n checkCaps: checkCaps\n };\n\n function checkConnection(url, settings) {\n var deferred = $q.defer();\n\n $http.post(url, settings).then(function (result) {\n //Using ng-class and a scope variable doesn't work for some reason, is only updated at second click\n if (result.data.successful) {\n deferred.resolve({checked: true, message: null, model: result.data});\n } else {\n deferred.reject({checked: true, message: result.data.message});\n }\n }, function (result) {\n deferred.reject({checked: false, message: result.data.message});\n });\n\n return deferred.promise;\n }\n\n function checkCaps(capsCheckRequest) {\n var deferred = $q.defer();\n\n var result = $uibModal.open({\n templateUrl: 'static/html/checker-state.html',\n controller: CheckCapsModalInstanceCtrl,\n size: \"md\",\n backdrop: \"static\",\n backdropClass: \"waiting-cursor\",\n resolve: {\n capsCheckRequest: function () {\n return capsCheckRequest;\n }\n }\n });\n\n result.result.then(function (data) {\n deferred.resolve(data[0], data[1]);\n }, function (message) {\n deferred.reject(message);\n });\n\n return deferred.promise;\n }\n}\n\nangular.module('nzbhydraApp').controller('DownloaderConfigBoxInstanceController', [\"$scope\", \"$q\", \"$uibModalInstance\", \"$http\", \"model\", \"fields\", \"isInitial\", \"parentModel\", \"data\", \"growl\", \"DownloaderCheckBeforeCloseService\", function ($scope, $q, $uibModalInstance, $http, model, fields, isInitial, parentModel, data, growl, DownloaderCheckBeforeCloseService) {\n\n $scope.model = model;\n $scope.fields = fields;\n $scope.isInitial = isInitial;\n $scope.spinnerActive = false;\n $scope.needsConnectionTest = false;\n\n $scope.obSubmit = function () {\n if ($scope.form.$valid) {\n var a = DownloaderCheckBeforeCloseService.checkBeforeClose($scope, model).then(function (data) {\n if (angular.isDefined(data)) {\n $scope.model = data;\n }\n $uibModalInstance.close(data);\n });\n } else {\n growl.error(\"Config invalid. Please check your settings.\");\n angular.forEach($scope.form.$error, function (error) {\n angular.forEach(error, function (field) {\n field.$setTouched();\n });\n });\n }\n };\n\n $scope.cancel = function () {\n $uibModalInstance.dismiss();\n };\n\n $scope.deleteEntry = function () {\n parentModel.splice(parentModel.indexOf(model), 1);\n $uibModalInstance.close($scope);\n };\n\n $scope.reset = function () {\n if (angular.isDefined(data.resetFunction)) {\n //Reset the model twice (for some reason when we do it once the search types / ids fields are empty, resetting again fixes that... (wtf))\n $scope.options.resetModel();\n $scope.options.resetModel();\n }\n };\n\n $scope.$on(\"modal.closing\", function (targetScope, reason) {\n if (reason === \"backdrop click\") {\n $scope.reset($scope);\n }\n });\n}]);\n\n\nangular\n .module('nzbhydraApp')\n .factory('DownloaderCheckBeforeCloseService', DownloaderCheckBeforeCloseService);\n\nfunction DownloaderCheckBeforeCloseService($q, DownloaderConfigBoxService, growl, ModalService, blockUI) {\n\n return {\n checkBeforeClose: checkBeforeClose\n };\n\n function checkBeforeClose(scope, model) {\n var deferred = $q.defer();\n if (!scope.isInitial && !scope.needsConnectionTest) {\n deferred.resolve();\n } else {\n scope.spinnerActive = true;\n blockUI.start(\"Testing connection...\");\n var url = \"internalapi/downloader/checkConnection\";\n DownloaderConfigBoxService.checkConnection(url, JSON.stringify(model)).then(function () {\n blockUI.reset();\n scope.spinnerActive = false;\n growl.info(\"Connection to the downloader tested successfully\");\n deferred.resolve();\n },\n function (data) {\n blockUI.reset();\n scope.spinnerActive = false;\n handleConnectionCheckFail(ModalService, data, model, \"downloader\", deferred);\n }).finally(function () {\n scope.spinnerActive = false;\n blockUI.reset();\n });\n }\n return deferred.promise;\n }\n}","/*\n * (C) Copyright 2017 TheOtherP (theotherp@posteo.net)\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nhashCode = function (s) {\n return s.split(\"\").reduce(function (a, b) {\n a = ((a << 5) - a) + b.charCodeAt(0);\n return a & a\n }, 0);\n};\n\nangular\n .module('nzbhydraApp').run([\"formlyConfig\", \"formlyValidationMessages\", function (formlyConfig, formlyValidationMessages) {\n formlyValidationMessages.addStringMessage('required', 'This field is required');\n formlyValidationMessages.addStringMessage('newznabCategories', 'Invalid');\n formlyConfig.extras.errorExistsAndShouldBeVisibleExpression = 'fc.$touched || form.$submitted';\n}]);\n\nangular\n .module('nzbhydraApp')\n .config([\"formlyConfigProvider\", function config(formlyConfigProvider) {\n formlyConfigProvider.extras.removeChromeAutoComplete = true;\n formlyConfigProvider.extras.explicitAsync = true;\n formlyConfigProvider.disableWarnings = window.onProd;\n\n\n formlyConfigProvider.setWrapper({\n name: 'settingWrapper',\n templateUrl: 'setting-wrapper.html'\n });\n\n\n formlyConfigProvider.setWrapper({\n name: 'fieldset',\n templateUrl: 'fieldset-wrapper.html',\n controller: ['$scope', function ($scope) {\n $scope.tooltipIsOpen = false;\n }]\n });\n\n formlyConfigProvider.setType({\n name: 'help',\n template: [\n '
'\n ].join(' '),\n controller: function ($scope, $uibModal, $http) {\n $scope.open = function () {\n var model = $scope.model;\n var modelCopy = structuredClone(model);\n $uibModal.open({\n templateUrl: 'static/html/custom-mapping-help.html',\n controller: [\"$scope\", \"$uibModalInstance\", \"$http\", function ($scope, $uibModalInstance, $http) {\n $scope.model = modelCopy;\n $scope.cancel = function () {\n $uibModalInstance.close();\n }\n $scope.submit = function () {\n Object.assign(model, $scope.model)\n $uibModalInstance.close();\n\n }\n\n $scope.test = function () {\n if (!$scope.exampleInput) {\n $scope.exampleResult = \"Empty example data\";\n return;\n\n }\n console.log(\"custom mapping test\");\n $http.post('internalapi/customMapping/test', {mapping: $scope.model, exampleInput: $scope.exampleInput, matchAll: $scope.matchAll}).then(function (response) {\n console.log(response.data);\n console.log(response.data.output);\n if (response.data.error) {\n $scope.exampleResult = response.data.error;\n } else if (response.data.match) {\n $scope.exampleResult = response.data.output;\n } else {\n $scope.exampleResult = \"Input does not match example\";\n }\n }, function (response) {\n $scope.exampleResult = response.message;\n })\n }\n }],\n size: \"md\"\n })\n }\n }\n });\n\n function updateIndexerModel(model, indexerConfig) {\n model.supportedSearchIds = indexerConfig.supportedSearchIds;\n model.supportedSearchTypes = indexerConfig.supportedSearchTypes;\n model.categoryMapping = indexerConfig.categoryMapping;\n model.configComplete = indexerConfig.configComplete;\n model.allCapsChecked = indexerConfig.allCapsChecked;\n model.hitLimit = indexerConfig.hitLimit;\n model.downloadLimit = indexerConfig.downloadLimit;\n model.state = indexerConfig.state;\n model.backend = indexerConfig.backend;\n }\n\n formlyConfigProvider.setType({\n //BUtton\n name: 'checkCaps',\n templateUrl: 'button-check-caps.html',\n controller: function ($scope, IndexerConfigBoxService, ModalService, growl) {\n $scope.message = \"\";\n $scope.uniqueId = hashCode($scope.model.name) + hashCode($scope.model.host);\n\n var testButton = \"#button-check-caps-\" + $scope.uniqueId;\n var testMessage = \"#message-check-caps-\" + $scope.uniqueId;\n\n function showSuccess() {\n angular.element(testButton).removeClass(\"btn-default\");\n angular.element(testButton).removeClass(\"btn-danger\");\n angular.element(testButton).removeClass(\"btn-warning\");\n angular.element(testButton).addClass(\"btn-success\");\n }\n\n function showError() {\n angular.element(testButton).removeClass(\"btn-default\");\n angular.element(testButton).removeClass(\"btn-warning\");\n angular.element(testButton).removeClass(\"btn-success\");\n angular.element(testButton).addClass(\"btn-danger\");\n }\n\n function showWarning() {\n angular.element(testButton).removeClass(\"btn-default\");\n angular.element(testButton).removeClass(\"btn-danger\");\n angular.element(testButton).removeClass(\"btn-success\");\n angular.element(testButton).addClass(\"btn-warning\");\n }\n\n\n //When button is clicked\n $scope.checkCaps = function () {\n angular.element(testButton).addClass(\"glyphicon-refresh-animate\");\n IndexerConfigBoxService.checkCaps({\n indexerConfig: $scope.model,\n checkType: \"SINGLE\"\n }).then(function (data) {\n data = data[0]; //We get a list of results (with one result because the check type is single)\n //Formly doesn't allow replacing the model so we need to set all the relevant values ourselves\n updateIndexerModel($scope.model, data.indexerConfig);\n if (data.indexerConfig.supportedSearchIds.length > 0) {\n var message = \"Supports \" + data.indexerConfig.supportedSearchIds;\n angular.element(testMessage).text(message);\n }\n if (data.indexerConfig.allCapsChecked && data.indexerConfig.configComplete) {\n showSuccess();\n growl.info(\"Successfully tested capabilites of indexer\");\n $scope.form.capsChecked = true;\n } else if (!data.indexerConfig.allCapsChecked && data.indexerConfig.configComplete) {\n showWarning();\n ModalService.open(\"Incomplete caps check\", \"The capabilities of the indexer could not be checked completely. You may use it but it's recommended to repeat the check at another time. Until then some search types or IDs may not be usable.\", {}, \"md\", \"left\");\n $scope.form.capsChecked = true;\n } else if (!data.configComplete) {\n showError();\n ModalService.open(\"Error testing capabilities\", \"An error occurred while contacting the indexer. It will not be usable until the caps check has been executed. You can trigger it manually from the indexer config box\", {}, \"md\", \"left\");\n }\n }, function (message) {\n angular.element(testMessage).text(message);\n showError();\n ModalService.open(\"Error testing capabilities\", \"An error occurred while contacting the indexer. It will not be usable until the caps check has been executed. You can trigger it manually from the indexer config box\", {}, \"md\", \"left\");\n }).finally(function () {\n angular.element(testButton).removeClass(\"glyphicon-refresh-animate\");\n });\n }\n }\n });\n\n formlyConfigProvider.setType({\n name: 'horizontalCheckCaps',\n extends: 'checkCaps',\n wrapper: ['settingWrapper', 'bootstrapHasError']\n });\n\n\n formlyConfigProvider.setType({\n name: 'horizontalApiKeyInput',\n extends: 'apiKeyInput',\n wrapper: ['settingWrapper', 'bootstrapHasError']\n });\n\n formlyConfigProvider.setType({\n name: 'horizontalPercentInput',\n extends: 'percentInput',\n wrapper: ['settingWrapper', 'bootstrapHasError']\n });\n\n\n formlyConfigProvider.setType({\n name: 'switch',\n template: ''\n });\n\n formlyConfigProvider.setType({\n name: 'indexerStateSwitch',\n template: ''\n });\n\n\n formlyConfigProvider.setType({\n name: 'horizontalIndexerStateSwitch',\n extends: 'indexerStateSwitch',\n wrapper: ['settingWrapper', 'bootstrapHasError']\n });\n\n\n formlyConfigProvider.setType({\n name: 'duoSetting',\n extends: 'input',\n defaultOptions: {\n className: 'col-md-9',\n templateOptions: {\n type: 'number',\n noRow: true,\n label: ''\n }\n }\n });\n\n formlyConfigProvider.setType({\n name: 'horizontalSwitch',\n extends: 'switch',\n wrapper: ['settingWrapper', 'bootstrapHasError']\n });\n\n formlyConfigProvider.setType({\n name: 'horizontalSelect',\n extends: 'select',\n wrapper: ['settingWrapper', 'bootstrapHasError'],\n controller: function ($scope) {\n if ($scope.options.templateOptions.optionsFunction !== undefined) {\n $scope.to.options.push.apply($scope.to.options, $scope.options.templateOptions.optionsFunction($scope.model));\n }\n if ($scope.options.templateOptions.optionsFunctionAfter !== undefined) {\n $scope.options.templateOptions.optionsFunctionAfter($scope.model);\n }\n }\n });\n\n\n formlyConfigProvider.setType({\n name: 'horizontalMultiselect',\n defaultOptions: {\n templateOptions: {\n optionsAttr: 'bs-options',\n ngOptions: 'option[to.valueProp] as option in to.options | filter: $select.search'\n }\n },\n template: '',\n controller: function ($scope) {\n var settings = $scope.to.settings || [];\n settings.classes = settings.classes || [];\n angular.extend(settings.classes, [\"form-control\"]);\n $scope.settings = settings;\n if ($scope.options.templateOptions.optionsFunction !== null && $scope.options.templateOptions.optionsFunction !== undefined) {\n $scope.to.options.push.apply($scope.to.options, $scope.options.templateOptions.optionsFunction($scope.model));\n }\n $scope.events = {\n onToggleItem: function (item, newValue) {\n $scope.form.$setDirty(true);\n }\n }\n },\n wrapper: ['settingWrapper', 'bootstrapHasError']\n });\n\n formlyConfigProvider.setType({\n name: 'label',\n template: ''\n });\n\n formlyConfigProvider.setType({\n name: 'duolabel',\n extends: 'label',\n defaultOptions: {\n className: 'col-md-2',\n templateOptions: {\n label: '-'\n }\n }\n });\n\n formlyConfigProvider.setType({\n name: 'repeatSection',\n templateUrl: 'repeatSection.html',\n controller: function ($scope) {\n $scope.formOptions = {formState: $scope.formState};\n $scope.addNew = addNew;\n $scope.remove = remove;\n $scope.copyFields = copyFields;\n\n function copyFields(fields) {\n fields = angular.copy(fields);\n $scope.repeatfields = fields;\n return fields;\n }\n\n $scope.clear = function (field) {\n return _.mapObject(field, function (key, val) {\n if (typeof val === 'object') {\n return $scope.clear(val);\n }\n return undefined;\n\n });\n };\n\n function addNew(preset) {\n console.log(preset);\n $scope.form.$setDirty(true);\n $scope.model[$scope.options.key] = $scope.model[$scope.options.key] || [];\n var repeatsection = $scope.model[$scope.options.key];\n var newsection = angular.copy($scope.options.templateOptions.defaultModel);\n Object.assign(newsection, preset);\n repeatsection.push(newsection);\n }\n\n function remove($index) {\n $scope.model[$scope.options.key].splice($index, 1);\n $scope.form.$setDirty(true);\n }\n }\n });\n\n formlyConfigProvider.setType({\n name: 'recheckAllCaps',\n templateUrl: 'static/html/config/recheck-all-caps.html',\n controller: function ($scope, $uibModal, growl, IndexerConfigBoxService) {\n $scope.recheck = function (checkType) {\n IndexerConfigBoxService.checkCaps({checkType: checkType}).then(function (listOfResults) {\n //A bit ugly, but we have to update the current model with the new data from the list\n for (var i = 0; i < $scope.model.length; i++) {\n for (var j = 0; j < listOfResults.length; j++) {\n if ($scope.model[i].name === listOfResults[j].indexerConfig.name) {\n updateIndexerModel($scope.model[i], listOfResults[j].indexerConfig);\n $scope.form.$setDirty(true);\n }\n }\n }\n });\n }\n }\n });\n\n\n formlyConfigProvider.setType({\n name: 'notificationSection',\n templateUrl: 'notificationRepeatSection.html',\n controller: function ($scope, NotificationService) {\n $scope.formOptions = {formState: $scope.formState};\n $scope.addNew = addNew;\n $scope.remove = remove;\n $scope.copyFields = copyFields;\n $scope.eventTypes = [];\n\n var allData = NotificationService.getAllData();\n _.each(_.keys(allData), function (key) {\n $scope.eventTypes.push({\"key\": key, \"label\": allData[key].readable})\n })\n\n function copyFields(fields) {\n fields = angular.copy(fields);\n $scope.repeatfields = fields;\n return fields;\n }\n\n $scope.clear = function (field) {\n return _.mapObject(field, function (key, val) {\n if (typeof val === 'object') {\n return $scope.clear(val);\n }\n return undefined;\n\n });\n };\n\n function addNew(eventType) {\n $scope.form.$setDirty(true);\n $scope.model[$scope.options.key] = $scope.model[$scope.options.key] || [];\n var repeatsection = $scope.model[$scope.options.key];\n var newsection = angular.copy($scope.options.templateOptions.defaultModel);\n\n var eventTypeData = NotificationService.getAllData()[eventType];\n console.log(eventTypeData);\n newsection.eventType = eventType;\n newsection.titleTemplate = eventTypeData.titleTemplate;\n newsection.bodyTemplate = eventTypeData.bodyTemplate;\n newsection.messageType = eventTypeData.messageType;\n\n repeatsection.push(newsection);\n }\n\n function remove($index) {\n $scope.model[$scope.options.key].splice($index, 1);\n $scope.form.$setDirty(true);\n }\n }\n });\n\n formlyConfigProvider.setType({\n //Button\n name: 'testNotification',\n templateUrl: 'button-test-notification.html',\n controller: function ($scope, NotificationService) {\n\n\n //When button is clicked\n $scope.testNotification = function () {\n NotificationService.testNotification($scope.model.eventType)\n }\n }\n });\n\n formlyConfigProvider.setType({\n name: 'horizontalTestNotification',\n extends: 'testNotification',\n wrapper: ['settingWrapper', 'bootstrapHasError']\n });\n\n\n }]);\n\n","\nConfigService.$inject = [\"$http\", \"$q\", \"$cacheFactory\", \"$uibModal\", \"bootstrapped\", \"RequestsErrorHandler\"];angular\n .module('nzbhydraApp')\n .factory('ConfigService', ConfigService);\n\nfunction ConfigService($http, $q, $cacheFactory, $uibModal, bootstrapped, RequestsErrorHandler) {\n\n ConfigureInModalInstanceCtrl.$inject = [\"$scope\", \"$uibModalInstance\", \"$http\", \"growl\", \"$interval\", \"RequestsErrorHandler\", \"localStorageService\", \"externalTool\", \"dialogInfo\"];\n var cache = $cacheFactory(\"nzbhydra\");\n var safeConfig = bootstrapped.safeConfig;\n\n return {\n set: set,\n get: get,\n getSafe: getSafe,\n invalidateSafe: invalidateSafe,\n maySeeAdminArea: maySeeAdminArea,\n reloadConfig: reloadConfig,\n apiHelp: apiHelp,\n configureIn: configureIn\n };\n\n function set(newConfig, ignoreWarnings) {\n var deferred = $q.defer();\n $http.put('internalapi/config', newConfig)\n .then(function (response) {\n if (response.data.ok && (ignoreWarnings || response.data.warningMessages.length === 0)) {\n cache.put(\"config\", newConfig);\n setTimeout(function () {\n invalidateSafe();\n }, 500)\n }\n deferred.resolve(response);\n\n }, function (errorresponse) {\n console.log(\"Error saving settings:\");\n console.log(errorresponse);\n deferred.reject(errorresponse);\n });\n return deferred.promise;\n }\n\n function reloadConfig() {\n return $http.get('internalapi/config/reload').then(function (response) {\n return response.data;\n });\n }\n\n function apiHelp() {\n return $http.get('internalapi/config/apiHelp').then(function (response) {\n return response.data;\n });\n }\n\n function get() {\n var config = cache.get(\"config\");\n if (angular.isUndefined(config)) {\n config = $http.get('internalapi/config').then(function (response) {\n return response.data;\n });\n cache.put(\"config\", config);\n }\n\n return config;\n }\n\n function getSafe() {\n return safeConfig;\n }\n\n function invalidateSafe() {\n RequestsErrorHandler.specificallyHandled(function () {\n $http.get('internalapi/config/safe').then(function (response) {\n safeConfig = response.data;\n });\n });\n\n }\n\n function maySeeAdminArea() {\n function loadAll() {\n var maySeeAdminArea = cache.get(\"maySeeAdminArea\");\n if (!angular.isUndefined(maySeeAdminArea)) {\n var deferred = $q.defer();\n deferred.resolve(maySeeAdminArea);\n return deferred.promise;\n }\n\n return $http.get('internalapi/mayseeadminarea')\n .then(function (configResponse) {\n var config = configResponse.data;\n cache.put(\"maySeeAdminArea\", config);\n return configResponse.data;\n });\n }\n\n return loadAll().then(function (maySeeAdminArea) {\n return maySeeAdminArea;\n });\n }\n\n function configureIn(externalTool) {\n $uibModal.open({\n templateUrl: 'static/html/configure-in-modal.html',\n controller: ConfigureInModalInstanceCtrl,\n size: \"md\",\n resolve: {\n externalTool: function () {\n return externalTool;\n },\n dialogInfo: function () {\n return $http.get(\"internalapi/externalTools/getDialogInfo\").then(function (response) {\n return response.data;\n })\n }\n }\n })\n }\n\n function ConfigureInModalInstanceCtrl($scope, $uibModalInstance, $http, growl, $interval, RequestsErrorHandler, localStorageService, externalTool, dialogInfo) {\n var lastConfig = localStorageService.get(externalTool);\n\n $scope.externalTool = externalTool;\n $scope.externalToolDisplayName = externalTool;\n $scope.externalToolsMessages = [];\n $scope.closeButtonType = \"warning\";\n $scope.completed = false;\n $scope.working = false;\n $scope.showMessages = false;\n\n $scope.nzbhydraHost = dialogInfo.nzbhydraHost;\n $scope.usenetIndexersConfigured = dialogInfo.usenetIndexersConfigured;\n $scope.prioritiesConfigured = dialogInfo.prioritiesConfigured;\n $scope.configureForUsenet = dialogInfo.usenetIndexersConfigured;\n $scope.torrentIndexersConfigured = dialogInfo.torrentIndexersConfigured;\n $scope.configureForTorrents = dialogInfo.torrentIndexersConfigured;\n $scope.addDisabledIndexers = false;\n\n if (!$scope.configureForUsenet && !$scope.configureForTorrents) {\n growl.error(\"No usenet or torrent indexers configured\");\n }\n\n\n $scope.nzbhydraName = \"NZBHydra2\";\n $scope.xdarrHost = \"http://localhost:\"\n $scope.addType = \"SINGLE\";\n $scope.enableRss = true;\n $scope.enableAutomaticSearch = true;\n $scope.enableInteractiveSearch = true;\n $scope.categories = null;\n $scope.animeCategories = null;\n $scope.priority = 0;\n $scope.useHydraPriorities = true;\n\n if (externalTool === \"Sonarr\" || externalTool === \"Sonarrv3\") {\n $scope.xdarrHost += \"8989\";\n $scope.categories = \"5030,5040\";\n if (externalTool === \"Sonarrv3\") {\n $scope.externalToolDisplayName = \"Sonarr v3+\";\n }\n } else if (externalTool === \"Radarr\" || externalTool === \"Radarrv3\") {\n $scope.xdarrHost += \"7878\";\n $scope.categories = \"2000\";\n if (externalTool === \"Radarrv3\") {\n $scope.externalToolDisplayName = \"Radarr v3+\";\n }\n } else if (externalTool === \"Lidarr\") {\n $scope.xdarrHost += \"8686\";\n $scope.categories = \"3000\";\n } else if (externalTool === \"Readarr\") {\n $scope.xdarrHost += \"8787\";\n $scope.categories = \"7020,8010\";\n }\n $scope.removeYearFromSearchString = false;\n\n if (lastConfig !== null && lastConfig !== undefined) {\n Object.assign($scope, lastConfig);\n }\n\n $scope.close = function () {\n $uibModalInstance.dismiss();\n };\n\n $scope.submit = function (deleteOnly) {\n if ($scope.completed && !deleteOnly) {\n $uibModalInstance.dismiss();\n }\n if (!$scope.usenetIndexersConfigured && !$scope.torrentIndexersConfigured && !deleteOnly) {\n growl.error(\"No usenet or torrent indexers configured\");\n return;\n }\n $scope.externalToolsMessages = [];\n $scope.spinnerActive = true;\n $scope.working = true;\n $scope.showMessages = true;\n var data = {\n\n nzbhydraName: $scope.nzbhydraName,\n externalTool: $scope.externalTool,\n nzbhydraHost: $scope.nzbhydraHost,\n addType: deleteOnly ? \"DELETE_ONLY\" : $scope.addType,\n xdarrHost: $scope.xdarrHost,\n xdarrApiKey: $scope.xdarrApiKey,\n enableRss: $scope.enableRss,\n enableAutomaticSearch: $scope.enableAutomaticSearch,\n enableInteractiveSearch: $scope.enableInteractiveSearch,\n categories: $scope.categories,\n animeCategories: $scope.animeCategories,\n removeYearFromSearchString: $scope.removeYearFromSearchString,\n earlyDownloadLimit: $scope.earlyDownloadLimit,\n multiLanguages: $scope.multiLanguages,\n configureForUsenet: $scope.configureForUsenet,\n configureForTorrents: $scope.configureForTorrents,\n additionalParameters: $scope.additionalParameters,\n minimumSeeders: $scope.minimumSeeders,\n seedRatio: $scope.seedRatio,\n seedTime: $scope.seedTime,\n seasonPackSeedTime: $scope.seasonPackSeedTime,\n discographySeedTime: $scope.discographySeedTime,\n addDisabledIndexers: $scope.addDisabledIndexers,\n priority: $scope.priority,\n useHydraPriorities: $scope.useHydraPriorities\n }\n\n localStorageService.set(externalTool, data);\n\n function updateMessages() {\n $http.get(\"internalapi/externalTools/messages\").then(function (response) {\n $scope.externalToolsMessages = response.data;\n });\n }\n\n var updateInterval = $interval(function () {\n updateMessages();\n }, 500);\n\n RequestsErrorHandler.specificallyHandled(function () {\n $scope.completed = false;\n $http.post(\"internalapi/externalTools/configure\", data).then(function (response) {\n updateMessages();\n $interval.cancel(updateInterval);\n $scope.spinnerActive = false;\n console.log(response);\n if (response.data) {\n $scope.completed = true;\n $scope.closeButtonType = \"success\";\n } else {\n $scope.working = false;\n $scope.completed = false;\n }\n }, function (error) {\n updateMessages();\n console.error(error.data);\n $interval.cancel(updateInterval);\n $scope.completed = false;\n $scope.spinnerActive = false;\n $scope.working = false;\n });\n });\n };\n\n }\n}\n","/*\n * (C) Copyright 2017 TheOtherP (theotherp@posteo.net)\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nConfigFields.$inject = [\"$injector\"];\nangular\n .module('nzbhydraApp')\n .factory('ConfigFields', ConfigFields);\n\nfunction ConfigFields($injector) {\n return {\n getFields: getFields\n };\n\n function ipValidator() {\n return {\n expression: function ($viewValue, $modelValue) {\n var value = $modelValue || $viewValue;\n if (value) {\n return /^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$/.test(value)\n || /^(?:[0-9]{1,3}\\.){3}[0-9]{1,3}$/.test(value);\n }\n return true;\n },\n message: '$viewValue + \" is not a valid IP Address\"'\n };\n }\n\n function regexValidator(regex, message, prefixViewValue, preventEmpty) {\n return {\n expression: function ($viewValue, $modelValue) {\n var value = $modelValue || $viewValue;\n if (value) {\n if (Array.isArray(value)) {\n for (var i = 0; i < value.length; i++) {\n if (!regex.test(value[i])) {\n return false;\n }\n }\n return true;\n } else {\n return regex.test(value);\n }\n }\n return !preventEmpty;\n },\n message: (prefixViewValue ? '$viewValue + \" ' : '\" ') + message + '\"'\n };\n }\n\n function getFields(rootModel, showAdvanced) {\n return {\n main: [\n {\n wrapper: 'fieldset',\n templateOptions: {label: 'Hosting'},\n fieldGroup: [\n {\n key: 'host',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n label: 'Host',\n required: true,\n placeholder: 'IPv4 address to bind to',\n help: 'I strongly recommend using a reverse proxy instead of exposing this directly. Requires restart.'\n },\n validators: {\n ipAddress: ipValidator()\n }\n },\n {\n key: 'port',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'Port',\n required: true,\n placeholder: '5076',\n help: 'Requires restart.'\n },\n validators: {\n port: regexValidator(/^\\d{1,5}$/, \"is no valid port\", true)\n }\n },\n {\n key: 'urlBase',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n label: 'URL base',\n placeholder: '/nzbhydra',\n help: 'Adapt when using a reverse proxy. See wiki. Always use when calling Hydra, even locally.',\n tooltip: 'If you use Hydra behind a reverse proxy you might want to set the URL base to a value like \"/nzbhydra\". If you accesses Hydra with tools running outside your network (for example from your phone) set the external URL so that it matches the full Hydra URL. That way the NZB links returned in the search results refer to your global URL and not your local address.',\n advanced: true\n },\n validators: {\n urlBase: regexValidator(/^((\\/.*[^\\/])|\\/)$/, 'URL base has to start and may not end with /', false, true)\n }\n\n },\n {\n key: 'ssl',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Use SSL',\n help: 'Requires restart.',\n tooltip: 'You can use SSL but I recommend using a reverse proxy with SSL. See the wiki for notes regarding reverse proxies and SSL. It\\'s more secure and can be configured better.',\n advanced: true\n }\n },\n {\n key: 'sslKeyStore',\n hideExpression: '!model.ssl',\n type: 'fileInput',\n templateOptions: {\n label: 'SSL keystore file',\n required: true,\n type: \"file\",\n help: 'Requires restart. See wiki.'\n }\n },\n {\n key: 'sslKeyStorePassword',\n hideExpression: '!model.ssl',\n type: 'horizontalInput',\n templateOptions: {\n type: 'password',\n label: 'SSL keystore password',\n required: true,\n help: 'Requires restart.'\n }\n }\n\n\n ]\n },\n {\n wrapper: 'fieldset',\n templateOptions: {\n label: 'Proxy',\n tooltip: 'You can select to use either a SOCKS or an HTTPS proxy. All outside connections will be done via the configured proxy.',\n advanced: true\n }\n ,\n fieldGroup: [\n {\n key: 'proxyType',\n type: 'horizontalSelect',\n templateOptions: {\n type: 'select',\n label: 'Use proxy',\n options: [\n {name: 'None', value: 'NONE'},\n {name: 'SOCKS', value: 'SOCKS'},\n {name: 'HTTP(S)', value: 'HTTP'}\n ]\n }\n },\n {\n key: 'proxyHost',\n type: 'horizontalInput',\n hideExpression: 'model.proxyType===\"NONE\"',\n templateOptions: {\n type: 'text',\n label: 'SOCKS proxy host',\n placeholder: 'Set to use a SOCKS proxy',\n help: \"IPv4 only\"\n }\n },\n {\n key: 'proxyPort',\n type: 'horizontalInput',\n hideExpression: 'model.proxyType===\"NONE\"',\n templateOptions: {\n type: 'number',\n label: 'Proxy port',\n placeholder: '1080'\n }\n },\n {\n key: 'proxyUsername',\n type: 'horizontalInput',\n hideExpression: 'model.proxyType===\"NONE\"',\n templateOptions: {\n type: 'text',\n label: 'Proxy username'\n }\n },\n {\n key: 'proxyPassword',\n type: 'passwordSwitch',\n hideExpression: 'model.proxyType===\"NONE\"',\n templateOptions: {\n type: 'text',\n label: 'Proxy password'\n }\n },\n {\n key: 'proxyIgnoreLocal',\n type: 'horizontalSwitch',\n hideExpression: 'model.proxyType===\"NONE\"',\n templateOptions: {\n type: 'switch',\n label: 'Bypass local network addresses'\n }\n },\n {\n key: 'proxyIgnoreDomains',\n type: 'horizontalChips',\n hideExpression: 'model.proxyType===\"NONE\"',\n templateOptions: {\n type: 'text',\n help: 'Separate by comma. You can use wildcards (*). Case insensitive. Apply values with enter key.',\n label: 'Bypass domains'\n }\n }\n ]\n },\n {\n wrapper: 'fieldset',\n templateOptions: {label: 'UI'},\n fieldGroup: [\n\n {\n key: 'theme',\n type: 'horizontalSelect',\n templateOptions: {\n type: 'select',\n label: 'Theme',\n options: [\n {name: 'Auto', value: 'auto'},\n {name: 'Grey', value: 'grey'},\n {name: 'Bright', value: 'bright'},\n {name: 'Dark', value: 'dark'}\n ]\n }\n }\n ]\n },\n {\n wrapper: 'fieldset',\n templateOptions: {label: 'Security'},\n fieldGroup: [\n {\n key: 'apiKey',\n type: 'horizontalApiKeyInput',\n templateOptions: {\n label: 'API key',\n help: 'Alphanumeric only.',\n required: true\n },\n validators: {\n apiKey: regexValidator(/^[a-zA-Z0-9]*$/, \"API key must only contain numbers and digits\", false)\n }\n },\n {\n key: 'dereferer',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n label: 'Dereferer',\n help: 'Redirect external links to hide your instance. Insert $s for escaped target URL and $us for unescaped target URL. Use empty value to disable.',\n advanced: true\n }\n },\n {\n key: 'verifySsl',\n type: 'horizontalSwitch',\n templateOptions: {\n label: 'Verify SSL certificates',\n help: 'If enabled only valid/known SSL certificates will be accepted when accessing indexers. Change requires restart. See wiki.',\n advanced: true\n }\n },\n {\n key: 'verifySslDisabledFor',\n type: 'horizontalChips',\n templateOptions: {\n type: 'text',\n label: 'Disable SSL for...',\n help: 'Add hosts for which to disable SSL verification. Apply words with return key.',\n advanced: true\n }\n },\n {\n key: 'disableSslLocally',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'text',\n label: 'Disable SSL locally',\n help: 'Disable SSL for local hosts.',\n advanced: true\n }\n },\n {\n key: 'sniDisabledFor',\n type: 'horizontalChips',\n templateOptions: {\n type: 'text',\n label: 'Disable SNI',\n help: 'Add a host if you get an \"unrecognized_name\" error. Apply words with return key. See wiki.',\n advanced: true\n }\n },\n {\n key: 'useCsrf',\n type: 'horizontalSwitch',\n templateOptions: {\n label: 'Use CSRF protection',\n help: 'Use CSRF protection.',\n advanced: true\n }\n }\n ]\n },\n\n {\n wrapper: 'fieldset',\n key: 'logging',\n templateOptions: {\n label: 'Logging',\n tooltip: 'The base settings should suffice for most users. If you want you can enable logging of IP adresses for failed logins and NZB downloads.',\n advanced: true\n },\n fieldGroup: [\n {\n key: 'logfilelevel',\n type: 'horizontalSelect',\n templateOptions: {\n type: 'select',\n label: 'Logfile level',\n options: [\n {name: 'Error', value: 'ERROR'},\n {name: 'Warning', value: 'WARN'},\n {name: 'Info', value: 'INFO'},\n {name: 'Debug', value: 'DEBUG'}\n ],\n help: 'Takes effect on next restart.'\n }\n },\n {\n key: 'logMaxHistory',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'Max log history',\n help: 'How many daily log files will be kept.'\n }\n },\n {\n key: 'consolelevel',\n type: 'horizontalSelect',\n templateOptions: {\n type: 'select',\n label: 'Console log level',\n options: [\n {name: 'Error', value: 'ERROR'},\n {name: 'Warning', value: 'WARN'},\n {name: 'Info', value: 'INFO'},\n {name: 'Debug', value: 'DEBUG'}\n ],\n help: 'Takes effect on next restart.'\n }\n },\n {\n key: 'logGc',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Log GC',\n help: 'Enable garbage collection logging. Only for debugging of memory issues.'\n }\n },\n {\n key: 'logIpAddresses',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Log IP addresses'\n }\n },\n {\n key: 'mapIpToHost',\n type: 'horizontalSwitch',\n hideExpression: '!model.logIpAddresses',\n templateOptions: {\n type: 'switch',\n label: 'Map hosts',\n help: 'Try to map logged IP addresses to host names.',\n tooltip: 'Enabling this may cause NZBHydra to load very, very slowly when accessed remotely.'\n }\n },\n {\n key: 'logUsername',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Log user names'\n }\n },\n {\n key: 'markersToLog',\n type: 'horizontalMultiselect',\n hideExpression: 'model.consolelevel !== \"DEBUG\" && model.logfilelevel !== \"DEBUG\"',\n templateOptions: {\n label: 'Log markers',\n help: 'Select certain sections for more output on debug level. Please enable only when asked for.',\n options: [\n {label: 'API limits', id: 'LIMITS'},\n {label: 'Category mapping', id: 'CATEGORY_MAPPING'},\n {label: 'Config file handling', id: 'CONFIG_READ_WRITE'},\n {label: 'Custom mapping', id: 'CUSTOM_MAPPING'},\n {label: 'Downloader status updating', id: 'DOWNLOADER_STATUS_UPDATE'},\n {label: 'Duplicate detection', id: 'DUPLICATES'},\n {label: 'External tool configuration', id: 'EXTERNAL_TOOLS'},\n {label: 'History cleanup', id: 'HISTORY_CLEANUP'},\n {label: 'HTTP', id: 'HTTP'},\n {label: 'HTTPS', id: 'HTTPS'},\n {label: 'HTTP Server', id: 'SERVER'},\n {label: 'Indexer scheduler', id: 'SCHEDULER'},\n {label: 'Notifications', id: 'NOTIFICATIONS'},\n {label: 'NZB download status updating', id: 'DOWNLOAD_STATUS_UPDATE'},\n {label: 'Performance', id: 'PERFORMANCE'},\n {label: 'Rejected results', id: 'RESULT_ACCEPTOR'},\n {label: 'Removed trailing words', id: 'TRAILING'},\n {label: 'URL calculation', id: 'URL_CALCULATION'},\n {label: 'User agent mapping', id: 'USER_AGENT'},\n {label: 'VIP expiry', id: 'VIP_EXPIRY'}\n ],\n buttonText: \"None\"\n }\n },\n {\n key: 'historyUserInfoType',\n type: 'horizontalSelect',\n templateOptions: {\n type: 'select',\n label: 'History user info',\n options: [\n {name: 'IP and username', value: 'BOTH'},\n {name: 'IP address', value: 'IP'},\n {name: 'Username', value: 'USERNAME'},\n {name: 'None', value: 'NONE'}\n ],\n help: 'Only affects if value is displayed in the search/download history.',\n hideExpression: '!model.keepHistory'\n }\n }\n ]\n },\n {\n wrapper: 'fieldset',\n templateOptions: {\n label: 'Backup',\n advanced: true\n },\n fieldGroup: [\n {\n key: 'backupFolder',\n type: 'horizontalInput',\n templateOptions: {\n label: 'Backup folder',\n help: 'Either relative to the NZBHydra data folder or an absolute folder.'\n }\n },\n {\n key: 'backupEveryXDays',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'Backup every...',\n addonRight: {\n text: 'days'\n }\n }\n },\n {\n key: 'backupBeforeUpdate',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Backup before update'\n }\n }\n ]\n },\n {\n wrapper: 'fieldset',\n templateOptions: {label: 'Updates'},\n fieldGroup: [\n {\n key: 'updateAutomatically',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Install updates automatically'\n }\n }, {\n key: 'updateToPrereleases',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Install prereleases',\n advanced: true\n }\n },\n {\n key: 'deleteBackupsAfterWeeks',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'Delete backups after...',\n addonRight: {\n text: 'weeks'\n },\n advanced: true\n }\n },\n {\n key: 'showUpdateBannerOnDocker',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Show update banner when managed externally',\n advanced: true,\n help: 'If enabled a banner will be shown when new versions are available even when NZBHydra is run inside docker or is installed using a package manager (where you wouldn\\'t let NZBHydra update itself).'\n }\n },\n {\n key: 'showWhatsNewBanner',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Show info banner after automatic updates',\n help: 'Please keep it enabled, I put some effort into the changelog ;-)',\n advanced: true\n }\n }\n ]\n },\n {\n wrapper: 'fieldset',\n templateOptions: {\n label: 'History',\n advanced: true\n },\n fieldGroup: [\n {\n key: 'keepHistory',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Keep history',\n help: 'Controls search and download history.',\n tooltip: 'If disabled no search or download history will be kept. These sections will be hidden in the GUI. You won\\'t be able to see stats. The database will still contain a short-lived history of transactions that are kept for 24 hours.'\n }\n },\n {\n key: 'keepHistoryForWeeks',\n type: 'horizontalInput',\n hideExpression: '!model.keepHistory',\n templateOptions: {\n type: 'number',\n label: 'Keep history for...',\n addonRight: {\n text: 'weeks'\n },\n min: 1,\n help: 'Only keep history (searches, downloads) for a certain time. Will decrease database size and may improve performance a bit. Rather reduce how long stats are kept.'\n }\n },\n {\n key: 'keepStatsForWeeks',\n type: 'horizontalInput',\n hideExpression: '!model.keepHistory',\n templateOptions: {\n type: 'number',\n label: 'Keep stats for...',\n addonRight: {\n text: 'weeks'\n },\n min: 1,\n help: 'Only keep stats for a certain time. Will decrease database size.'\n }\n }\n ]\n },\n {\n wrapper: 'fieldset',\n templateOptions: {\n label: 'Database',\n tooltip: 'You should not change these values unless you\\'re either told to or really know what you\\'re doing.',\n advanced: true\n },\n fieldGroup: [\n {\n key: 'databaseCompactTime',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'Database compact time',\n addonRight: {\n text: 'ms'\n },\n min: 200,\n help: 'The time the database is given to compact (reduce size) when shutting down. Reduce this if shutting down NZBHydra takes too long (database size may increase). Takes effect on next restart.'\n }\n },\n {\n key: 'databaseRetentionTime',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'Database retention time',\n addonRight: {\n text: 'ms'\n },\n help: 'How long the db should retain old, persisted data. See here.'\n }\n },\n {\n key: 'databaseWriteDelay',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'Database write delay',\n addonRight: {\n text: 'ms'\n },\n help: 'Maximum delay between a commit and flushing the log, in milliseconds. See here.'\n }\n }\n\n ]\n },\n {\n wrapper: 'fieldset',\n templateOptions: {label: 'Other'},\n fieldGroup: [\n {\n key: 'startupBrowser',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Open browser on startup'\n }\n },\n {\n key: 'showNews',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Show news',\n help: \"Hydra will occasionally show news when opened. You can always find them in the system section\",\n advanced: true\n }\n },\n {\n key: 'checkOpenPort',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Check for open port',\n help: \"Check if NZBHydra is reachable from the internet and not protected\",\n advanced: true\n }\n },\n {\n key: 'xmx',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'JVM memory',\n addonRight: {\n text: 'MB'\n },\n min: 128,\n help: '256 should suffice except when working with big databases / many indexers. See wiki.',\n advanced: true\n }\n }\n ]\n\n }\n ],\n\n searching: [\n {\n wrapper: 'fieldset',\n templateOptions: {\n label: 'Indexer access',\n tooltip: 'Settings that control how communication with indexers is done and how to handle errors while doing that.',\n advanced: true\n },\n fieldGroup: [\n {\n key: 'timeout',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'Timeout when accessing indexers',\n help: 'Any web call to an indexer taking longer than this is aborted.',\n min: 1,\n addonRight: {\n text: 'seconds'\n }\n }\n },\n {\n key: 'userAgent',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n label: 'User agent',\n help: 'Used when accessing indexers.',\n required: true,\n tooltip: 'Some indexers don\\'t seem to like Hydra and disable access based on the user agent. You can change it here if you want. Please leave it as it is if you have no problems. This allows indexers to gather better statistics on how their API services are used.',\n }\n },\n {\n key: 'userAgents',\n type: 'horizontalChips',\n templateOptions: {\n type: 'text',\n label: 'Map user agents',\n help: 'Used to map the user agent from accessing services to the service names. Apply words with return key.',\n }\n },\n {\n key: 'ignoreLoadLimitingForInternalSearches',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Ignore load limiting internally',\n help: 'When enabled load limiting defined for indexers will be ignored for internal searches.',\n }\n },\n {\n key: 'ignoreTemporarilyDisabled',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Ignore temporary errors',\n tooltip: \"By default if access to an indexer fails the indexer is disabled for a certain amount of time (for a short while first, then increasingly longer if the problems persist). Disable this and always try these indexers.\",\n }\n }\n ]\n }, {\n wrapper: 'fieldset',\n templateOptions: {\n label: 'Category handling',\n tooltip: 'Settings that control the handling of newznab categories (e.g. 2000 for Movies).',\n advanced: true\n },\n fieldGroup: [\n\n {\n key: 'transformNewznabCategories',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Transform newznab categories',\n help: 'Map newznab categories from API searches to configured categories and use all configured newznab categories in searches.'\n }\n },\n {\n key: 'sendTorznabCategories',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Send categories to trackers',\n help: 'If disabled no categories will be included in queries to torznab indexers (trackers).'\n }\n }\n ]\n },\n {\n wrapper: 'fieldset',\n templateOptions: {\n label: 'Media IDs / Query generation / Query processing',\n tooltip: 'Raw search engines like Binsearch don\\'t support searches based on IDs (e.g. for a movie using an IMDB id). You can enable query generation for these. Hydra will then try to retrieve the movie\\'s or show\\'s title and generate a query, for example \"showname s01e01\". In some cases an ID based search will not provide any results. You can enable a fallback so that in such a case the search will be repeated with a query using the title of the show or movie.'\n },\n fieldGroup: [\n {\n key: 'alwaysConvertIds',\n type: 'horizontalSelect',\n templateOptions: {\n label: 'Convert media IDs for...',\n options: [\n {name: 'Internal searches', value: 'INTERNAL'},\n {name: 'API searches', value: 'API'},\n {name: 'All searches', value: 'BOTH'},\n {name: 'Never', value: 'NONE'}\n ],\n help: \"When enabled media ID conversions will always be done even when an indexer supports the already known ID(s).\",\n advanced: true\n }\n },\n {\n key: 'generateQueries',\n type: 'horizontalSelect',\n templateOptions: {\n label: 'Generate queries',\n options: [\n {name: 'Internal searches', value: 'INTERNAL'},\n {name: 'API searches', value: 'API'},\n {name: 'All searches', value: 'BOTH'},\n {name: 'Never', value: 'NONE'}\n ],\n help: \"Generate queries for indexers which do not support ID based searches.\"\n }\n },\n {\n key: 'idFallbackToQueryGeneration',\n type: 'horizontalSelect',\n templateOptions: {\n label: 'Fallback to generated queries',\n options: [\n {name: 'Internal searches', value: 'INTERNAL'},\n {name: 'API searches', value: 'API'},\n {name: 'All searches', value: 'BOTH'},\n {name: 'Never', value: 'NONE'}\n ],\n help: \"When no results were found for a query ID search again using a generated query (on indexer level).\"\n }\n },\n {\n key: 'language',\n type: 'horizontalSelect',\n templateOptions: {\n type: 'text',\n label: 'Language',\n required: true,\n help: 'Used for movie query generation and autocomplete only.',\n options: [{\"name\": \"Abkhaz\", value: \"ab\"}, {\n \"name\": \"Afar\",\n value: \"aa\"\n }, {\"name\": \"Afrikaans\", value: \"af\"}, {\"name\": \"Akan\", value: \"ak\"}, {\n \"name\": \"Albanian\",\n value: \"sq\"\n }, {\"name\": \"Amharic\", value: \"am\"}, {\n \"name\": \"Arabic\",\n value: \"ar\"\n }, {\"name\": \"Aragonese\", value: \"an\"}, {\"name\": \"Armenian\", value: \"hy\"}, {\n \"name\": \"Assamese\",\n value: \"as\"\n }, {\"name\": \"Avaric\", value: \"av\"}, {\"name\": \"Avestan\", value: \"ae\"}, {\n \"name\": \"Aymara\",\n value: \"ay\"\n }, {\"name\": \"Azerbaijani\", value: \"az\"}, {\n \"name\": \"Bambara\",\n value: \"bm\"\n }, {\"name\": \"Bashkir\", value: \"ba\"}, {\n \"name\": \"Basque\",\n value: \"eu\"\n }, {\"name\": \"Belarusian\", value: \"be\"}, {\"name\": \"Bengali\", value: \"bn\"}, {\n \"name\": \"Bihari\",\n value: \"bh\"\n }, {\"name\": \"Bislama\", value: \"bi\"}, {\n \"name\": \"Bosnian\",\n value: \"bs\"\n }, {\"name\": \"Breton\", value: \"br\"}, {\"name\": \"Bulgarian\", value: \"bg\"}, {\n \"name\": \"Burmese\",\n value: \"my\"\n }, {\"name\": \"Catalan\", value: \"ca\"}, {\n \"name\": \"Chamorro\",\n value: \"ch\"\n }, {\"name\": \"Chechen\", value: \"ce\"}, {\"name\": \"Chichewa\", value: \"ny\"}, {\n \"name\": \"Chinese\",\n value: \"zh\"\n }, {\"name\": \"Chuvash\", value: \"cv\"}, {\n \"name\": \"Cornish\",\n value: \"kw\"\n }, {\"name\": \"Corsican\", value: \"co\"}, {\"name\": \"Cree\", value: \"cr\"}, {\n \"name\": \"Croatian\",\n value: \"hr\"\n }, {\"name\": \"Czech\", value: \"cs\"}, {\"name\": \"Danish\", value: \"da\"}, {\n \"name\": \"Divehi\",\n value: \"dv\"\n }, {\"name\": \"Dutch\", value: \"nl\"}, {\n \"name\": \"Dzongkha\",\n value: \"dz\"\n }, {\"name\": \"English\", value: \"en\"}, {\n \"name\": \"Esperanto\",\n value: \"eo\"\n }, {\"name\": \"Estonian\", value: \"et\"}, {\"name\": \"Ewe\", value: \"ee\"}, {\n \"name\": \"Faroese\",\n value: \"fo\"\n }, {\"name\": \"Fijian\", value: \"fj\"}, {\"name\": \"Finnish\", value: \"fi\"}, {\n \"name\": \"French\",\n value: \"fr\"\n }, {\"name\": \"Fula\", value: \"ff\"}, {\n \"name\": \"Galician\",\n value: \"gl\"\n }, {\"name\": \"Georgian\", value: \"ka\"}, {\"name\": \"German\", value: \"de\"}, {\n \"name\": \"Greek\",\n value: \"el\"\n }, {\"name\": \"Guaraní\", value: \"gn\"}, {\n \"name\": \"Gujarati\",\n value: \"gu\"\n }, {\"name\": \"Haitian\", value: \"ht\"}, {\"name\": \"Hausa\", value: \"ha\"}, {\n \"name\": \"Hebrew\",\n value: \"he\"\n }, {\"name\": \"Herero\", value: \"hz\"}, {\n \"name\": \"Hindi\",\n value: \"hi\"\n }, {\"name\": \"Hiri Motu\", value: \"ho\"}, {\n \"name\": \"Hungarian\",\n value: \"hu\"\n }, {\"name\": \"Interlingua\", value: \"ia\"}, {\n \"name\": \"Indonesian\",\n value: \"id\"\n }, {\"name\": \"Interlingue\", value: \"ie\"}, {\n \"name\": \"Irish\",\n value: \"ga\"\n }, {\"name\": \"Igbo\", value: \"ig\"}, {\"name\": \"Inupiaq\", value: \"ik\"}, {\n \"name\": \"Ido\",\n value: \"io\"\n }, {\"name\": \"Icelandic\", value: \"is\"}, {\n \"name\": \"Italian\",\n value: \"it\"\n }, {\"name\": \"Inuktitut\", value: \"iu\"}, {\"name\": \"Japanese\", value: \"ja\"}, {\n \"name\": \"Javanese\",\n value: \"jv\"\n }, {\"name\": \"Kalaallisut\", value: \"kl\"}, {\n \"name\": \"Kannada\",\n value: \"kn\"\n }, {\"name\": \"Kanuri\", value: \"kr\"}, {\"name\": \"Kashmiri\", value: \"ks\"}, {\n \"name\": \"Kazakh\",\n value: \"kk\"\n }, {\"name\": \"Khmer\", value: \"km\"}, {\n \"name\": \"Kikuyu\",\n value: \"ki\"\n }, {\"name\": \"Kinyarwanda\", value: \"rw\"}, {\"name\": \"Kyrgyz\", value: \"ky\"}, {\n \"name\": \"Komi\",\n value: \"kv\"\n }, {\"name\": \"Kongo\", value: \"kg\"}, {\"name\": \"Korean\", value: \"ko\"}, {\n \"name\": \"Kurdish\",\n value: \"ku\"\n }, {\"name\": \"Kwanyama\", value: \"kj\"}, {\n \"name\": \"Latin\",\n value: \"la\"\n }, {\"name\": \"Luxembourgish\", value: \"lb\"}, {\n \"name\": \"Ganda\",\n value: \"lg\"\n }, {\"name\": \"Limburgish\", value: \"li\"}, {\"name\": \"Lingala\", value: \"ln\"}, {\n \"name\": \"Lao\",\n value: \"lo\"\n }, {\"name\": \"Lithuanian\", value: \"lt\"}, {\n \"name\": \"Luba-Katanga\",\n value: \"lu\"\n }, {\"name\": \"Latvian\", value: \"lv\"}, {\"name\": \"Manx\", value: \"gv\"}, {\n \"name\": \"Macedonian\",\n value: \"mk\"\n }, {\"name\": \"Malagasy\", value: \"mg\"}, {\n \"name\": \"Malay\",\n value: \"ms\"\n }, {\"name\": \"Malayalam\", value: \"ml\"}, {\"name\": \"Maltese\", value: \"mt\"}, {\n \"name\": \"Māori\",\n value: \"mi\"\n }, {\"name\": \"Marathi\", value: \"mr\"}, {\n \"name\": \"Marshallese\",\n value: \"mh\"\n }, {\"name\": \"Mongolian\", value: \"mn\"}, {\"name\": \"Nauru\", value: \"na\"}, {\n \"name\": \"Navajo\",\n value: \"nv\"\n }, {\"name\": \"Northern Ndebele\", value: \"nd\"}, {\n \"name\": \"Nepali\",\n value: \"ne\"\n }, {\"name\": \"Ndonga\", value: \"ng\"}, {\n \"name\": \"Norwegian Bokmål\",\n value: \"nb\"\n }, {\"name\": \"Norwegian Nynorsk\", value: \"nn\"}, {\n \"name\": \"Norwegian\",\n value: \"no\"\n }, {\"name\": \"Nuosu\", value: \"ii\"}, {\n \"name\": \"Southern Ndebele\",\n value: \"nr\"\n }, {\"name\": \"Occitan\", value: \"oc\"}, {\n \"name\": \"Ojibwe\",\n value: \"oj\"\n }, {\"name\": \"Old Church Slavonic\", value: \"cu\"}, {\"name\": \"Oromo\", value: \"om\"}, {\n \"name\": \"Oriya\",\n value: \"or\"\n }, {\"name\": \"Ossetian\", value: \"os\"}, {\"name\": \"Panjabi\", value: \"pa\"}, {\n \"name\": \"Pāli\",\n value: \"pi\"\n }, {\"name\": \"Persian\", value: \"fa\"}, {\n \"name\": \"Polish\",\n value: \"pl\"\n }, {\"name\": \"Pashto\", value: \"ps\"}, {\n \"name\": \"Portuguese\",\n value: \"pt\"\n }, {\"name\": \"Quechua\", value: \"qu\"}, {\"name\": \"Romansh\", value: \"rm\"}, {\n \"name\": \"Kirundi\",\n value: \"rn\"\n }, {\"name\": \"Romanian\", value: \"ro\"}, {\n \"name\": \"Russian\",\n value: \"ru\"\n }, {\"name\": \"Sanskrit\", value: \"sa\"}, {\"name\": \"Sardinian\", value: \"sc\"}, {\n \"name\": \"Sindhi\",\n value: \"sd\"\n }, {\"name\": \"Northern Sami\", value: \"se\"}, {\n \"name\": \"Samoan\",\n value: \"sm\"\n }, {\"name\": \"Sango\", value: \"sg\"}, {\"name\": \"Serbian\", value: \"sr\"}, {\n \"name\": \"Gaelic\",\n value: \"gd\"\n }, {\"name\": \"Shona\", value: \"sn\"}, {\"name\": \"Sinhala\", value: \"si\"}, {\n \"name\": \"Slovak\",\n value: \"sk\"\n }, {\"name\": \"Slovene\", value: \"sl\"}, {\n \"name\": \"Somali\",\n value: \"so\"\n }, {\"name\": \"Southern Sotho\", value: \"st\"}, {\n \"name\": \"Spanish\",\n value: \"es\"\n }, {\"name\": \"Sundanese\", value: \"su\"}, {\"name\": \"Swahili\", value: \"sw\"}, {\n \"name\": \"Swati\",\n value: \"ss\"\n }, {\"name\": \"Swedish\", value: \"sv\"}, {\"name\": \"Tamil\", value: \"ta\"}, {\n \"name\": \"Telugu\",\n value: \"te\"\n }, {\"name\": \"Tajik\", value: \"tg\"}, {\n \"name\": \"Thai\",\n value: \"th\"\n }, {\"name\": \"Tigrinya\", value: \"ti\"}, {\n \"name\": \"Tibetan Standard\",\n value: \"bo\"\n }, {\"name\": \"Turkmen\", value: \"tk\"}, {\"name\": \"Tagalog\", value: \"tl\"}, {\n \"name\": \"Tswana\",\n value: \"tn\"\n }, {\"name\": \"Tonga\", value: \"to\"}, {\"name\": \"Turkish\", value: \"tr\"}, {\n \"name\": \"Tsonga\",\n value: \"ts\"\n }, {\"name\": \"Tatar\", value: \"tt\"}, {\n \"name\": \"Twi\",\n value: \"tw\"\n }, {\"name\": \"Tahitian\", value: \"ty\"}, {\n \"name\": \"Uyghur\",\n value: \"ug\"\n }, {\"name\": \"Ukrainian\", value: \"uk\"}, {\"name\": \"Urdu\", value: \"ur\"}, {\n \"name\": \"Uzbek\",\n value: \"uz\"\n }, {\"name\": \"Venda\", value: \"ve\"}, {\n \"name\": \"Vietnamese\",\n value: \"vi\"\n }, {\"name\": \"Volapük\", value: \"vo\"}, {\"name\": \"Walloon\", value: \"wa\"}, {\n \"name\": \"Welsh\",\n value: \"cy\"\n }, {\"name\": \"Wolof\", value: \"wo\"}, {\n \"name\": \"Western Frisian\",\n value: \"fy\"\n }, {\"name\": \"Xhosa\", value: \"xh\"}, {\"name\": \"Yiddish\", value: \"yi\"}, {\n \"name\": \"Yoruba\",\n value: \"yo\"\n }, {\"name\": \"Zhuang\", value: \"za\"}, {\"name\": \"Zulu\", value: \"zu\"}]\n }\n },\n {\n key: 'replaceUmlauts',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Replace umlauts',\n help: 'Replace german umlauts and special characters (ä, ö, ü and ß) in external request queries.'\n }\n }\n ]\n },\n {\n wrapper: 'fieldset',\n templateOptions: {\n label: 'Result filters',\n tooltip: 'This section allows you to define global filters which will be applied to all search results. You can define words and regexes which must or must not be matched for a search result to be matched. You can also exclude certain usenet posters and groups which are known for spamming. You can define forbidden and required words for categories in the next tab (Categories). Usually required or forbidden words are applied on a word base, so they must form a complete word in a title. Only if they contain a dash or a dot they may appear anywhere in the title. Example: \"ea\" matches \"something.from.ea\" but not \"release.from.other\". \"web-dl\" matches \"title.web-dl\" and \"someweb-dl\".'\n },\n fieldGroup: [\n {\n key: 'applyRestrictions',\n type: 'horizontalSelect',\n templateOptions: {\n label: 'Apply word filters',\n options: [\n {name: 'All searches', value: 'BOTH'},\n {name: 'Internal searches', value: 'INTERNAL'},\n {name: 'API searches', value: 'API'},\n {name: 'Never', value: 'NONE'}\n ],\n help: \"For which type of search word/regex filters will be applied\"\n }\n },\n {\n key: 'forbiddenWords',\n type: 'horizontalChips',\n templateOptions: {\n type: 'text',\n label: 'Forbidden words',\n help: \"Results with any of these words in the title will be ignored. Title is converted to lowercase before. Apply words with return key.\",\n tooltip: 'One forbidden word in a result title dismisses the result.'\n },\n hideExpression: function () {\n return rootModel.searching.applyRestrictions === \"NONE\";\n }\n },\n {\n key: 'forbiddenRegex',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n label: 'Forbidden regex',\n help: 'Must not be present in a title (case is ignored).',\n advanced: true\n },\n hideExpression: function () {\n return rootModel.searching.applyRestrictions === \"NONE\";\n }\n },\n {\n key: 'requiredWords',\n type: 'horizontalChips',\n templateOptions: {\n type: 'text',\n label: 'Required words',\n help: \"Only results with titles that contain *all* words will be used. Title is converted to lowercase before. Apply words with return key.\",\n tooltip: 'If any of the required words is not found anywhere in a result title it\\'s also dismissed.'\n },\n hideExpression: function () {\n return rootModel.searching.applyRestrictions === \"NONE\";\n }\n },\n {\n key: 'requiredRegex',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n label: 'Required regex',\n help: 'Must be present in a title (case is ignored).',\n advanced: true\n },\n hideExpression: function () {\n return rootModel.searching.applyRestrictions === \"NONE\";\n }\n },\n\n {\n key: 'forbiddenGroups',\n type: 'horizontalChips',\n templateOptions: {\n type: 'text',\n label: 'Forbidden groups',\n help: 'Posts from any groups containing any of these words will be ignored. Apply words with return key.',\n advanced: true\n },\n hideExpression: function () {\n return rootModel.searching.applyRestrictions === \"NONE\";\n }\n },\n {\n key: 'forbiddenPosters',\n type: 'horizontalChips',\n templateOptions: {\n type: 'text',\n label: 'Forbidden posters',\n help: 'Posts from any posters containing any of these words will be ignored. Apply words with return key.',\n advanced: true\n }\n },\n {\n key: 'languagesToKeep',\n type: 'horizontalChips',\n templateOptions: {\n type: 'text',\n label: 'Languages to keep',\n help: 'If an indexer returns the language in the results only those results with configured languages will be used. Apply words with return key.'\n }\n },\n {\n key: 'maxAge',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'Maximum results age',\n help: 'Results older than this are ignored. Can be overwritten per search. Apply words with return key.',\n addonRight: {\n text: 'days'\n }\n }\n },\n {\n key: 'minSeeders',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'Minimum # seeders',\n help: 'Torznab results with fewer seeders will be ignored.'\n }\n },\n {\n key: 'ignorePassworded',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Ignore passworded releases',\n help: \"Not all indexers provide this information\",\n tooltip: 'Some indexers provide information if a release is passworded. If you select to ignore these releases only those will be ignored of which I know for sure that they\\'re actually passworded.'\n }\n }\n ]\n },\n {\n wrapper: 'fieldset',\n templateOptions: {\n label: 'Result processing'\n },\n fieldGroup: [\n {\n key: 'wrapApiErrors',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'text',\n label: 'Wrap API errors in empty results page',\n help: 'When enabled accessing tools will think the search was completed successfully but without results.',\n tooltip: 'In (hopefully) rare cases Hydra may crash when processing an API search request. You can enable to return an empty search page in these cases (if Hydra hasn\\'t crashed altogether ). This means that the calling tool (e.g. Sonarr) will think that the indexer (Hydra) is fine but just didn\\'t return a result. That way Hydra won\\'t be disabled as indexer but on the downside you may not be directly notified that an error occurred.',\n advanced: true\n }\n },\n {\n key: 'removeTrailing',\n type: 'horizontalChips',\n templateOptions: {\n type: 'text',\n label: 'Remove trailing...',\n help: 'Removed from title if it ends with either of these. Case insensitive and disregards leading/trailing spaces. Allows wildcards (\"*\"). Apply words with return key.',\n tooltip: 'Hydra contains a predefined list of words which will be removed if a search result title ends with them. This allows better duplicate detection and cleans up the titles. Trailing words will be removed until none of the defined strings are found at the end of the result title.'\n }\n },\n {\n key: 'useOriginalCategories',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Use original categories',\n help: 'Enable to use the category descriptions provided by the indexer.',\n tooltip: 'Hydra attempts to parse the provided newznab category IDs for results and map them to the configured categories. In some cases this may lead to category names which are not quite correct. You can select to use the original category name used by the indexer. This will only affect which category name is shown in the results.',\n advanced: true\n }\n }\n ]\n },\n {\n type: 'repeatSection',\n key: 'customMappings',\n model: rootModel.searching,\n templateOptions: {\n tooltip: 'Here you can define mappings to modify either queries or titles for search requests or to dynamically change the titles of found results. The former allows you, for example, to change requests made by external tools, the latter to clean up results by indexers in a more advanced way.',\n btnText: 'Add new custom mapping',\n altLegendText: 'Mapping',\n headline: 'Custom mappings of queries, search titles and result titles',\n advanced: true,\n fields: [\n {\n key: 'affectedValue',\n type: 'horizontalSelect',\n templateOptions: {\n label: 'Affected value',\n options: [\n {name: 'Query', value: 'QUERY'},\n {name: 'Search title', value: 'TITLE'},\n {name: 'Result title', value: 'RESULT_TITLE'},\n ],\n required: true,\n help: \"Determines which value of the search request or result will be processed\"\n }\n },\n {\n key: 'searchType',\n type: 'horizontalSelect',\n hideExpression: 'model.affectedValue === \"RESULT_TITLE\"',\n templateOptions: {\n label: 'Search type',\n options: [\n {name: 'General', value: 'SEARCH'},\n {name: 'Audio', value: 'MUSIC'},\n {name: 'EBook', value: 'BOOK'},\n {name: 'Movie', value: 'MOVIE'},\n {name: 'TV', value: 'TVSEARCH'}\n ],\n help: \"Determines in what context the mapping will be executed\"\n }\n },\n {\n key: 'matchAll',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Match whole string',\n help: 'If true then the input pattern must match the whole affected value. If false then any match will be replaced, even if it\\'s only part of the affected value.'\n }\n },\n {\n key: 'from',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n label: 'Input pattern',\n help: 'Pattern which must match the query or title of a search request (completely or in part, depending on the previous setting). You may use regexes in groups which can be referenced in the output puttern by using {group:regex}. Case insensitive.',\n required: true\n }\n },\n {\n key: 'to',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n label: 'Output pattern',\n required: true,\n help: 'If a query or title matches the input pattern it will be replaced using this. You may reference groups from the input pattern by using {group}. Additionally you may use {season:0} or {season:00} or {episode:0} or {episode:00} (with and without leading zeroes). Use <remove> to remove the match.'\n }\n },\n {\n type: 'customMappingTest',\n }\n ],\n defaultModel: {\n searchType: null,\n affectedValue: null,\n matchAll: true,\n from: null,\n to: null\n }\n }\n },\n\n\n {\n wrapper: 'fieldset',\n templateOptions: {\n label: 'Result display'\n },\n fieldGroup: [\n {\n key: 'loadAllCachedOnInternal',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Display all retrieved results',\n help: 'Load all results already retrieved from indexers. Might make sorting / filtering a bit slower. Will still be paged according to the limit set above.',\n advanced: true\n }\n },\n {\n key: 'loadLimitInternal',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'Display...',\n addonRight: {\n text: 'results per page'\n },\n max: 500,\n required: true,\n help: 'Determines the number of results shown on one page. This might also cause more API hits because indexers are queried until the number of results is matched or all indexers are exhausted. Limit is 500.',\n advanced: true\n }\n },\n {\n key: 'coverSize',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'Cover width',\n addonRight: {\n text: 'px'\n },\n required: true,\n help: 'Determines width of covers in search results (when enabled in display options).'\n }\n }\n ]\n }, {\n wrapper: 'fieldset',\n templateOptions: {\n label: 'Quick filters'\n },\n fieldGroup: [\n {\n key: 'showQuickFilterButtons',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Show quick filters',\n help: 'Show quick filter buttons for movie and TV results.'\n }\n },\n {\n key: 'alwaysShowQuickFilterButtons',\n type: 'horizontalSwitch',\n hideExpression: '!model.showQuickFilterButtons',\n templateOptions: {\n type: 'switch',\n label: 'Always show quick filters',\n help: 'Show all quick filter buttons for all types of searches.',\n advanced: true\n }\n },\n {\n key: 'customQuickFilterButtons',\n type: 'horizontalChips',\n hideExpression: '!model.showQuickFilterButtons',\n templateOptions: {\n type: 'text',\n label: 'Custom quick filters',\n help: 'Enter in the format DisplayName=Required1,Required2. Prefix words with ! to exclude them. Apply values with enter key.',\n tooltip: 'E.g. use WEB=webdl,web-dl. for a quick filter with the name \"WEB\" to be displayed that searches for \"webdl\" and \"web-dl\" in lowercase search results.',\n advanced: true\n }\n },\n {\n key: 'preselectQuickFilterButtons',\n type: 'horizontalMultiselect',\n hideExpression: '!model.showQuickFilterButtons',\n templateOptions: {\n label: 'Preselect quickfilters',\n help: 'Choose which quickfilters will be selected by default.',\n options: [\n {id: 'source|camts', label: 'CAM / TS'},\n {id: 'source|tv', label: 'TV'},\n {id: 'source|web', label: 'WEB'},\n {id: 'source|dvd', label: 'DVD'},\n {id: 'source|bluray', label: 'Blu-Ray'},\n {id: 'quality|q480p', label: '480p'},\n {id: 'quality|q720p', label: '720p'},\n {id: 'quality|q1080p', label: '1080p'},\n {id: 'quality|q2160p', label: '2160p'},\n {id: 'other|q3d', label: '3D'},\n {id: 'other|qx265', label: 'x265'},\n {id: 'other|qhevc', label: 'HEVC'},\n ],\n optionsFunction: function (model) {\n var customQuickFilters = [];\n _.each(model.customQuickFilterButtons, function (entry) {\n var split1 = entry.split(\"=\");\n var displayName = split1[0];\n customQuickFilters.push({id: \"custom|\" + displayName, label: displayName})\n })\n return customQuickFilters;\n },\n tooltip: 'To select custom quickfilters you just entered please save the config first.',\n buttonText: \"None\",\n advanced: true\n }\n }\n ]\n },\n {\n wrapper: 'fieldset',\n templateOptions: {\n label: 'Duplicate detection',\n tooltip: 'Hydra tries to find duplicate results from different indexers using heuristics. You can control the parameters for that but usually the default values work quite well.',\n advanced: true\n },\n fieldGroup: [\n {\n key: 'duplicateSizeThresholdInPercent',\n type: 'horizontalPercentInput',\n templateOptions: {\n type: 'text',\n label: 'Duplicate size threshold',\n required: true,\n addonRight: {\n text: '%'\n }\n\n }\n },\n {\n key: 'duplicateAgeThreshold',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'Duplicate age threshold',\n required: true,\n addonRight: {\n text: 'hours'\n }\n }\n }\n\n ]\n },\n {\n wrapper: 'fieldset',\n templateOptions: {\n label: 'Other',\n advanced: true\n },\n fieldGroup: [\n {\n key: 'keepSearchResultsForDays',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'Store results for ...',\n addonRight: {\n text: 'days'\n },\n required: true,\n tooltip: 'Found results are stored in the database for this long until they\\'re deleted. After that any links to Hydra results still stored elsewhere become invalid. You can increase the limit if you want, the disc space needed is negligible (about 75 MB for 7 days on my server).'\n }\n },\n {\n key: 'globalCacheTimeMinutes',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'Results cache time',\n help: 'When set search results will be cached for this time. Any search with the same parameters will return the cached results. API cache time parameters will be preferred. See wiki.',\n addonRight: {\n text: 'minutes'\n }\n }\n }\n ]\n }\n ],\n\n categoriesConfig: [\n {\n key: 'enableCategorySizes',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Category sizes',\n help: \"Preset min and max sizes depending on the selected category\",\n tooltip: 'Preset range of minimum and maximum sizes for its categories. When you select a category in the search area the appropriate fields are filled with these values.'\n }\n },\n {\n key: 'defaultCategory',\n type: 'horizontalSelect',\n templateOptions: {\n label: 'Default category',\n options: [],\n help: \"Set a default category. Reload page to set a category you just added.\"\n },\n controller: function ($scope) {\n var options = [];\n options.push({name: 'All', value: 'All'});\n _.each($scope.model.categories, function (cat) {\n options.push({name: cat.name, value: cat.name});\n });\n $scope.to.options = options;\n }\n },\n {\n type: 'help',\n templateOptions: {\n type: 'help',\n lines: [\n \"The category configuration is not validated in any way. You can seriously fuck up Hydra's results and overall behavior so take care.\",\n \"Restrictions will taken from a result's category, not the search request category which may not always be the same.\"\n ],\n marginTop: '50px',\n advanced: true\n }\n },\n {\n type: 'repeatSection',\n key: 'categories',\n model: rootModel.categoriesConfig,\n templateOptions: {\n btnText: 'Add new category',\n headline: 'Categories',\n advanced: true,\n fields: [\n {\n key: 'name',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n label: 'Name',\n help: 'Renaming categories might cause problems with repeating searches from the history.',\n required: true\n }\n },\n {\n key: 'searchType',\n type: 'horizontalSelect',\n templateOptions: {\n label: 'Search type',\n options: [\n {name: 'General', value: 'SEARCH'},\n {name: 'Audio', value: 'MUSIC'},\n {name: 'EBook', value: 'BOOK'},\n {name: 'Movie', value: 'MOVIE'},\n {name: 'TV', value: 'TVSEARCH'}\n ],\n help: \"Determines how indexers will be searched and if autocompletion is available in the GUI\"\n }\n },\n {\n key: 'subtype',\n type: 'horizontalSelect',\n templateOptions: {\n label: 'Sub type',\n options: [\n {name: 'Anime', value: 'ANIME'},\n {name: 'Audiobook', value: 'AUDIOBOOK'},\n {name: 'Comic', value: 'COMIC'},\n {name: 'Ebook', value: 'EBOOK'},\n {name: 'None', value: 'NONE'}\n ],\n help: \"Special search type. Used for indexer specific mappings between categories and newznab IDs\"\n }\n },\n {\n key: 'applyRestrictionsType',\n type: 'horizontalSelect',\n templateOptions: {\n label: 'Apply restrictions',\n options: [\n {name: 'All searches', value: 'BOTH'},\n {name: 'Internal searches', value: 'INTERNAL'},\n {name: 'API searches', value: 'API'},\n {name: 'Never', value: 'NONE'}\n ],\n help: \"For which type of search word restrictions will be applied\"\n }\n },\n {\n key: 'requiredWords',\n type: 'horizontalChips',\n templateOptions: {\n type: 'text',\n label: 'Required words',\n help: \"Must *all* be present in a title which is converted to lowercase before. Apply words with return key.\"\n }\n },\n {\n key: 'requiredRegex',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n label: 'Required regex',\n help: 'Must be present in a title (case is ignored).'\n }\n },\n {\n key: 'forbiddenWords',\n type: 'horizontalChips',\n templateOptions: {\n type: 'text',\n label: 'Forbidden words',\n help: \"None may be present in a title which is converted to lowercase before. Apply words with return key.\"\n }\n },\n {\n key: 'forbiddenRegex',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n label: 'Forbidden regex',\n help: 'Must not be present in a title (case is ignored).'\n }\n },\n {\n wrapper: 'settingWrapper',\n templateOptions: {\n label: 'Size preset',\n help: \"Will set these values on the search page\"\n },\n fieldGroup: [\n {\n key: 'minSizePreset',\n type: 'duoSetting',\n templateOptions: {\n addonRight: {\n text: 'MB'\n }\n\n }\n },\n {\n type: 'duolabel'\n },\n {\n key: 'maxSizePreset',\n type: 'duoSetting', templateOptions: {addonRight: {text: 'MB'}}\n }\n ]\n },\n {\n key: 'applySizeLimitsToApi',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Limit API results size',\n help: \"Enable to apply the size preset to API results from this category\"\n }\n },\n {\n key: 'newznabCategories',\n type: 'horizontalChips',\n templateOptions: {\n type: 'text',\n label: 'Newznab categories',\n help: 'Map newznab categories to Hydra categories. Used for parsing and when searching internally. Apply categories with return key.',\n tooltip: 'Hydra tries to map API search (newnzab) categories to its internal list of categories, going from specific to general. Example: If an API search is done with a catagory that matches those of \"Movies HD\" the settings for that category are used. Otherwise it checks if it matches the \"Movies\" category and, if yes, uses that one. If that one doesn\\'t match no category settings are used.
' +\n 'Related to that you must also define the newznab categories for every Hydra category, e.g. decide if the category for foreign movies (2010) is used for movie searches. This also controls the category mapping described above. You may combine newznab categories using \"&\" to require multiple numbers to be present in a result. For example \"2010&11000\" would require a search result to contain both 2010 and 11000 for that category to match.
' +\n 'Note: When an API search defines categories the internal mapping is only used for the forbidden and required words. The search requests to your newznab indexers will still use the categories from the original request, not the ones configured here.'\n }\n },\n {\n key: 'ignoreResultsFrom',\n type: 'horizontalSelect',\n templateOptions: {\n label: 'Ignore results',\n options: [\n {name: 'For all searches', value: 'BOTH'},\n {name: 'For internal searches', value: 'INTERNAL'},\n {name: 'For API searches', value: 'API'},\n {name: 'Never', value: 'NONE'}\n ],\n help: \"Ignore results from this category\",\n tooltip: 'If you want you can entirely ignore results from categories. Results from these categories will not show in the searches. If you select \"Internal\" or \"Always\" this category will also not be selectable on the search page.'\n }\n }\n\n ],\n defaultModel: {\n name: null,\n applySizeLimitsToApi: false,\n applyRestrictionsType: \"NONE\",\n forbiddenRegex: null,\n forbiddenWords: [],\n ignoreResultsFrom: \"NONE\",\n mayBeSelected: true,\n maxSizePreset: null,\n minSizePreset: null,\n newznabCategories: [],\n preselect: true,\n requiredRegex: null,\n requiredWords: [],\n searchType: \"SEARCH\",\n subtype: \"NONE\"\n }\n }\n }\n ],\n downloading: [\n {\n wrapper: 'fieldset',\n templateOptions: {\n label: 'General',\n tooltip: 'Hydra allows sending NZB search results directly to downloaders (NZBGet, sabnzbd). Torrent downloaders are not supported.'\n },\n fieldGroup: [\n {\n key: 'saveTorrentsTo',\n type: 'fileInput',\n templateOptions: {\n label: 'Torrent black hole',\n help: 'Allow torrents to be saved in this folder from the search results. Ignored if not set.',\n type: \"folder\"\n }\n },\n {\n key: 'saveNzbsTo',\n type: 'fileInput',\n templateOptions: {\n label: 'NZB black hole',\n help: 'Allow NZBs to be saved in this folder from the search results. Ignored if not set.',\n type: \"folder\"\n }\n },\n {\n key: 'nzbAccessType',\n type: 'horizontalSelect',\n templateOptions: {\n type: 'select',\n label: 'NZB access type',\n options: [\n {name: 'Proxy NZBs from indexer', value: 'PROXY'},\n {name: 'Redirect to the indexer', value: 'REDIRECT'}\n ],\n help: \"How access to NZBs is provided when NZBs are downloaded (by the user or external tools). Proxying is recommended as it allows fallback for failed downloads (see below)..\",\n tooltip: 'NZB downloads from Hydra can either be achieved by redirecting the requester to the original indexer or by downloading the NZB from the indexer and serving this. Redirecting has the advantage that it causes the least load on Hydra but also the disadvantage that the requester might be forwarded to an indexer link that contains the indexer\\'s API key. To prevent that select to proxy NZBs. It also allows fallback for failed downloads (next option).',\n advanced: true\n\n }\n },\n {\n key: 'externalUrl',\n type: 'horizontalInput',\n hideExpression: function ($viewValue, $modelValue, scope) {\n return !_.any(scope.model.downloaders, function (downloader) {\n return downloader.nzbAddingType === \"SEND_LINK\";\n });\n },\n templateOptions: {\n label: 'External URL',\n help: 'Used for links when sending links to the downloader.',\n tooltip: 'When using \"Add links\" to add NZBs to your downloader the links are usually calculated using the URL with which you accessed NZBHydra. This might be a URL that\\'s not accessible by the downloader (e.g. when it\\'s inside a docker container). Set the URL for NZBHydra that\\'s accessible by the downloader here and it will be used instead. ',\n advanced: true\n }\n },\n\n {\n key: 'fallbackForFailed',\n type: 'horizontalSelect',\n hideExpression: 'model.nzbAccessType === \"REDIRECT\"',\n templateOptions: {\n label: 'Fallback for failed downloads',\n options: [\n {name: 'GUI downloads', value: 'INTERNAL'},\n {name: 'API downloads', value: 'API'},\n {name: 'All downloads', value: 'BOTH'},\n {name: 'Never', value: 'NONE'}\n ],\n help: \"Fallback to similar results when a download fails. Only available when proxying NZBs (see above).\",\n tooltip: \"When you or an external program tries to download an NZB from NZBHydra the download may fail because the indexer is offline or its download limit has been reached. You can use this setting for NZBHydra to try and fall back on results from other indexers. It will search for results with the same name that were the result from the same search as where the download originated from. It will *not* execute another search.\"\n }\n },\n {\n key: 'sendMagnetLinks',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Send magnet links',\n help: \"Enable to send magnet links to the associated program on the server machine. Won't work with docker\"\n }\n },\n {\n key: 'updateStatuses',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Update statuses',\n help: \"Query your downloader for status updates of downloads\",\n advanced: true\n }\n },\n {\n key: 'showDownloaderStatus',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Show downloader footer',\n help: \"Show footer with downloader status\",\n advanced: true\n }\n },\n {\n key: 'primaryDownloader',\n type: 'horizontalSelect',\n hideExpression: 'model.downloaders.length <= 1 || !model.showDownloaderStatus',\n templateOptions: {\n label: 'Primary downloader',\n options: [],\n help: \"This downloader's state will be shown in the footer.\",\n tooltip: \"To select a downloader you just added please save the config first.\",\n optionsFunction: function (model) {\n var downloaders = [];\n _.each(model.downloaders, function (downloader) {\n downloaders.push({name: downloader.name, value: downloader.name})\n })\n return downloaders;\n },\n optionsFunctionAfter: function (model) {\n if (!model.primaryDownloader) {\n model.primaryDownloader = model.downloaders[0].name;\n }\n }\n }\n },\n ]\n },\n {\n wrapper: 'fieldset',\n key: 'downloaders',\n templateOptions: {label: 'Downloaders'},\n fieldGroup: [\n {\n type: \"downloaderConfig\",\n data: {}\n }\n ]\n }\n ],\n\n indexers: [\n {\n type: \"indexers\",\n data: {}\n },\n {\n type: 'recheckAllCaps'\n }\n ],\n auth: [\n {\n wrapper: 'fieldset',\n templateOptions: {\n label: 'Main',\n\n },\n fieldGroup: [\n {\n key: 'authType',\n type: 'horizontalSelect',\n templateOptions: {\n label: 'Auth type',\n options: [\n {name: 'None', value: 'NONE'},\n {name: 'HTTP Basic auth', value: 'BASIC'},\n {name: 'Login form', value: 'FORM'}\n ],\n tooltip: '
' +\n '
With auth type \"None\" all areas are unrestricted.
' +\n '
With auth type \"Form\" the basic page is loaded and login is done via a form.
' +\n '
With auth type \"Basic\" you login via basic HTTP authentication. With all areas restricted this is the most secure as nearly no data is loaded from the server before you auth. Logging out is not supported with basic auth.
' +\n '
'\n }\n },\n {\n key: 'authHeader',\n type: 'horizontalInput',\n templateOptions: {\n type: 'string',\n label: 'Auth header',\n help: 'Name of header that provides the username in requests from secure sources.',\n advanced: true\n },\n hideExpression: function () {\n return rootModel.auth.authType === \"NONE\";\n }\n },\n {\n key: 'authHeaderIpRanges',\n type: 'horizontalChips',\n templateOptions: {\n type: 'text',\n label: 'Secure IP ranges',\n help: 'IP ranges from which the auth header will be accepted. Apply with return key. Use values like \"192.168.0.1-192.168.0.100\" or single IP addresses like \"127.0.0.1\".',\n advanced: true\n },\n hideExpression: function () {\n return rootModel.auth.authType === \"NONE\" || _.isNullOrEmpty(rootModel.auth.authHeader);\n }\n },\n {\n key: 'rememberUsers',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Remember users',\n help: 'Remember users with cookie for 14 days.'\n },\n hideExpression: function () {\n return rootModel.auth.authType === \"NONE\";\n }\n },\n {\n key: 'rememberMeValidityDays',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'Cookie expiry',\n help: 'How long users are remembered.',\n addonRight: {\n text: 'days'\n },\n advanced: true\n }\n }\n\n ]\n },\n\n {\n wrapper: 'fieldset',\n templateOptions: {\n label: 'Restrictions',\n tooltip: 'Select which areas/features can only be accessed by logged in users (i.e. are restricted). If you don\\'t to allow anonymous users to do anything just leave everything selected. You can decide for every user if he is allowed to: ' +\n '
\\n' +\n '
view the search page at all
\\n' +\n '
view the stats
\\n' +\n '
access the admin area (config and control)
\\n' +\n '
view links for downloading NZBs and see their details
\\n' +\n '
may select which indexers are used for search.
\\n' +\n '
'\n },\n hideExpression: function () {\n return rootModel.auth.authType === \"NONE\";\n },\n fieldGroup: [\n {\n key: 'restrictSearch',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Restrict searching',\n help: 'Restrict access to searching.'\n }\n },\n {\n key: 'restrictStats',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Restrict stats',\n help: 'Restrict access to stats.'\n }\n },\n {\n key: 'restrictAdmin',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Restrict admin',\n help: 'Restrict access to admin functions.'\n }\n },\n {\n key: 'restrictDetailsDl',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Restrict NZB details & DL',\n help: 'Restrict NZB details, comments and download links.'\n }\n },\n {\n key: 'restrictIndexerSelection',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Restrict indexer selection box',\n help: 'Restrict visibility of indexer selection box in search. Affects only GUI.'\n }\n },\n {\n key: 'allowApiStats',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Allow stats access',\n help: 'Allow access to stats via external API.'\n }\n }\n ]\n },\n\n {\n type: 'repeatSection',\n key: 'users',\n model: rootModel.auth,\n hideExpression: function () {\n return rootModel.auth.authType === \"NONE\";\n },\n templateOptions: {\n btnText: 'Add new user',\n altLegendText: 'Authless',\n headline: 'Users',\n fields: [\n {\n key: 'username',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n label: 'Username',\n required: true\n }\n },\n {\n key: 'password',\n type: 'passwordSwitch',\n templateOptions: {\n type: 'password',\n label: 'Password',\n required: true\n }\n },\n {\n key: 'maySeeAdmin',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'May see admin area'\n }\n },\n {\n key: 'maySeeStats',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'May see stats'\n },\n hideExpression: 'model.maySeeAdmin'\n },\n {\n key: 'maySeeDetailsDl',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'May see NZB details & DL links'\n },\n hideExpression: 'model.maySeeAdmin'\n },\n {\n key: 'showIndexerSelection',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'May see indexer selection box'\n },\n hideExpression: 'model.maySeeAdmin'\n }\n ],\n defaultModel: {\n username: null,\n password: null,\n token: null,\n maySeeStats: true,\n maySeeAdmin: true,\n maySeeDetailsDl: true,\n showIndexerSelection: true\n }\n }\n }\n ],\n notificationConfig: [\n {\n type: 'help',\n templateOptions: {\n type: 'help',\n lines: [\n \"NZBHydra supports sending and displaying notifications for certain events. You can enable notifications for each event by adding entries below.\",\n 'NZBHydra uses Apprise to communicate with the actual notification providers. You need either a) an instance of Apprise API running or b) an Apprise runnable accessible by NZBHydra. Either are not part of NZBHydra.',\n \"NZBHydra will also show notifications on the GUI if enabled.\"\n ]\n }\n },\n {\n wrapper: 'fieldset',\n templateOptions: {\n label: 'Main'\n },\n fieldGroup: [\n\n {\n key: 'appriseType',\n type: 'horizontalSelect',\n templateOptions: {\n type: 'select',\n label: 'Apprise type',\n options: [\n {name: 'None', value: 'NONE'},\n {name: 'API', value: 'API'},\n {name: 'CLI', value: 'CLI'}\n ]\n }\n },\n {\n key: 'appriseApiUrl',\n type: 'horizontalInput',\n templateOptions: {\n type: 'string',\n label: 'Apprise API URL',\n help: 'URL of Apprise API to send notifications to.'\n },\n hideExpression: 'model.appriseType !== \"API\"'\n },\n {\n key: 'appriseCliPath',\n type: 'fileInput',\n templateOptions: {\n type: 'file',\n label: 'Apprise runnable',\n help: 'Full path of of Apprise runnable to execute.'\n },\n hideExpression: 'model.appriseType !== \"CLI\"'\n },\n {\n key: 'displayNotifications',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Display notifications',\n help: 'If enabled notifications will be shown on the GUI.'\n }\n },\n {\n key: 'displayNotificationsMax',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'Show max notifications',\n help: 'Max number of notifications to show on the GUI. If more have piled up a notification will indicate this and link to the notification history.'\n },\n hideExpression: '!model.displayNotifications'\n },\n {\n key: 'filterOuts',\n type: 'horizontalChips',\n templateOptions: {\n type: 'text',\n label: 'Hide if message contains...',\n help: 'Apply values with return key. Surround with \"/\" for regex (e.g. /contains[0-9]This/). Case insensitive.',\n\n },\n hideExpression: '!model.displayNotifications'\n }\n ]\n },\n\n {\n type: 'notificationSection',\n key: 'entries',\n model: rootModel.notificationConfig,\n templateOptions: {\n btnText: 'Add new notification',\n altLegendText: 'Notification',\n headline: 'Notifications',\n fields: [\n {\n key: 'appriseUrls',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n label: 'URLs',\n help: 'One or more URLs identifying where the notification should be sent to, comma-separated.'\n }\n },\n {\n key: 'titleTemplate',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n label: 'Title template'\n },\n controller: notificationTemplateHelpController\n },\n {\n key: 'bodyTemplate',\n type: 'horizontalTextArea',\n templateOptions: {\n type: 'text',\n label: 'Body template',\n required: true\n },\n controller: notificationTemplateHelpController\n },\n {\n key: 'messageType',\n type: 'horizontalSelect',\n templateOptions: {\n label: 'Message type',\n options: [\n {name: 'Info', value: 'INFO'},\n {name: 'Success', value: 'SUCCESS'},\n {name: 'Warning', value: 'WARNING'},\n {name: 'Failure', value: 'FAILURE'}\n ],\n help: \"Select the message type to use.\"\n }\n },\n {\n key: 'bodyTemplate',\n type: 'horizontalTestNotification'\n }\n\n ],\n defaultModel: {\n eventType: null,\n appriseUrls: null,\n titleTemplate: null,\n bodyTemplate: null,\n messageType: 'WARNING'\n }\n }\n }\n ]\n\n }\n\n function notificationTemplateHelpController($scope, NotificationService) {\n $scope.model.eventTypeReadable = NotificationService.humanize($scope.model.eventType);\n $scope.to.help = NotificationService.getTemplateHelp($scope.model.eventType);\n }\n }\n}\n\nfunction handleConnectionCheckFail(ModalService, data, model, whatFailed, deferred) {\n var message;\n var yesText;\n if (data.checked) {\n message = \"The connection to the \" + whatFailed + \" failed: \" + data.message + \" Do you want to add it anyway?\";\n yesText = \"I know what I'm doing\";\n } else {\n message = \"The connection to the \" + whatFailed + \" could not be tested, sorry. Please check the log.\";\n yesText = \"I'll risk it\";\n }\n ModalService.open(\"Connection check failed\", message, {\n yes: {\n onYes: function () {\n deferred.resolve();\n },\n text: yesText\n },\n no: {\n onNo: function () {\n model.enabled = false;\n deferred.resolve();\n },\n text: \"Add it, but disabled\"\n },\n cancel: {\n onCancel: function () {\n deferred.reject();\n },\n text: \"Aahh, let me try again\"\n }\n });\n}\n","\nConfigController.$inject = [\"$scope\", \"$http\", \"activeTab\", \"ConfigService\", \"config\", \"DownloaderCategoriesService\", \"ConfigFields\", \"ConfigModel\", \"ModalService\", \"RestartService\", \"localStorageService\", \"$state\", \"growl\", \"$window\"];angular\n .module('nzbhydraApp')\n .factory('ConfigModel', function () {\n return {};\n });\n\nangular\n .module('nzbhydraApp')\n .factory('ConfigWatcher', function () {\n var $scope;\n\n return {\n watch: watch\n };\n\n function watch(scope) {\n $scope = scope;\n $scope.$watchGroup([\"config.main.host\"], function () {\n }, true);\n }\n });\n\n\nangular\n .module('nzbhydraApp')\n .controller('ConfigController', ConfigController);\n\nfunction ConfigController($scope, $http, activeTab, ConfigService, config, DownloaderCategoriesService, ConfigFields, ConfigModel, ModalService, RestartService, localStorageService, $state, growl, $window) {\n $scope.config = config;\n $scope.submit = submit;\n $scope.activeTab = activeTab;\n\n $scope.restartRequired = false;\n $scope.ignoreSaveNeeded = false;\n console.log(localStorageService.get(\"showAdvanced\"));\n if (localStorageService.get(\"showAdvanced\") === null) {\n $scope.showAdvanced = false;\n localStorageService.set(\"showAdvanced\", false);\n } else {\n $scope.showAdvanced = localStorageService.get(\"showAdvanced\");\n }\n\n\n $scope.toggleShowAdvanced = function () {\n $scope.showAdvanced = !$scope.showAdvanced;\n var wasDirty = $scope.form.$dirty === true;\n\n $scope.allTabs[$scope.activeTab].model.showAdvanced = $scope.showAdvanced === true;\n //Also save in main tab where it will be stored to file\n $scope.allTabs[0].model.showAdvanced = $scope.allTabs[$scope.activeTab].model.showAdvanced === true;\n $scope.form.$dirty = wasDirty;\n localStorageService.set(\"showAdvanced\", $scope.showAdvanced);\n }\n\n function updateAndAskForRestartIfNecessary(responseData) {\n if (angular.isUndefined($scope.form)) {\n console.error(\"Unable to determine if a restart is necessary\");\n return;\n }\n\n $scope.form.$setPristine();\n DownloaderCategoriesService.invalidate();\n if ($scope.restartRequired) {\n ModalService.open(\"Restart required\", \"The changes you have made may require a restart to be effective. Do you want to restart now?\", {\n yes: {\n onYes: function () {\n RestartService.restart();\n }\n },\n no: {\n onNo: function ($uibModalInstance) {\n //Needs to be clicked twice for some reason\n $scope.restartRequired = false;\n $uibModalInstance.dismiss();\n $uibModalInstance.dismiss();\n $scope.config = responseData.newConfig;\n $window.location.reload();\n }\n }\n });\n } else {\n $scope.config = responseData.newConfig;\n $window.location.reload();\n }\n }\n\n function handleConfigSetResponse(response, ignoreWarnings, restartNeeded) {\n if (angular.isUndefined(ignoreWarnings)) {\n ignoreWarnings = localStorageService.get(\"ignoreWarnings\") !== null ? localStorageService.get(\"ignoreWarnings\") : false;\n }\n //Communication with server was successful but there might be validation errors and/or warnings\n var warningMessages = response.data.warningMessages;\n var errorMessages = response.data.errorMessages;\n $scope.restartRequired = response.data.restartNeeded || (angular.isDefined(restartNeeded) ? restartNeeded : false);\n var showMessage = errorMessages.length > 0 || (warningMessages.length > 0 && !ignoreWarnings);\n\n function extendMessageWithList(message, messages) {\n _.forEach(messages, function (x) {\n message += \"
\" + x + \"
\";\n });\n message += \"
\";\n return message;\n }\n\n if (showMessage) {\n var options;\n var message;\n var title;\n if (errorMessages.length > 0) { //Actual errors which cannot be ignored\n title = \"Config validation failed\";\n message = 'The following errors have been found in your config. They need to be fixed.
';\n message = extendMessageWithList(message, response.data.errorMessages);\n if (warningMessages.length > 0) {\n message += ' The following warnings were found. You can ignore them if you wish.
';\n message = extendMessageWithList(message, response.data.warningMessages);\n }\n options = {\n yes: {\n onYes: function () {\n },\n text: \"OK\"\n }\n };\n } else if (warningMessages.length > 0) {\n title = \"Config validation warnings\";\n message = ' The following warnings have been found. You can ignore them if you wish. The config was already saved.