mirror of
https://github.com/theotherp/nzbhydra2.git
synced 2026-02-06 11:17:18 +00:00
Add syncing with external tools
This commit is contained in:
parent
cc6fd534b4
commit
717e2f12ab
10
core/.claude/settings.local.json
Normal file
10
core/.claude/settings.local.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Read(//c/Users/strat/IdeaProjects/nzbhydra2/**)",
|
||||
"Bash(mvn:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
}
|
||||
}
|
||||
@ -5,12 +5,11 @@ import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.nzbhydra.GenericResponse;
|
||||
import org.nzbhydra.config.FileSystemBrowser.DirectoryListingRequest;
|
||||
import org.nzbhydra.config.FileSystemBrowser.FileSystemEntry;
|
||||
import org.nzbhydra.config.indexer.IndexerConfig;
|
||||
import org.nzbhydra.config.safeconfig.SafeConfig;
|
||||
import org.nzbhydra.config.validation.BaseConfigValidator;
|
||||
import org.nzbhydra.config.validation.ConfigValidationResult;
|
||||
import org.nzbhydra.externaltools.ExternalToolsSyncService;
|
||||
import org.nzbhydra.indexers.IndexerEntity;
|
||||
import org.nzbhydra.indexers.IndexerRepository;
|
||||
import org.nzbhydra.springnative.ReflectionMarker;
|
||||
@ -54,6 +53,8 @@ public class ConfigWeb {
|
||||
private BaseConfigValidator baseConfigValidator;
|
||||
@Autowired
|
||||
private BaseConfigHandler baseConfigHandler;
|
||||
@Autowired
|
||||
private ExternalToolsSyncService externalToolsSyncService;
|
||||
private final ConfigReaderWriter configReaderWriter = new ConfigReaderWriter();
|
||||
|
||||
@Secured({"ROLE_ADMIN"})
|
||||
@ -80,14 +81,33 @@ public class ConfigWeb {
|
||||
}
|
||||
|
||||
logger.info("Received new config");
|
||||
newConfig = baseConfigValidator.prepareForSaving(configProvider.getBaseConfig(), newConfig);
|
||||
ConfigValidationResult result = baseConfigValidator.validateConfig(configProvider.getBaseConfig(), newConfig, newConfig);
|
||||
BaseConfig oldConfig = configProvider.getBaseConfig();
|
||||
newConfig = baseConfigValidator.prepareForSaving(oldConfig, newConfig);
|
||||
ConfigValidationResult result = baseConfigValidator.validateConfig(oldConfig, newConfig, newConfig);
|
||||
if (result.isOk()) {
|
||||
handleRenamedIndexers(newConfig);
|
||||
|
||||
// Detect which indexers changed before replacing config
|
||||
Set<String> changedIndexers = externalToolsSyncService.detectChangedIndexers(
|
||||
oldConfig.getIndexers(),
|
||||
newConfig.getIndexers()
|
||||
);
|
||||
|
||||
baseConfigHandler.replace(newConfig);
|
||||
baseConfigHandler.save(true);
|
||||
result.setNewConfig(configProvider.getBaseConfig());
|
||||
|
||||
// Sync to external tools if enabled and indexers changed
|
||||
if (configProvider.getBaseConfig().getExternalTools().isSyncOnConfigChange() && !changedIndexers.isEmpty()) {
|
||||
logger.info("Indexers changed, syncing to external tools");
|
||||
try {
|
||||
ExternalToolsSyncService.SyncResult syncResult = externalToolsSyncService.syncTools(changedIndexers);
|
||||
logger.info("External tools sync completed: {} successful, {} failed",
|
||||
syncResult.getSuccessCount(), syncResult.getFailureCount());
|
||||
} catch (Exception e) {
|
||||
logger.error("Error syncing to external tools", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@ -141,7 +161,7 @@ public class ConfigWeb {
|
||||
|
||||
@Secured({"ROLE_USER"})
|
||||
@RequestMapping(value = "/internalapi/config/folderlisting", method = RequestMethod.POST, produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public FileSystemEntry getDirectoryListing(@RequestBody DirectoryListingRequest request) {
|
||||
public FileSystemBrowser.FileSystemEntry getDirectoryListing(@RequestBody FileSystemBrowser.DirectoryListingRequest request) {
|
||||
return fileSystemBrowser.getDirectoryListing(request);
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,272 @@
|
||||
/*
|
||||
* (C) Copyright 2023 TheOtherP (theotherp@posteo.net)
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.nzbhydra.externaltools;
|
||||
|
||||
import org.nzbhydra.config.ConfigProvider;
|
||||
import org.nzbhydra.config.ExternalToolConfig;
|
||||
import org.nzbhydra.config.ExternalToolsConfig;
|
||||
import org.nzbhydra.config.indexer.IndexerConfig;
|
||||
import org.nzbhydra.notifications.NotificationEntity;
|
||||
import org.nzbhydra.notifications.NotificationMessageType;
|
||||
import org.nzbhydra.notifications.NotificationRepository;
|
||||
import org.nzbhydra.web.UrlCalculator;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
@Component
|
||||
public class ExternalToolsSyncService {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(ExternalToolsSyncService.class);
|
||||
|
||||
@Autowired
|
||||
private ExternalTools externalTools;
|
||||
@Autowired
|
||||
private ConfigProvider configProvider;
|
||||
@Autowired
|
||||
private UrlCalculator urlCalculator;
|
||||
@Autowired
|
||||
private NotificationRepository notificationRepository;
|
||||
|
||||
/**
|
||||
* Sync all indexers to all configured external tools
|
||||
*
|
||||
* @return Summary of sync results
|
||||
*/
|
||||
public SyncResult syncAllTools() {
|
||||
return syncTools(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync specific indexers to all configured external tools
|
||||
*
|
||||
* @param changedIndexers Set of indexer names that were changed, or null to sync all
|
||||
* @return Summary of sync results
|
||||
*/
|
||||
public SyncResult syncTools(Set<String> changedIndexers) {
|
||||
ExternalToolsConfig config = configProvider.getBaseConfig().getExternalTools();
|
||||
if (!config.isSyncOnConfigChange()) {
|
||||
logger.debug("External tools sync is disabled");
|
||||
return new SyncResult(0, 0, new ArrayList<>());
|
||||
}
|
||||
|
||||
List<ExternalToolConfig> enabledTools = config.getExternalTools().stream()
|
||||
.filter(ExternalToolConfig::isEnabled)
|
||||
.toList();
|
||||
|
||||
if (enabledTools.isEmpty()) {
|
||||
logger.debug("No enabled external tools configured");
|
||||
return new SyncResult(0, 0, new ArrayList<>());
|
||||
}
|
||||
|
||||
logger.info("Starting sync to {} external tools", enabledTools.size());
|
||||
|
||||
int successCount = 0;
|
||||
int failureCount = 0;
|
||||
List<String> messages = new ArrayList<>();
|
||||
|
||||
for (ExternalToolConfig tool : enabledTools) {
|
||||
try {
|
||||
boolean success = syncToTool(tool, changedIndexers);
|
||||
if (success) {
|
||||
successCount++;
|
||||
messages.add("Successfully synced to " + tool.getName());
|
||||
} else {
|
||||
failureCount++;
|
||||
messages.addAll(externalTools.getMessages());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
failureCount++;
|
||||
logger.error("Failed to sync to {}: {}", tool.getName(), e.getMessage(), e);
|
||||
messages.add("Failed to sync to " + tool.getName() + ": " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Create notification
|
||||
createNotification(successCount, failureCount, messages);
|
||||
|
||||
return new SyncResult(successCount, failureCount, messages);
|
||||
}
|
||||
|
||||
private boolean syncToTool(ExternalToolConfig tool, Set<String> changedIndexers) throws IOException {
|
||||
logger.info("Syncing to {} ({})", tool.getName(), tool.getType());
|
||||
|
||||
// Convert tool config to AddRequest
|
||||
AddRequest request = buildAddRequest(tool, changedIndexers);
|
||||
|
||||
// Use existing ExternalTools logic
|
||||
boolean success = externalTools.addNzbhydraAsIndexer(request);
|
||||
|
||||
if (success) {
|
||||
logger.info("Successfully synced to {}", tool.getName());
|
||||
} else {
|
||||
logger.warn("Failed to sync to {}", tool.getName());
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
private AddRequest buildAddRequest(ExternalToolConfig tool, Set<String> changedIndexers) {
|
||||
AddRequest request = new AddRequest();
|
||||
|
||||
// Map tool type to ExternalTool enum
|
||||
AddRequest.ExternalTool externalToolType = switch (tool.getType()) {
|
||||
case SONARR -> AddRequest.ExternalTool.Sonarr;
|
||||
case RADARR -> AddRequest.ExternalTool.Radarr;
|
||||
case LIDARR -> AddRequest.ExternalTool.Lidarr;
|
||||
case READARR -> AddRequest.ExternalTool.Readarr;
|
||||
};
|
||||
request.setExternalTool(externalToolType);
|
||||
|
||||
// Set connection details
|
||||
request.setXdarrHost(tool.getHost());
|
||||
request.setXdarrApiKey(tool.getApiKey());
|
||||
request.setNzbhydraHost(urlCalculator.getRequestBasedUriBuilder().build().toUriString());
|
||||
request.setNzbhydraName(tool.getNzbhydraName());
|
||||
|
||||
// Set sync type
|
||||
AddRequest.AddType addType = tool.getSyncType() == ExternalToolConfig.SyncType.SINGLE
|
||||
? AddRequest.AddType.SINGLE
|
||||
: AddRequest.AddType.PER_INDEXER;
|
||||
request.setAddType(addType);
|
||||
|
||||
// If we're in PER_INDEXER mode and have changed indexers, only sync those
|
||||
if (addType == AddRequest.AddType.PER_INDEXER && changedIndexers != null && !changedIndexers.isEmpty()) {
|
||||
// This will be handled by the ExternalTools class by filtering indexers
|
||||
request.setAdditionalParameters("indexers=" + String.join(",", changedIndexers));
|
||||
}
|
||||
|
||||
// Set configuration options
|
||||
request.setConfigureForUsenet(tool.isConfigureForUsenet());
|
||||
request.setConfigureForTorrents(tool.isConfigureForTorrents());
|
||||
request.setAddDisabledIndexers(tool.isAddDisabledIndexers());
|
||||
request.setUseHydraPriorities(tool.isUseHydraPriorities());
|
||||
request.setPriority(tool.getPriority());
|
||||
|
||||
// Set RSS and search settings
|
||||
request.setEnableRss(tool.isEnableRss());
|
||||
request.setEnableAutomaticSearch(tool.isEnableAutomaticSearch());
|
||||
request.setEnableInteractiveSearch(tool.isEnableInteractiveSearch());
|
||||
|
||||
// Set categories
|
||||
request.setCategories(tool.getCategories());
|
||||
request.setAnimeCategories(tool.getAnimeCategories());
|
||||
|
||||
// Set additional settings
|
||||
request.setAdditionalParameters(tool.getAdditionalParameters());
|
||||
request.setMinimumSeeders(tool.getMinimumSeeders());
|
||||
request.setSeedRatio(tool.getSeedRatio());
|
||||
request.setSeedTime(tool.getSeedTime());
|
||||
request.setSeasonPackSeedTime(tool.getSeasonPackSeedTime());
|
||||
request.setDiscographySeedTime(tool.getDiscographySeedTime());
|
||||
request.setEarlyDownloadLimit(tool.getEarlyDownloadLimit());
|
||||
request.setRemoveYearFromSearchString(tool.isRemoveYearFromSearchString());
|
||||
|
||||
return request;
|
||||
}
|
||||
|
||||
private void createNotification(int successCount, int failureCount, List<String> messages) {
|
||||
String title = "External Tools Sync";
|
||||
String body;
|
||||
|
||||
if (failureCount == 0 && successCount > 0) {
|
||||
body = String.format("Successfully synced to %d external tool(s)", successCount);
|
||||
} else if (successCount == 0 && failureCount > 0) {
|
||||
body = String.format("Failed to sync to %d external tool(s). Check logs for details.", failureCount);
|
||||
} else {
|
||||
body = String.format("Synced to %d tool(s), %d failed. Check logs for details.", successCount, failureCount);
|
||||
}
|
||||
|
||||
NotificationEntity notification = new NotificationEntity();
|
||||
notification.setTime(Instant.now());
|
||||
notification.setTitle(title);
|
||||
notification.setBody(body);
|
||||
notification.setUrls(null);
|
||||
notification.setDisplayed(false);
|
||||
notification.setMessageType(failureCount > 0
|
||||
? NotificationMessageType.FAILURE
|
||||
: NotificationMessageType.SUCCESS);
|
||||
|
||||
notificationRepository.save(notification);
|
||||
}
|
||||
|
||||
public static class SyncResult {
|
||||
private final int successCount;
|
||||
private final int failureCount;
|
||||
private final List<String> messages;
|
||||
|
||||
public SyncResult(int successCount, int failureCount, List<String> messages) {
|
||||
this.successCount = successCount;
|
||||
this.failureCount = failureCount;
|
||||
this.messages = messages;
|
||||
}
|
||||
|
||||
public int getSuccessCount() {
|
||||
return successCount;
|
||||
}
|
||||
|
||||
public int getFailureCount() {
|
||||
return failureCount;
|
||||
}
|
||||
|
||||
public List<String> getMessages() {
|
||||
return messages;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect which indexers have changed between old and new config
|
||||
*/
|
||||
public Set<String> detectChangedIndexers(List<IndexerConfig> oldIndexers, List<IndexerConfig> newIndexers) {
|
||||
Set<String> changed = new HashSet<>();
|
||||
|
||||
// Check for added or modified indexers
|
||||
for (IndexerConfig newIndexer : newIndexers) {
|
||||
IndexerConfig oldIndexer = oldIndexers.stream()
|
||||
.filter(i -> i.getName().equals(newIndexer.getName()))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
|
||||
if (oldIndexer == null) {
|
||||
// New indexer
|
||||
changed.add(newIndexer.getName());
|
||||
} else if (!oldIndexer.equals(newIndexer)) {
|
||||
// Modified indexer
|
||||
changed.add(newIndexer.getName());
|
||||
}
|
||||
}
|
||||
|
||||
// Check for removed indexers
|
||||
for (IndexerConfig oldIndexer : oldIndexers) {
|
||||
boolean exists = newIndexers.stream()
|
||||
.anyMatch(i -> i.getName().equals(oldIndexer.getName()));
|
||||
if (!exists) {
|
||||
changed.add(oldIndexer.getName());
|
||||
}
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
}
|
||||
@ -33,6 +33,8 @@ public class ExternalToolsWeb {
|
||||
@Autowired
|
||||
private IndexerRepository indexerRepository;
|
||||
private final ConfigReaderWriter configReaderWriter = new ConfigReaderWriter();
|
||||
@Autowired
|
||||
private ExternalToolsSyncService externalToolsSyncService;
|
||||
|
||||
@Secured({"ROLE_ADMIN"})
|
||||
@RequestMapping(value = "/internalapi/externalTools/getDialogInfo", method = RequestMethod.GET, produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
@ -56,5 +58,52 @@ public class ExternalToolsWeb {
|
||||
return externalTools.getMessages();
|
||||
}
|
||||
|
||||
@Secured({"ROLE_ADMIN"})
|
||||
@RequestMapping(value = "/internalapi/externalTools/syncAll", method = RequestMethod.POST, produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public ExternalToolsSyncService.SyncResult syncAllTools() {
|
||||
return externalToolsSyncService.syncAllTools();
|
||||
}
|
||||
|
||||
@Secured({"ROLE_ADMIN"})
|
||||
@RequestMapping(value = "/internalapi/externalTools/testConnection", method = RequestMethod.POST, consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public ConnectionTestResult testConnection(@RequestBody AddRequest addRequest) {
|
||||
try {
|
||||
// Just test the connection without actually syncing
|
||||
boolean success = externalTools.addNzbhydraAsIndexer(buildTestRequest(addRequest));
|
||||
return new ConnectionTestResult(success, success ? "Connection successful" : "Connection failed");
|
||||
} catch (Exception e) {
|
||||
return new ConnectionTestResult(false, "Error: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private AddRequest buildTestRequest(AddRequest request) {
|
||||
// Create a test request that only checks connection without modifying anything
|
||||
AddRequest testRequest = new AddRequest();
|
||||
testRequest.setExternalTool(request.getExternalTool());
|
||||
testRequest.setXdarrHost(request.getXdarrHost());
|
||||
testRequest.setXdarrApiKey(request.getXdarrApiKey());
|
||||
testRequest.setAddType(AddRequest.AddType.DELETE_ONLY); // Only delete, don't add
|
||||
testRequest.setNzbhydraName("TEST_CONNECTION_" + System.currentTimeMillis());
|
||||
return testRequest;
|
||||
}
|
||||
|
||||
public static class ConnectionTestResult {
|
||||
private final boolean successful;
|
||||
private final String message;
|
||||
|
||||
public ConnectionTestResult(boolean successful, String message) {
|
||||
this.successful = successful;
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
public boolean isSuccessful() {
|
||||
return successful;
|
||||
}
|
||||
|
||||
public String getMessage() {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@ -0,0 +1,19 @@
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title">External Tool Configuration</h3>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<form class="form-horizontal config-form" name="form">
|
||||
<formly-form model="model" fields="fields" options="options" form="form">
|
||||
</formly-form>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-warning" type="button" ng-click="testConnection()" ng-show="needsConnectionTest || isInitial">
|
||||
Test connection
|
||||
</button>
|
||||
<button class="btn btn-primary" type="button" ng-click="obSubmit()">OK</button>
|
||||
<button class="btn btn-warning" type="button" ng-click="cancel()">Cancel</button>
|
||||
<button class="btn btn-danger" type="button" ng-click="deleteEntry()" ng-if="!isInitial">Delete</button>
|
||||
</div>
|
||||
@ -0,0 +1,62 @@
|
||||
<div class="form-horizontal config-section">
|
||||
|
||||
<div class="form-group">
|
||||
<label class="control-label col-md-2">External tools</label>
|
||||
|
||||
<div class="col-md-10">
|
||||
<p>Configure external tools (Sonarr, Radarr, Lidarr, Readarr) for automatic indexer synchronization.</p>
|
||||
<p>When indexers are added, removed or modified in NZBHydra, they will be automatically synced to all configured external tools.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" ng-if="!model.externalTools || model.externalTools.length === 0">
|
||||
<label class="control-label col-md-2"></label>
|
||||
<div class="col-md-10">
|
||||
<p><i>No external tools configured</i></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ng-repeat="entry in model.externalTools" class="form-group">
|
||||
<label class="control-label col-md-2"></label>
|
||||
<div class="col-md-8">
|
||||
<div class="btn btn-default" ng-click="showBox(entry, model.externalTools)" style="width: 100%; text-align: left">
|
||||
<span ng-class="entry.enabled ? 'glyphicon-ok' : 'glyphicon-remove'"
|
||||
class="glyphicon"
|
||||
style="color: gray"
|
||||
ng-style="{'color': entry.enabled ? 'green' : 'red'}"></span>
|
||||
<span style="margin-left: 10px">{{ entry.name }} ({{ entry.type }})</span>
|
||||
<span class="pull-right">{{ entry.host }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<button type="button" class="btn btn-danger" ng-click="model.externalTools.splice($index, 1); form.$setDirty(true)">
|
||||
<span class="glyphicon glyphicon-remove"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="control-label col-md-2"></label>
|
||||
<div class="col-md-10">
|
||||
<div class="btn-group" uib-dropdown dropdown-append-to-body>
|
||||
<button type="button" class="btn btn-default" uib-dropdown-toggle>
|
||||
Add external tool <span class="caret"></span>
|
||||
</button>
|
||||
<ul class="dropdown-menu" uib-dropdown-menu role="menu">
|
||||
<li role="menuitem" ng-repeat="preset in presets">
|
||||
<a href="" ng-click="addEntry(model.externalTools, preset)">{{ preset.name }}</a>
|
||||
</li>
|
||||
<li class="divider"></li>
|
||||
<li role="menuitem">
|
||||
<a href="" ng-click="addEntry(model.externalTools)">Custom</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<button type="button" class="btn btn-info" ng-click="syncAll()" style="margin-left: 10px">
|
||||
<span class="glyphicon glyphicon-refresh"></span> Sync All Now
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@ -4978,6 +4978,608 @@ function IndexerCheckBeforeCloseService($q, ModalService, IndexerConfigBoxServic
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* (C) Copyright 2023 TheOtherP (theotherp@posteo.net)
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
angular
|
||||
.module('nzbhydraApp')
|
||||
.config(["formlyConfigProvider", function config(formlyConfigProvider) {
|
||||
|
||||
formlyConfigProvider.setType({
|
||||
name: 'externalToolConfig',
|
||||
templateUrl: 'static/html/config/external-tool-config.html',
|
||||
controller: function ($scope, $uibModal, growl, localStorageService, $http) {
|
||||
$scope.formOptions = {formState: $scope.formState};
|
||||
$scope._showBox = _showBox;
|
||||
$scope.showBox = showBox;
|
||||
$scope.isInitial = false;
|
||||
$scope.presets = [
|
||||
{
|
||||
name: "Sonarr",
|
||||
type: "SONARR",
|
||||
host: "http://localhost:8989",
|
||||
categories: "5030,5040",
|
||||
syncType: "PER_INDEXER"
|
||||
},
|
||||
{
|
||||
name: "Radarr",
|
||||
type: "RADARR",
|
||||
host: "http://localhost:7878",
|
||||
categories: "2000",
|
||||
syncType: "PER_INDEXER"
|
||||
},
|
||||
{
|
||||
name: "Lidarr",
|
||||
type: "LIDARR",
|
||||
host: "http://localhost:8686",
|
||||
categories: "3000",
|
||||
syncType: "PER_INDEXER"
|
||||
},
|
||||
{
|
||||
name: "Readarr",
|
||||
type: "READARR",
|
||||
host: "http://localhost:8787",
|
||||
categories: "7020,8010",
|
||||
syncType: "PER_INDEXER"
|
||||
}
|
||||
];
|
||||
|
||||
function _showBox(model, parentModel, isInitial, callback) {
|
||||
var modalInstance = $uibModal.open({
|
||||
templateUrl: 'static/html/config/external-tool-config-box.html',
|
||||
controller: 'ExternalToolConfigBoxInstanceController',
|
||||
size: 'lg',
|
||||
resolve: {
|
||||
model: function () {
|
||||
model.showAdvanced = localStorageService.get("showAdvanced");
|
||||
return model;
|
||||
},
|
||||
fields: function () {
|
||||
return getExternalToolBoxFields(model, parentModel, isInitial);
|
||||
},
|
||||
isInitial: function () {
|
||||
return isInitial
|
||||
},
|
||||
parentModel: function () {
|
||||
return parentModel;
|
||||
},
|
||||
data: function () {
|
||||
return $scope.options.data;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
modalInstance.result.then(function (returnedModel) {
|
||||
$scope.form.$setDirty(true);
|
||||
if (angular.isDefined(callback)) {
|
||||
callback(true, returnedModel);
|
||||
}
|
||||
}, function () {
|
||||
if (angular.isDefined(callback)) {
|
||||
callback(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function showBox(model, parentModel) {
|
||||
$scope._showBox(model, parentModel, false)
|
||||
}
|
||||
|
||||
$scope.syncAll = function () {
|
||||
growl.info("Starting sync to all external tools...");
|
||||
$http.post('internalapi/externalTools/syncAll').then(
|
||||
function (response) {
|
||||
var result = response.data;
|
||||
if (result.failureCount === 0) {
|
||||
growl.success("Successfully synced to " + result.successCount + " external tool(s)");
|
||||
} else if (result.successCount === 0) {
|
||||
growl.error("Failed to sync to all " + result.failureCount + " external tool(s)");
|
||||
} else {
|
||||
growl.warning("Synced to " + result.successCount + " tool(s), " + result.failureCount + " failed");
|
||||
}
|
||||
},
|
||||
function (error) {
|
||||
growl.error("Error syncing to external tools: " + error.data);
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
$scope.addEntry = function (entriesCollection, preset) {
|
||||
var model = angular.copy({
|
||||
enabled: true,
|
||||
syncType: "PER_INDEXER",
|
||||
configureForUsenet: true,
|
||||
configureForTorrents: false,
|
||||
enableRss: true,
|
||||
enableAutomaticSearch: true,
|
||||
enableInteractiveSearch: true,
|
||||
useHydraPriorities: true,
|
||||
priority: 25,
|
||||
nzbhydraName: "NZBHydra2"
|
||||
});
|
||||
if (angular.isDefined(preset)) {
|
||||
_.extend(model, preset);
|
||||
}
|
||||
|
||||
$scope.isInitial = true;
|
||||
|
||||
$scope._showBox(model, entriesCollection, true, function (isSubmitted, returnedModel) {
|
||||
if (isSubmitted) {
|
||||
entriesCollection.push(angular.isDefined(returnedModel) ? returnedModel : model);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
function getExternalToolBoxFields(model, parentModel, isInitial) {
|
||||
var fieldset = [];
|
||||
|
||||
fieldset.push({
|
||||
key: 'enabled',
|
||||
type: 'horizontalSwitch',
|
||||
templateOptions: {
|
||||
type: 'switch',
|
||||
label: 'Enabled'
|
||||
}
|
||||
});
|
||||
|
||||
fieldset.push({
|
||||
key: 'name',
|
||||
type: 'horizontalInput',
|
||||
templateOptions: {
|
||||
type: 'text',
|
||||
label: 'Name',
|
||||
required: true,
|
||||
help: 'Unique name for this external tool instance'
|
||||
},
|
||||
validators: {
|
||||
uniqueName: {
|
||||
expression: function (viewValue) {
|
||||
if (isInitial || viewValue !== model.name) {
|
||||
return _.pluck(parentModel, "name").indexOf(viewValue) === -1;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
message: '"External tool \\"" + $viewValue + "\\" already exists"'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
fieldset.push({
|
||||
key: 'type',
|
||||
type: 'horizontalSelect',
|
||||
templateOptions: {
|
||||
type: 'select',
|
||||
label: 'Type',
|
||||
required: true,
|
||||
options: [
|
||||
{name: 'Sonarr', value: 'SONARR'},
|
||||
{name: 'Radarr', value: 'RADARR'},
|
||||
{name: 'Lidarr', value: 'LIDARR'},
|
||||
{name: 'Readarr', value: 'READARR'}
|
||||
]
|
||||
},
|
||||
watcher: {
|
||||
listener: function (field, newValue, oldValue, scope) {
|
||||
if (newValue !== oldValue) {
|
||||
// Update default categories based on type
|
||||
switch (newValue) {
|
||||
case 'SONARR':
|
||||
model.categories = "5030,5040";
|
||||
break;
|
||||
case 'RADARR':
|
||||
model.categories = "2000";
|
||||
break;
|
||||
case 'LIDARR':
|
||||
model.categories = "3000";
|
||||
break;
|
||||
case 'READARR':
|
||||
model.categories = "7020,8010";
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
fieldset.push({
|
||||
key: 'host',
|
||||
type: 'horizontalInput',
|
||||
templateOptions: {
|
||||
type: 'text',
|
||||
label: 'Host URL',
|
||||
help: 'URL with scheme and port (e.g., http://localhost:8989)',
|
||||
required: true
|
||||
},
|
||||
watcher: {
|
||||
listener: function (field, newValue, oldValue, scope) {
|
||||
if (newValue !== oldValue) {
|
||||
scope.$parent.needsConnectionTest = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
fieldset.push({
|
||||
key: 'apiKey',
|
||||
type: 'horizontalInput',
|
||||
templateOptions: {
|
||||
type: 'text',
|
||||
label: 'API Key',
|
||||
help: 'API key for the external tool'
|
||||
},
|
||||
watcher: {
|
||||
listener: function (field, newValue, oldValue, scope) {
|
||||
if (newValue !== oldValue) {
|
||||
scope.$parent.needsConnectionTest = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
fieldset.push({
|
||||
key: 'syncType',
|
||||
type: 'horizontalSelect',
|
||||
templateOptions: {
|
||||
type: 'select',
|
||||
label: 'Sync Type',
|
||||
options: [
|
||||
{name: 'Single entry for all indexers', value: 'SINGLE'},
|
||||
{name: 'Separate entry per indexer', value: 'PER_INDEXER'}
|
||||
],
|
||||
help: 'Whether to create one entry for all indexers or separate entries'
|
||||
}
|
||||
});
|
||||
|
||||
fieldset.push({
|
||||
key: 'nzbhydraName',
|
||||
type: 'horizontalInput',
|
||||
templateOptions: {
|
||||
type: 'text',
|
||||
label: 'NZBHydra Name',
|
||||
help: 'Name prefix used in the external tool',
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
fieldset.push({
|
||||
key: 'configureForUsenet',
|
||||
type: 'horizontalSwitch',
|
||||
templateOptions: {
|
||||
type: 'switch',
|
||||
label: 'Configure for Usenet',
|
||||
help: 'Sync Usenet indexers'
|
||||
}
|
||||
});
|
||||
|
||||
fieldset.push({
|
||||
key: 'configureForTorrents',
|
||||
type: 'horizontalSwitch',
|
||||
templateOptions: {
|
||||
type: 'switch',
|
||||
label: 'Configure for Torrents',
|
||||
help: 'Sync torrent indexers'
|
||||
}
|
||||
});
|
||||
|
||||
fieldset.push({
|
||||
key: 'addDisabledIndexers',
|
||||
type: 'horizontalSwitch',
|
||||
templateOptions: {
|
||||
type: 'switch',
|
||||
label: 'Add disabled indexers',
|
||||
help: 'Also sync indexers that are disabled in NZBHydra',
|
||||
advanced: true
|
||||
}
|
||||
});
|
||||
|
||||
fieldset.push({
|
||||
key: 'useHydraPriorities',
|
||||
type: 'horizontalSwitch',
|
||||
templateOptions: {
|
||||
type: 'switch',
|
||||
label: 'Use Hydra priorities',
|
||||
help: 'Map NZBHydra indexer priorities to the external tool'
|
||||
}
|
||||
});
|
||||
|
||||
fieldset.push({
|
||||
key: 'priority',
|
||||
type: 'horizontalInput',
|
||||
hideExpression: 'model.useHydraPriorities && model.syncType === "PER_INDEXER"',
|
||||
templateOptions: {
|
||||
type: 'number',
|
||||
label: 'Default Priority',
|
||||
help: 'Priority to use when not using Hydra priorities (1-50, lower is better)',
|
||||
placeholder: '25'
|
||||
}
|
||||
});
|
||||
|
||||
fieldset.push({
|
||||
key: 'enableRss',
|
||||
type: 'horizontalSwitch',
|
||||
templateOptions: {
|
||||
type: 'switch',
|
||||
label: 'Enable RSS',
|
||||
help: 'Enable RSS sync in the external tool'
|
||||
}
|
||||
});
|
||||
|
||||
fieldset.push({
|
||||
key: 'enableAutomaticSearch',
|
||||
type: 'horizontalSwitch',
|
||||
templateOptions: {
|
||||
type: 'switch',
|
||||
label: 'Enable automatic search',
|
||||
help: 'Enable automatic search in the external tool'
|
||||
}
|
||||
});
|
||||
|
||||
fieldset.push({
|
||||
key: 'enableInteractiveSearch',
|
||||
type: 'horizontalSwitch',
|
||||
templateOptions: {
|
||||
type: 'switch',
|
||||
label: 'Enable interactive search',
|
||||
help: 'Enable interactive (manual) search in the external tool'
|
||||
}
|
||||
});
|
||||
|
||||
fieldset.push({
|
||||
key: 'categories',
|
||||
type: 'horizontalInput',
|
||||
templateOptions: {
|
||||
type: 'text',
|
||||
label: 'Categories',
|
||||
help: 'Comma-separated newznab category IDs',
|
||||
advanced: true
|
||||
}
|
||||
});
|
||||
|
||||
if (model.type === 'SONARR') {
|
||||
fieldset.push({
|
||||
key: 'animeCategories',
|
||||
type: 'horizontalInput',
|
||||
templateOptions: {
|
||||
type: 'text',
|
||||
label: 'Anime categories',
|
||||
help: 'Comma-separated newznab category IDs for anime',
|
||||
advanced: true
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (model.type === 'RADARR') {
|
||||
fieldset.push({
|
||||
key: 'removeYearFromSearchString',
|
||||
type: 'horizontalSwitch',
|
||||
templateOptions: {
|
||||
type: 'switch',
|
||||
label: 'Remove year from search',
|
||||
help: 'Remove year from movie search queries',
|
||||
advanced: true
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (model.type === 'LIDARR' || model.type === 'READARR') {
|
||||
fieldset.push({
|
||||
key: 'earlyDownloadLimit',
|
||||
type: 'horizontalInput',
|
||||
templateOptions: {
|
||||
type: 'text',
|
||||
label: 'Early download limit',
|
||||
advanced: true
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fieldset.push({
|
||||
key: 'additionalParameters',
|
||||
type: 'horizontalInput',
|
||||
templateOptions: {
|
||||
type: 'text',
|
||||
label: 'Additional parameters',
|
||||
help: 'Additional URL parameters to send to the indexer',
|
||||
advanced: true
|
||||
}
|
||||
});
|
||||
|
||||
// Torrent-specific fields
|
||||
if (model.configureForTorrents) {
|
||||
fieldset.push({
|
||||
key: 'minimumSeeders',
|
||||
type: 'horizontalInput',
|
||||
templateOptions: {
|
||||
type: 'text',
|
||||
label: 'Minimum seeders',
|
||||
help: 'Minimum number of seeders',
|
||||
advanced: true
|
||||
}
|
||||
});
|
||||
|
||||
fieldset.push({
|
||||
key: 'seedRatio',
|
||||
type: 'horizontalInput',
|
||||
templateOptions: {
|
||||
type: 'text',
|
||||
label: 'Seed ratio',
|
||||
advanced: true
|
||||
}
|
||||
});
|
||||
|
||||
fieldset.push({
|
||||
key: 'seedTime',
|
||||
type: 'horizontalInput',
|
||||
templateOptions: {
|
||||
type: 'text',
|
||||
label: 'Seed time',
|
||||
advanced: true
|
||||
}
|
||||
});
|
||||
|
||||
if (model.type === 'SONARR') {
|
||||
fieldset.push({
|
||||
key: 'seasonPackSeedTime',
|
||||
type: 'horizontalInput',
|
||||
templateOptions: {
|
||||
type: 'text',
|
||||
label: 'Season pack seed time',
|
||||
advanced: true
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (model.type === 'LIDARR' || model.type === 'READARR') {
|
||||
fieldset.push({
|
||||
key: 'discographySeedTime',
|
||||
type: 'horizontalInput',
|
||||
templateOptions: {
|
||||
type: 'text',
|
||||
label: 'Discography seed time',
|
||||
advanced: true
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return fieldset;
|
||||
}
|
||||
}
|
||||
});
|
||||
}]);
|
||||
|
||||
|
||||
angular.module('nzbhydraApp').controller('ExternalToolConfigBoxInstanceController', ["$scope", "$q", "$uibModalInstance", "$http", "model", "fields", "isInitial", "parentModel", "data", "growl", "blockUI", function ($scope, $q, $uibModalInstance, $http, model, fields, isInitial, parentModel, data, growl, blockUI) {
|
||||
|
||||
$scope.model = model;
|
||||
$scope.fields = fields;
|
||||
$scope.isInitial = isInitial;
|
||||
$scope.spinnerActive = false;
|
||||
$scope.needsConnectionTest = false;
|
||||
|
||||
$scope.obSubmit = function () {
|
||||
if ($scope.form.$valid) {
|
||||
checkConnection().then(function () {
|
||||
$uibModalInstance.close($scope.model);
|
||||
});
|
||||
} else {
|
||||
growl.error("Config invalid. Please check your settings.");
|
||||
angular.forEach($scope.form.$error, function (error) {
|
||||
angular.forEach(error, function (field) {
|
||||
field.$setTouched();
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
$scope.cancel = function () {
|
||||
$uibModalInstance.dismiss();
|
||||
};
|
||||
|
||||
$scope.deleteEntry = function () {
|
||||
parentModel.splice(parentModel.indexOf(model), 1);
|
||||
$uibModalInstance.close($scope);
|
||||
};
|
||||
|
||||
$scope.reset = function () {
|
||||
if (angular.isDefined(data.resetFunction)) {
|
||||
$scope.options.resetModel();
|
||||
$scope.options.resetModel();
|
||||
}
|
||||
};
|
||||
|
||||
$scope.testConnection = function () {
|
||||
$scope.spinnerActive = true;
|
||||
blockUI.start("Testing connection...");
|
||||
|
||||
var testRequest = {
|
||||
externalTool: model.type === 'SONARR' ? 'Sonarr' :
|
||||
model.type === 'RADARR' ? 'Radarr' :
|
||||
model.type === 'LIDARR' ? 'Lidarr' : 'Readarr',
|
||||
xdarrHost: model.host,
|
||||
xdarrApiKey: model.apiKey,
|
||||
addType: 'DELETE_ONLY' // Just test connection
|
||||
};
|
||||
|
||||
$http.post("internalapi/externalTools/testConnection", testRequest).then(
|
||||
function (response) {
|
||||
blockUI.reset();
|
||||
$scope.spinnerActive = false;
|
||||
if (response.data.successful) {
|
||||
growl.info("Connection test successful");
|
||||
} else {
|
||||
growl.error("Connection test failed: " + response.data.message);
|
||||
}
|
||||
},
|
||||
function (error) {
|
||||
blockUI.reset();
|
||||
$scope.spinnerActive = false;
|
||||
growl.error("Connection test failed: " + (error.data ? error.data.message : "Unknown error"));
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
function checkConnection() {
|
||||
var deferred = $q.defer();
|
||||
|
||||
if (!$scope.isInitial && !$scope.needsConnectionTest) {
|
||||
deferred.resolve();
|
||||
} else {
|
||||
$scope.spinnerActive = true;
|
||||
blockUI.start("Testing connection...");
|
||||
|
||||
var testRequest = {
|
||||
externalTool: model.type === 'SONARR' ? 'Sonarr' :
|
||||
model.type === 'RADARR' ? 'Radarr' :
|
||||
model.type === 'LIDARR' ? 'Lidarr' : 'Readarr',
|
||||
xdarrHost: model.host,
|
||||
xdarrApiKey: model.apiKey,
|
||||
addType: 'DELETE_ONLY'
|
||||
};
|
||||
|
||||
$http.post("internalapi/externalTools/testConnection", testRequest).then(
|
||||
function (response) {
|
||||
blockUI.reset();
|
||||
$scope.spinnerActive = false;
|
||||
if (response.data.successful) {
|
||||
growl.info("Connection test successful");
|
||||
deferred.resolve();
|
||||
} else {
|
||||
growl.error("Connection test failed: " + response.data.message);
|
||||
deferred.reject();
|
||||
}
|
||||
},
|
||||
function (error) {
|
||||
blockUI.reset();
|
||||
$scope.spinnerActive = false;
|
||||
growl.error("Connection test failed: " + (error.data ? error.data.message : "Unknown error"));
|
||||
deferred.reject();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return deferred.promise;
|
||||
}
|
||||
|
||||
$scope.$on("modal.closing", function (targetScope, reason) {
|
||||
if (reason === "backdrop click") {
|
||||
$scope.reset($scope);
|
||||
}
|
||||
});
|
||||
}]);
|
||||
/*
|
||||
* (C) Copyright 2017 TheOtherP (theotherp@posteo.net)
|
||||
*
|
||||
@ -8224,6 +8826,28 @@ function ConfigFields($injector) {
|
||||
}
|
||||
],
|
||||
|
||||
externalTools: [
|
||||
{
|
||||
wrapper: 'fieldset',
|
||||
templateOptions: {label: 'External Tool Sync Settings'},
|
||||
fieldGroup: [
|
||||
{
|
||||
key: 'syncOnConfigChange',
|
||||
type: 'horizontalSwitch',
|
||||
templateOptions: {
|
||||
type: 'switch',
|
||||
label: 'Sync on config change',
|
||||
help: 'Automatically sync indexers to external tools when configuration is saved'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
type: 'externalToolConfig',
|
||||
data: {}
|
||||
}
|
||||
],
|
||||
|
||||
indexers: [
|
||||
{
|
||||
type: "indexers",
|
||||
|
||||
File diff suppressed because one or more lines are too long
@ -1931,6 +1931,28 @@ function ConfigFields($injector) {
|
||||
}
|
||||
],
|
||||
|
||||
externalTools: [
|
||||
{
|
||||
wrapper: 'fieldset',
|
||||
templateOptions: {label: 'External Tool Sync Settings'},
|
||||
fieldGroup: [
|
||||
{
|
||||
key: 'syncOnConfigChange',
|
||||
type: 'horizontalSwitch',
|
||||
templateOptions: {
|
||||
type: 'switch',
|
||||
label: 'Sync on config change',
|
||||
help: 'Automatically sync indexers to external tools when configuration is saved'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
type: 'externalToolConfig',
|
||||
data: {}
|
||||
}
|
||||
],
|
||||
|
||||
indexers: [
|
||||
{
|
||||
type: "indexers",
|
||||
|
||||
602
core/ui-src/js/config/formly-external-tools.js
Normal file
602
core/ui-src/js/config/formly-external-tools.js
Normal file
@ -0,0 +1,602 @@
|
||||
/*
|
||||
* (C) Copyright 2023 TheOtherP (theotherp@posteo.net)
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
angular
|
||||
.module('nzbhydraApp')
|
||||
.config(function config(formlyConfigProvider) {
|
||||
|
||||
formlyConfigProvider.setType({
|
||||
name: 'externalToolConfig',
|
||||
templateUrl: 'static/html/config/external-tool-config.html',
|
||||
controller: function ($scope, $uibModal, growl, localStorageService, $http) {
|
||||
$scope.formOptions = {formState: $scope.formState};
|
||||
$scope._showBox = _showBox;
|
||||
$scope.showBox = showBox;
|
||||
$scope.isInitial = false;
|
||||
$scope.presets = [
|
||||
{
|
||||
name: "Sonarr",
|
||||
type: "SONARR",
|
||||
host: "http://localhost:8989",
|
||||
categories: "5030,5040",
|
||||
syncType: "PER_INDEXER"
|
||||
},
|
||||
{
|
||||
name: "Radarr",
|
||||
type: "RADARR",
|
||||
host: "http://localhost:7878",
|
||||
categories: "2000",
|
||||
syncType: "PER_INDEXER"
|
||||
},
|
||||
{
|
||||
name: "Lidarr",
|
||||
type: "LIDARR",
|
||||
host: "http://localhost:8686",
|
||||
categories: "3000",
|
||||
syncType: "PER_INDEXER"
|
||||
},
|
||||
{
|
||||
name: "Readarr",
|
||||
type: "READARR",
|
||||
host: "http://localhost:8787",
|
||||
categories: "7020,8010",
|
||||
syncType: "PER_INDEXER"
|
||||
}
|
||||
];
|
||||
|
||||
function _showBox(model, parentModel, isInitial, callback) {
|
||||
var modalInstance = $uibModal.open({
|
||||
templateUrl: 'static/html/config/external-tool-config-box.html',
|
||||
controller: 'ExternalToolConfigBoxInstanceController',
|
||||
size: 'lg',
|
||||
resolve: {
|
||||
model: function () {
|
||||
model.showAdvanced = localStorageService.get("showAdvanced");
|
||||
return model;
|
||||
},
|
||||
fields: function () {
|
||||
return getExternalToolBoxFields(model, parentModel, isInitial);
|
||||
},
|
||||
isInitial: function () {
|
||||
return isInitial
|
||||
},
|
||||
parentModel: function () {
|
||||
return parentModel;
|
||||
},
|
||||
data: function () {
|
||||
return $scope.options.data;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
modalInstance.result.then(function (returnedModel) {
|
||||
$scope.form.$setDirty(true);
|
||||
if (angular.isDefined(callback)) {
|
||||
callback(true, returnedModel);
|
||||
}
|
||||
}, function () {
|
||||
if (angular.isDefined(callback)) {
|
||||
callback(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function showBox(model, parentModel) {
|
||||
$scope._showBox(model, parentModel, false)
|
||||
}
|
||||
|
||||
$scope.syncAll = function () {
|
||||
growl.info("Starting sync to all external tools...");
|
||||
$http.post('internalapi/externalTools/syncAll').then(
|
||||
function (response) {
|
||||
var result = response.data;
|
||||
if (result.failureCount === 0) {
|
||||
growl.success("Successfully synced to " + result.successCount + " external tool(s)");
|
||||
} else if (result.successCount === 0) {
|
||||
growl.error("Failed to sync to all " + result.failureCount + " external tool(s)");
|
||||
} else {
|
||||
growl.warning("Synced to " + result.successCount + " tool(s), " + result.failureCount + " failed");
|
||||
}
|
||||
},
|
||||
function (error) {
|
||||
growl.error("Error syncing to external tools: " + error.data);
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
$scope.addEntry = function (entriesCollection, preset) {
|
||||
var model = angular.copy({
|
||||
enabled: true,
|
||||
syncType: "PER_INDEXER",
|
||||
configureForUsenet: true,
|
||||
configureForTorrents: false,
|
||||
enableRss: true,
|
||||
enableAutomaticSearch: true,
|
||||
enableInteractiveSearch: true,
|
||||
useHydraPriorities: true,
|
||||
priority: 25,
|
||||
nzbhydraName: "NZBHydra2"
|
||||
});
|
||||
if (angular.isDefined(preset)) {
|
||||
_.extend(model, preset);
|
||||
}
|
||||
|
||||
$scope.isInitial = true;
|
||||
|
||||
$scope._showBox(model, entriesCollection, true, function (isSubmitted, returnedModel) {
|
||||
if (isSubmitted) {
|
||||
entriesCollection.push(angular.isDefined(returnedModel) ? returnedModel : model);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
function getExternalToolBoxFields(model, parentModel, isInitial) {
|
||||
var fieldset = [];
|
||||
|
||||
fieldset.push({
|
||||
key: 'enabled',
|
||||
type: 'horizontalSwitch',
|
||||
templateOptions: {
|
||||
type: 'switch',
|
||||
label: 'Enabled'
|
||||
}
|
||||
});
|
||||
|
||||
fieldset.push({
|
||||
key: 'name',
|
||||
type: 'horizontalInput',
|
||||
templateOptions: {
|
||||
type: 'text',
|
||||
label: 'Name',
|
||||
required: true,
|
||||
help: 'Unique name for this external tool instance'
|
||||
},
|
||||
validators: {
|
||||
uniqueName: {
|
||||
expression: function (viewValue) {
|
||||
if (isInitial || viewValue !== model.name) {
|
||||
return _.pluck(parentModel, "name").indexOf(viewValue) === -1;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
message: '"External tool \\"" + $viewValue + "\\" already exists"'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
fieldset.push({
|
||||
key: 'type',
|
||||
type: 'horizontalSelect',
|
||||
templateOptions: {
|
||||
type: 'select',
|
||||
label: 'Type',
|
||||
required: true,
|
||||
options: [
|
||||
{name: 'Sonarr', value: 'SONARR'},
|
||||
{name: 'Radarr', value: 'RADARR'},
|
||||
{name: 'Lidarr', value: 'LIDARR'},
|
||||
{name: 'Readarr', value: 'READARR'}
|
||||
]
|
||||
},
|
||||
watcher: {
|
||||
listener: function (field, newValue, oldValue, scope) {
|
||||
if (newValue !== oldValue) {
|
||||
// Update default categories based on type
|
||||
switch (newValue) {
|
||||
case 'SONARR':
|
||||
model.categories = "5030,5040";
|
||||
break;
|
||||
case 'RADARR':
|
||||
model.categories = "2000";
|
||||
break;
|
||||
case 'LIDARR':
|
||||
model.categories = "3000";
|
||||
break;
|
||||
case 'READARR':
|
||||
model.categories = "7020,8010";
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
fieldset.push({
|
||||
key: 'host',
|
||||
type: 'horizontalInput',
|
||||
templateOptions: {
|
||||
type: 'text',
|
||||
label: 'Host URL',
|
||||
help: 'URL with scheme and port (e.g., http://localhost:8989)',
|
||||
required: true
|
||||
},
|
||||
watcher: {
|
||||
listener: function (field, newValue, oldValue, scope) {
|
||||
if (newValue !== oldValue) {
|
||||
scope.$parent.needsConnectionTest = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
fieldset.push({
|
||||
key: 'apiKey',
|
||||
type: 'horizontalInput',
|
||||
templateOptions: {
|
||||
type: 'text',
|
||||
label: 'API Key',
|
||||
help: 'API key for the external tool'
|
||||
},
|
||||
watcher: {
|
||||
listener: function (field, newValue, oldValue, scope) {
|
||||
if (newValue !== oldValue) {
|
||||
scope.$parent.needsConnectionTest = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
fieldset.push({
|
||||
key: 'syncType',
|
||||
type: 'horizontalSelect',
|
||||
templateOptions: {
|
||||
type: 'select',
|
||||
label: 'Sync Type',
|
||||
options: [
|
||||
{name: 'Single entry for all indexers', value: 'SINGLE'},
|
||||
{name: 'Separate entry per indexer', value: 'PER_INDEXER'}
|
||||
],
|
||||
help: 'Whether to create one entry for all indexers or separate entries'
|
||||
}
|
||||
});
|
||||
|
||||
fieldset.push({
|
||||
key: 'nzbhydraName',
|
||||
type: 'horizontalInput',
|
||||
templateOptions: {
|
||||
type: 'text',
|
||||
label: 'NZBHydra Name',
|
||||
help: 'Name prefix used in the external tool',
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
fieldset.push({
|
||||
key: 'configureForUsenet',
|
||||
type: 'horizontalSwitch',
|
||||
templateOptions: {
|
||||
type: 'switch',
|
||||
label: 'Configure for Usenet',
|
||||
help: 'Sync Usenet indexers'
|
||||
}
|
||||
});
|
||||
|
||||
fieldset.push({
|
||||
key: 'configureForTorrents',
|
||||
type: 'horizontalSwitch',
|
||||
templateOptions: {
|
||||
type: 'switch',
|
||||
label: 'Configure for Torrents',
|
||||
help: 'Sync torrent indexers'
|
||||
}
|
||||
});
|
||||
|
||||
fieldset.push({
|
||||
key: 'addDisabledIndexers',
|
||||
type: 'horizontalSwitch',
|
||||
templateOptions: {
|
||||
type: 'switch',
|
||||
label: 'Add disabled indexers',
|
||||
help: 'Also sync indexers that are disabled in NZBHydra',
|
||||
advanced: true
|
||||
}
|
||||
});
|
||||
|
||||
fieldset.push({
|
||||
key: 'useHydraPriorities',
|
||||
type: 'horizontalSwitch',
|
||||
templateOptions: {
|
||||
type: 'switch',
|
||||
label: 'Use Hydra priorities',
|
||||
help: 'Map NZBHydra indexer priorities to the external tool'
|
||||
}
|
||||
});
|
||||
|
||||
fieldset.push({
|
||||
key: 'priority',
|
||||
type: 'horizontalInput',
|
||||
hideExpression: 'model.useHydraPriorities && model.syncType === "PER_INDEXER"',
|
||||
templateOptions: {
|
||||
type: 'number',
|
||||
label: 'Default Priority',
|
||||
help: 'Priority to use when not using Hydra priorities (1-50, lower is better)',
|
||||
placeholder: '25'
|
||||
}
|
||||
});
|
||||
|
||||
fieldset.push({
|
||||
key: 'enableRss',
|
||||
type: 'horizontalSwitch',
|
||||
templateOptions: {
|
||||
type: 'switch',
|
||||
label: 'Enable RSS',
|
||||
help: 'Enable RSS sync in the external tool'
|
||||
}
|
||||
});
|
||||
|
||||
fieldset.push({
|
||||
key: 'enableAutomaticSearch',
|
||||
type: 'horizontalSwitch',
|
||||
templateOptions: {
|
||||
type: 'switch',
|
||||
label: 'Enable automatic search',
|
||||
help: 'Enable automatic search in the external tool'
|
||||
}
|
||||
});
|
||||
|
||||
fieldset.push({
|
||||
key: 'enableInteractiveSearch',
|
||||
type: 'horizontalSwitch',
|
||||
templateOptions: {
|
||||
type: 'switch',
|
||||
label: 'Enable interactive search',
|
||||
help: 'Enable interactive (manual) search in the external tool'
|
||||
}
|
||||
});
|
||||
|
||||
fieldset.push({
|
||||
key: 'categories',
|
||||
type: 'horizontalInput',
|
||||
templateOptions: {
|
||||
type: 'text',
|
||||
label: 'Categories',
|
||||
help: 'Comma-separated newznab category IDs',
|
||||
advanced: true
|
||||
}
|
||||
});
|
||||
|
||||
if (model.type === 'SONARR') {
|
||||
fieldset.push({
|
||||
key: 'animeCategories',
|
||||
type: 'horizontalInput',
|
||||
templateOptions: {
|
||||
type: 'text',
|
||||
label: 'Anime categories',
|
||||
help: 'Comma-separated newznab category IDs for anime',
|
||||
advanced: true
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (model.type === 'RADARR') {
|
||||
fieldset.push({
|
||||
key: 'removeYearFromSearchString',
|
||||
type: 'horizontalSwitch',
|
||||
templateOptions: {
|
||||
type: 'switch',
|
||||
label: 'Remove year from search',
|
||||
help: 'Remove year from movie search queries',
|
||||
advanced: true
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (model.type === 'LIDARR' || model.type === 'READARR') {
|
||||
fieldset.push({
|
||||
key: 'earlyDownloadLimit',
|
||||
type: 'horizontalInput',
|
||||
templateOptions: {
|
||||
type: 'text',
|
||||
label: 'Early download limit',
|
||||
advanced: true
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fieldset.push({
|
||||
key: 'additionalParameters',
|
||||
type: 'horizontalInput',
|
||||
templateOptions: {
|
||||
type: 'text',
|
||||
label: 'Additional parameters',
|
||||
help: 'Additional URL parameters to send to the indexer',
|
||||
advanced: true
|
||||
}
|
||||
});
|
||||
|
||||
// Torrent-specific fields
|
||||
if (model.configureForTorrents) {
|
||||
fieldset.push({
|
||||
key: 'minimumSeeders',
|
||||
type: 'horizontalInput',
|
||||
templateOptions: {
|
||||
type: 'text',
|
||||
label: 'Minimum seeders',
|
||||
help: 'Minimum number of seeders',
|
||||
advanced: true
|
||||
}
|
||||
});
|
||||
|
||||
fieldset.push({
|
||||
key: 'seedRatio',
|
||||
type: 'horizontalInput',
|
||||
templateOptions: {
|
||||
type: 'text',
|
||||
label: 'Seed ratio',
|
||||
advanced: true
|
||||
}
|
||||
});
|
||||
|
||||
fieldset.push({
|
||||
key: 'seedTime',
|
||||
type: 'horizontalInput',
|
||||
templateOptions: {
|
||||
type: 'text',
|
||||
label: 'Seed time',
|
||||
advanced: true
|
||||
}
|
||||
});
|
||||
|
||||
if (model.type === 'SONARR') {
|
||||
fieldset.push({
|
||||
key: 'seasonPackSeedTime',
|
||||
type: 'horizontalInput',
|
||||
templateOptions: {
|
||||
type: 'text',
|
||||
label: 'Season pack seed time',
|
||||
advanced: true
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (model.type === 'LIDARR' || model.type === 'READARR') {
|
||||
fieldset.push({
|
||||
key: 'discographySeedTime',
|
||||
type: 'horizontalInput',
|
||||
templateOptions: {
|
||||
type: 'text',
|
||||
label: 'Discography seed time',
|
||||
advanced: true
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return fieldset;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
angular.module('nzbhydraApp').controller('ExternalToolConfigBoxInstanceController', function ($scope, $q, $uibModalInstance, $http, model, fields, isInitial, parentModel, data, growl, blockUI) {
|
||||
|
||||
$scope.model = model;
|
||||
$scope.fields = fields;
|
||||
$scope.isInitial = isInitial;
|
||||
$scope.spinnerActive = false;
|
||||
$scope.needsConnectionTest = false;
|
||||
|
||||
$scope.obSubmit = function () {
|
||||
if ($scope.form.$valid) {
|
||||
checkConnection().then(function () {
|
||||
$uibModalInstance.close($scope.model);
|
||||
});
|
||||
} else {
|
||||
growl.error("Config invalid. Please check your settings.");
|
||||
angular.forEach($scope.form.$error, function (error) {
|
||||
angular.forEach(error, function (field) {
|
||||
field.$setTouched();
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
$scope.cancel = function () {
|
||||
$uibModalInstance.dismiss();
|
||||
};
|
||||
|
||||
$scope.deleteEntry = function () {
|
||||
parentModel.splice(parentModel.indexOf(model), 1);
|
||||
$uibModalInstance.close($scope);
|
||||
};
|
||||
|
||||
$scope.reset = function () {
|
||||
if (angular.isDefined(data.resetFunction)) {
|
||||
$scope.options.resetModel();
|
||||
$scope.options.resetModel();
|
||||
}
|
||||
};
|
||||
|
||||
$scope.testConnection = function () {
|
||||
$scope.spinnerActive = true;
|
||||
blockUI.start("Testing connection...");
|
||||
|
||||
var testRequest = {
|
||||
externalTool: model.type === 'SONARR' ? 'Sonarr' :
|
||||
model.type === 'RADARR' ? 'Radarr' :
|
||||
model.type === 'LIDARR' ? 'Lidarr' : 'Readarr',
|
||||
xdarrHost: model.host,
|
||||
xdarrApiKey: model.apiKey,
|
||||
addType: 'DELETE_ONLY' // Just test connection
|
||||
};
|
||||
|
||||
$http.post("internalapi/externalTools/testConnection", testRequest).then(
|
||||
function (response) {
|
||||
blockUI.reset();
|
||||
$scope.spinnerActive = false;
|
||||
if (response.data.successful) {
|
||||
growl.info("Connection test successful");
|
||||
} else {
|
||||
growl.error("Connection test failed: " + response.data.message);
|
||||
}
|
||||
},
|
||||
function (error) {
|
||||
blockUI.reset();
|
||||
$scope.spinnerActive = false;
|
||||
growl.error("Connection test failed: " + (error.data ? error.data.message : "Unknown error"));
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
function checkConnection() {
|
||||
var deferred = $q.defer();
|
||||
|
||||
if (!$scope.isInitial && !$scope.needsConnectionTest) {
|
||||
deferred.resolve();
|
||||
} else {
|
||||
$scope.spinnerActive = true;
|
||||
blockUI.start("Testing connection...");
|
||||
|
||||
var testRequest = {
|
||||
externalTool: model.type === 'SONARR' ? 'Sonarr' :
|
||||
model.type === 'RADARR' ? 'Radarr' :
|
||||
model.type === 'LIDARR' ? 'Lidarr' : 'Readarr',
|
||||
xdarrHost: model.host,
|
||||
xdarrApiKey: model.apiKey,
|
||||
addType: 'DELETE_ONLY'
|
||||
};
|
||||
|
||||
$http.post("internalapi/externalTools/testConnection", testRequest).then(
|
||||
function (response) {
|
||||
blockUI.reset();
|
||||
$scope.spinnerActive = false;
|
||||
if (response.data.successful) {
|
||||
growl.info("Connection test successful");
|
||||
deferred.resolve();
|
||||
} else {
|
||||
growl.error("Connection test failed: " + response.data.message);
|
||||
deferred.reject();
|
||||
}
|
||||
},
|
||||
function (error) {
|
||||
blockUI.reset();
|
||||
$scope.spinnerActive = false;
|
||||
growl.error("Connection test failed: " + (error.data ? error.data.message : "Unknown error"));
|
||||
deferred.reject();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return deferred.promise;
|
||||
}
|
||||
|
||||
$scope.$on("modal.closing", function (targetScope, reason) {
|
||||
if (reason === "backdrop click") {
|
||||
$scope.reset($scope);
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -59,6 +59,8 @@ public class BaseConfig {
|
||||
private NotificationConfig notificationConfig = new NotificationConfig();
|
||||
@NestedConfigurationProperty
|
||||
private EmbyConfig emby = new EmbyConfig();
|
||||
@NestedConfigurationProperty
|
||||
private ExternalToolsConfig externalTools = new ExternalToolsConfig();
|
||||
|
||||
|
||||
@DiffIgnore
|
||||
|
||||
@ -0,0 +1,103 @@
|
||||
/*
|
||||
* (C) Copyright 2023 TheOtherP (theotherp@posteo.net)
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.nzbhydra.config;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import lombok.Data;
|
||||
import org.nzbhydra.config.sensitive.SensitiveData;
|
||||
import org.nzbhydra.externaltools.AddRequest;
|
||||
import org.nzbhydra.springnative.ReflectionMarker;
|
||||
|
||||
@Data
|
||||
@ReflectionMarker
|
||||
public class ExternalToolConfig {
|
||||
|
||||
@JsonFormat(shape = JsonFormat.Shape.STRING)
|
||||
public enum ExternalToolType {
|
||||
SONARR,
|
||||
RADARR,
|
||||
LIDARR,
|
||||
READARR
|
||||
}
|
||||
|
||||
@JsonFormat(shape = JsonFormat.Shape.STRING)
|
||||
public enum SyncType {
|
||||
SINGLE,
|
||||
PER_INDEXER
|
||||
}
|
||||
|
||||
private String name;
|
||||
private ExternalToolType type = ExternalToolType.SONARR;
|
||||
private String host;
|
||||
@SensitiveData
|
||||
private String apiKey;
|
||||
private boolean enabled = true;
|
||||
private SyncType syncType = AddRequest.AddType.SINGLE.name().equals("SINGLE") ? SyncType.SINGLE : SyncType.PER_INDEXER;
|
||||
private String nzbhydraName = "NZBHydra2";
|
||||
|
||||
// Sync settings
|
||||
private boolean configureForUsenet = true;
|
||||
private boolean configureForTorrents = false;
|
||||
private boolean addDisabledIndexers = false;
|
||||
private boolean useHydraPriorities = true;
|
||||
private Integer priority = 25;
|
||||
|
||||
// RSS and search settings
|
||||
private boolean enableRss = true;
|
||||
private boolean enableAutomaticSearch = true;
|
||||
private boolean enableInteractiveSearch = true;
|
||||
|
||||
// Category settings
|
||||
private String categories = "";
|
||||
private String animeCategories = "";
|
||||
|
||||
// Additional settings
|
||||
private String additionalParameters = "";
|
||||
private String minimumSeeders = "1";
|
||||
private String seedRatio = "";
|
||||
private String seedTime = "";
|
||||
private String seasonPackSeedTime = "";
|
||||
private String discographySeedTime = "";
|
||||
private String earlyDownloadLimit = "";
|
||||
private boolean removeYearFromSearchString = false;
|
||||
|
||||
// Validation and lifecycle methods can be implemented if needed
|
||||
public void prepareForSaving() {
|
||||
// Ensure host doesn't end with slash
|
||||
if (host != null && host.endsWith("/")) {
|
||||
host = host.substring(0, host.length() - 1);
|
||||
}
|
||||
|
||||
// Set default categories based on type if not set
|
||||
if (categories == null || categories.isEmpty()) {
|
||||
switch (type) {
|
||||
case SONARR:
|
||||
categories = "5030,5040";
|
||||
break;
|
||||
case RADARR:
|
||||
categories = "2000";
|
||||
break;
|
||||
case LIDARR:
|
||||
categories = "3000";
|
||||
break;
|
||||
case READARR:
|
||||
categories = "7020,8010";
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,35 @@
|
||||
/*
|
||||
* (C) Copyright 2023 TheOtherP (theotherp@posteo.net)
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.nzbhydra.config;
|
||||
|
||||
import lombok.Data;
|
||||
import org.nzbhydra.springnative.ReflectionMarker;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@ReflectionMarker
|
||||
public class ExternalToolsConfig {
|
||||
|
||||
private List<ExternalToolConfig> externalTools = new ArrayList<>();
|
||||
private boolean syncOnConfigChange = true;
|
||||
|
||||
public void prepareForSaving() {
|
||||
externalTools.forEach(ExternalToolConfig::prepareForSaving);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user