mirror of
https://github.com/theotherp/nzbhydra2.git
synced 2026-02-06 11:17:18 +00:00
Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3490a4cc8 | ||
|
|
01b0d0e451 | ||
|
|
de2ad9da1b | ||
|
|
3f6e72e056 | ||
|
|
a141b2d111 | ||
|
|
8908e1c069 | ||
|
|
d1e065ccee | ||
|
|
384a9a4091 | ||
|
|
dfa8b1ecb6 | ||
|
|
9a96e68b48 | ||
|
|
68d435c4e4 | ||
|
|
b08287572e | ||
|
|
47ac446d47 | ||
|
|
97854cc48f | ||
|
|
03185b0d6b | ||
|
|
7828fbdfcc | ||
|
|
67a3dea758 | ||
|
|
4f5b198e6a | ||
|
|
a9f4653c4a | ||
|
|
7857268823 | ||
|
|
ab34100a24 | ||
|
|
f3e961197d | ||
|
|
8b1cecda44 | ||
|
|
9a58e4d558 | ||
|
|
1673d4d1c2 | ||
|
|
dec8a3d5be | ||
|
|
e6ec142c09 | ||
|
|
8dbfec3309 | ||
|
|
5a13abfde0 | ||
|
|
e70f1edc4b | ||
|
|
d913548b74 | ||
|
|
d72623435a | ||
|
|
440d337415 | ||
|
|
285adaaadf | ||
|
|
dad346379c |
@ -13,7 +13,10 @@
|
||||
"Bash(mvn test:*)",
|
||||
"Bash(mvn help:describe:*)",
|
||||
"mcp__github__get_commit",
|
||||
"Bash(findstr:*)"
|
||||
"Bash(findstr:*)",
|
||||
"Bash(dir \"c:\\\\temp\\\\hydrastats\")",
|
||||
"Bash(gh issue view:*)",
|
||||
"Bash(gh api:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@ -26,6 +26,8 @@ heapdump*
|
||||
!/.idea/vcs.xml
|
||||
!/.idea/misc.xml
|
||||
misc/rsyncToServers.sh
|
||||
misc/build-logs
|
||||
misc/.build-release-state.json
|
||||
/nzbhydra.yml
|
||||
/results
|
||||
other/wrapper/pyInstaller/windows/build
|
||||
|
||||
@ -23,6 +23,7 @@
|
||||
<outputRelativeToContentRoot value="true" />
|
||||
<processorPath useClasspath="false">
|
||||
<entry name="$MAVEN_REPOSITORY$/org/projectlombok/lombok/1.18.42/lombok-1.18.42.jar" />
|
||||
<entry name="$MAVEN_REPOSITORY$/org/projectlombok/lombok/1.18.42/lombok-1.18.42.jar" />
|
||||
</processorPath>
|
||||
<module name="mapping" />
|
||||
</profile>
|
||||
@ -50,6 +51,7 @@
|
||||
<outputRelativeToContentRoot value="true" />
|
||||
<processorPath useClasspath="false">
|
||||
<entry name="$MAVEN_REPOSITORY$/org/projectlombok/lombok/1.18.42/lombok-1.18.42.jar" />
|
||||
<entry name="$MAVEN_REPOSITORY$/org/projectlombok/lombok/1.18.42/lombok-1.18.42.jar" />
|
||||
</processorPath>
|
||||
<module name="release-parser" />
|
||||
</profile>
|
||||
|
||||
264
WINDOWS-VM-BUILD-SETUP.md
Normal file
264
WINDOWS-VM-BUILD-SETUP.md
Normal file
@ -0,0 +1,264 @@
|
||||
# Windows VM Setup for GraalVM Native Builds
|
||||
|
||||
This document describes how to set up a headless Windows VM on Linux for building Windows native executables using GraalVM.
|
||||
|
||||
## Why This Is Needed
|
||||
|
||||
GraalVM native-image doesn't support cross-compilation. To build Windows executables from Linux, you need a Windows environment. A headless VM with SSH access provides CLI-controllable builds.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Linux host with KVM support (`grep -E 'vmx|svm' /proc/cpuinfo`)
|
||||
- Windows ISO (Windows 10/11)
|
||||
- VirtIO drivers ISO: https://fedorapeople.org/groups/virt/virtio-win/direct-downloads/stable-virtio/
|
||||
|
||||
## Step 1: Install QEMU/KVM
|
||||
|
||||
### Ubuntu/Debian
|
||||
|
||||
```bash
|
||||
sudo apt install qemu-kvm libvirt-daemon-system virtinst virt-manager
|
||||
sudo usermod -aG libvirt,kvm $USER
|
||||
# Log out and back in for group changes
|
||||
```
|
||||
|
||||
### Fedora
|
||||
|
||||
```bash
|
||||
sudo dnf install @virtualization
|
||||
sudo systemctl enable --now libvirtd
|
||||
sudo usermod -aG libvirt $USER
|
||||
```
|
||||
|
||||
### Arch
|
||||
|
||||
```bash
|
||||
sudo pacman -S qemu-full libvirt virt-manager dnsmasq
|
||||
sudo systemctl enable --now libvirtd
|
||||
sudo usermod -aG libvirt $USER
|
||||
```
|
||||
|
||||
## Step 2: Create Windows VM
|
||||
|
||||
```bash
|
||||
# Create directory for VMs
|
||||
mkdir -p ~/vms
|
||||
|
||||
# Create disk image (60GB, grows dynamically)
|
||||
qemu-img create -f qcow2 ~/vms/windows-build.qcow2 60G
|
||||
|
||||
# Start installation
|
||||
virt-install \
|
||||
--name windows-build \
|
||||
--ram 8192 \
|
||||
--vcpus 4 \
|
||||
--disk path=~/vms/windows-build.qcow2,format=qcow2,bus=virtio \
|
||||
--cdrom /path/to/windows.iso \
|
||||
--disk path=/path/to/virtio-win.iso,device=cdrom \
|
||||
--os-variant win11 \
|
||||
--network network=default,model=virtio \
|
||||
--graphics spice
|
||||
```
|
||||
|
||||
This opens a graphical installer. Complete the Windows installation.
|
||||
|
||||
**During installation**: Load VirtIO drivers from the second CD-ROM when Windows can't find the disk.
|
||||
|
||||
## Step 3: Windows VM Configuration
|
||||
|
||||
After Windows is installed, perform these steps inside the VM:
|
||||
|
||||
### Install VirtIO Drivers
|
||||
|
||||
- Open Device Manager
|
||||
- Update any devices with missing drivers using the VirtIO CD-ROM
|
||||
|
||||
### Install OpenSSH Server
|
||||
|
||||
Open PowerShell as Administrator:
|
||||
|
||||
```powershell
|
||||
# Install OpenSSH Server
|
||||
Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0
|
||||
|
||||
# Start and enable the service
|
||||
Start-Service sshd
|
||||
Set-Service -Name sshd -StartupType Automatic
|
||||
|
||||
# Configure firewall (usually automatic)
|
||||
New-NetFirewallRule -Name sshd -DisplayName 'OpenSSH Server' -Enabled True -Direction Inbound -Protocol TCP -Action Allow -LocalPort 22
|
||||
```
|
||||
|
||||
### Install Build Tools
|
||||
|
||||
1. **GraalVM Community Edition**
|
||||
- Download from https://github.com/graalvm/graalvm-ce-builds/releases
|
||||
- Extract to `C:\Program Files\graalvm`
|
||||
- Set `JAVA_HOME` and add to `PATH`
|
||||
|
||||
2. **Visual Studio Build Tools**
|
||||
- Download from https://visualstudio.microsoft.com/visual-cpp-build-tools/
|
||||
- Install "Desktop development with C++" workload
|
||||
- Required for native-image on Windows
|
||||
|
||||
3. **Maven**
|
||||
- Download from https://maven.apache.org/download.cgi
|
||||
- Extract and add `bin` to `PATH`
|
||||
|
||||
4. **Git** (optional, if syncing via git instead of rsync)
|
||||
- Download from https://git-scm.com/download/win
|
||||
|
||||
### Configure Static IP (Recommended)
|
||||
|
||||
In Windows Network Settings, set a static IP like `192.168.122.100` to make SSH scripting easier.
|
||||
|
||||
### Set Up SSH Key Authentication
|
||||
|
||||
From your Linux host:
|
||||
|
||||
```bash
|
||||
ssh-copy-id builder@192.168.122.100
|
||||
```
|
||||
|
||||
## Step 4: VM Management Commands
|
||||
|
||||
```bash
|
||||
# Start VM (headless)
|
||||
virsh start windows-build
|
||||
|
||||
# Check running VMs
|
||||
virsh list
|
||||
|
||||
# Get VM IP address
|
||||
virsh domifaddr windows-build
|
||||
|
||||
# Shutdown gracefully
|
||||
virsh shutdown windows-build
|
||||
|
||||
# Force stop
|
||||
virsh destroy windows-build
|
||||
|
||||
# Create snapshot (recommended after setup)
|
||||
virsh snapshot-create-as windows-build clean-setup "Fresh install with build tools"
|
||||
|
||||
# Restore snapshot
|
||||
virsh snapshot-revert windows-build clean-setup
|
||||
```
|
||||
|
||||
## Step 5: Build Script
|
||||
|
||||
Create `build-windows.sh` in the project root:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
VM_NAME="windows-build"
|
||||
WIN_USER="builder"
|
||||
WIN_HOST="192.168.122.100"
|
||||
PROJECT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
BUILD_DIR="C:\\Users\\$WIN_USER\\nzbhydra2"
|
||||
|
||||
echo "=== Windows Native Build ==="
|
||||
|
||||
# Start VM if not running
|
||||
if ! virsh list --name | grep -q "^${VM_NAME}$"; then
|
||||
echo "Starting Windows VM..."
|
||||
virsh start "$VM_NAME"
|
||||
echo "Waiting for VM to boot..."
|
||||
sleep 60
|
||||
fi
|
||||
|
||||
# Wait for SSH to be available
|
||||
echo "Waiting for SSH..."
|
||||
until ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=no "$WIN_USER@$WIN_HOST" "echo ready" 2>/dev/null; do
|
||||
sleep 5
|
||||
done
|
||||
echo "SSH is ready"
|
||||
|
||||
# Sync source code
|
||||
echo "Syncing source code..."
|
||||
rsync -avz --delete \
|
||||
--exclude 'target/' \
|
||||
--exclude '.git/' \
|
||||
--exclude '.idea/' \
|
||||
--exclude '*.iml' \
|
||||
"$PROJECT_DIR/" \
|
||||
"$WIN_USER@$WIN_HOST:nzbhydra2/"
|
||||
|
||||
# Run build
|
||||
echo "Running build..."
|
||||
ssh "$WIN_USER@$WIN_HOST" "cd nzbhydra2 && cmd /c buildCore.cmd"
|
||||
|
||||
# Copy artifact back
|
||||
echo "Copying artifact..."
|
||||
mkdir -p "$PROJECT_DIR/core/target"
|
||||
scp "$WIN_USER@$WIN_HOST:nzbhydra2/core/target/core.exe" \
|
||||
"$PROJECT_DIR/core/target/core-windows.exe"
|
||||
|
||||
echo "=== Build complete: core/target/core-windows.exe ==="
|
||||
|
||||
# Uncomment to shutdown VM after build:
|
||||
# virsh shutdown "$VM_NAME"
|
||||
```
|
||||
|
||||
Make it executable:
|
||||
|
||||
```bash
|
||||
chmod +x build-windows.sh
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# One command to build Windows executable
|
||||
./build-windows.sh
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### VM won't start
|
||||
|
||||
```bash
|
||||
# Check for errors
|
||||
virsh start windows-build 2>&1
|
||||
|
||||
# Check libvirt logs
|
||||
sudo journalctl -u libvirtd
|
||||
```
|
||||
|
||||
### Can't connect via SSH
|
||||
|
||||
```bash
|
||||
# Check VM is running
|
||||
virsh list
|
||||
|
||||
# Get IP address
|
||||
virsh domifaddr windows-build
|
||||
|
||||
# Check if SSH port is open
|
||||
nc -zv 192.168.122.100 22
|
||||
```
|
||||
|
||||
### Build fails
|
||||
|
||||
SSH into the VM and run the build manually to see full output:
|
||||
|
||||
```bash
|
||||
ssh builder@192.168.122.100
|
||||
cd nzbhydra2
|
||||
cmd /c buildCore.cmd
|
||||
```
|
||||
|
||||
### Performance issues
|
||||
|
||||
- Ensure KVM is enabled: `lsmod | grep kvm`
|
||||
- Increase RAM/CPU in VM settings
|
||||
- Use VirtIO drivers for disk and network
|
||||
|
||||
## Notes
|
||||
|
||||
- The Windows VM requires a valid Windows license
|
||||
- First boot after shutdown takes longer (Windows updates, etc.)
|
||||
- Consider keeping the VM running during development sessions
|
||||
- Snapshot the VM after successful setup to enable easy recovery
|
||||
26
changelog.md
26
changelog.md
@ -1,3 +1,29 @@
|
||||
### v8.4.1 (2026-02-01)
|
||||
|
||||
**Fix** Fix several path traversal vulnerabilities.
|
||||
|
||||
**Fix** Use custom page size parameter (limit) for Newznab indexers instead of the hardcoded value of 1000.
|
||||
|
||||
**Fix** Releases were incorrectly shown as BETA in the updates view.
|
||||
|
||||
**Fix** Hopefully fix NullPointerException when loading Torbox downloads.
|
||||
|
||||
**Fix** SSL keystore password is no longer required. See <a href="https://github.com/theotherp/nzbhydra2/issues/1036">#1036</a>
|
||||
|
||||
**Fix** Prevent startup if the data folder has less than 500 MB free space to ensure the database can be written. See <a href="https://github.com/theotherp/nzbhydra2/issues/850">#850</a>
|
||||
|
||||
|
||||
|
||||
### v8.4.0 (2026-01-31)
|
||||
|
||||
**Feature** Option to overwrite result category N/A with the category of the search
|
||||
|
||||
**Fix** Truncate notification body to 255 characters before saving to database to prevent DataIntegrityViolationException causing massive log files. See <a href="https://github.com/theotherp/nzbhydra2/issues/1040">#1040</a>
|
||||
|
||||
**Fix** If a custom page size parameter (limit) is configured for a newznab indexer use that one instead of the hardcoded 1000.
|
||||
|
||||
|
||||
|
||||
### v8.3.0 (2026-01-16)
|
||||
|
||||
**Feature** Added an option to show a quality indicator for movie releases. This helps identify release quality at a glance without needing to understand terms like HC, TS or DV. You can enable it in the searching section. The parser is adapted from Radarr. To comply with their GPL license, NZBHydra is now GPL as well. This doesn't change anything for you as a user.
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
<parent>
|
||||
<groupId>org.nzbhydra</groupId>
|
||||
<artifactId>nzbhydra2</artifactId>
|
||||
<version>8.3.0</version>
|
||||
<version>8.4.2-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>core</artifactId>
|
||||
@ -100,12 +100,12 @@
|
||||
<dependency>
|
||||
<groupId>org.nzbhydra</groupId>
|
||||
<artifactId>mapping</artifactId>
|
||||
<version>8.3.0</version>
|
||||
<version>8.4.2-SNAPSHOT</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.nzbhydra</groupId>
|
||||
<artifactId>release-parser</artifactId>
|
||||
<version>8.3.0</version>
|
||||
<version>8.4.2-SNAPSHOT</version>
|
||||
</dependency>
|
||||
|
||||
<!-- spring (boot) -->
|
||||
|
||||
@ -65,6 +65,7 @@ public class NzbHydra {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(NzbHydra.class);
|
||||
public static final String BROWSER_DISABLED = "browser.disabled";
|
||||
public static final long USABLE_MB_NEEDED = 500L;
|
||||
|
||||
public static String[] originalArgs;
|
||||
private static ConfigurableApplicationContext applicationContext;
|
||||
@ -158,6 +159,14 @@ public class NzbHydra {
|
||||
logger.error("Unable to read or write data folder {}", dataFolder);
|
||||
System.exit(1);
|
||||
}
|
||||
//Check if we have enough disk space
|
||||
long usableSpaceBytes = dataFolderFile.getUsableSpace();
|
||||
long requiredSpaceBytes = USABLE_MB_NEEDED * 1024 * 1024;
|
||||
if (usableSpaceBytes < requiredSpaceBytes) {
|
||||
long usableSpaceMB = usableSpaceBytes / (1024 * 1024);
|
||||
logger.error("Insufficient disk space in data folder {}. Required: {} MB, Available: {} MB", dataFolder, USABLE_MB_NEEDED, usableSpaceMB);
|
||||
System.exit(1);
|
||||
}
|
||||
if (isOsWindows()) {
|
||||
String programFiles = Strings.nullToEmpty(System.getenv("PROGRAMFILES")).toLowerCase();
|
||||
String programFilesx86 = Strings.nullToEmpty(System.getenv("PROGRAMFILES(X86)")).toLowerCase();
|
||||
@ -353,7 +362,7 @@ public class NzbHydra {
|
||||
return new CaffeineCacheManager("infos", "titles", "updates", "dev");
|
||||
}
|
||||
|
||||
static void setDataFolder(String dataFolder) {
|
||||
public static void setDataFolder(String dataFolder) {
|
||||
NzbHydra.dataFolder = dataFolder;
|
||||
}
|
||||
|
||||
|
||||
@ -68,6 +68,9 @@ public class BackupWeb {
|
||||
@Secured({"ROLE_ADMIN"})
|
||||
@RequestMapping(value = "/internalapi/backup/restore", method = RequestMethod.GET)
|
||||
public GenericResponse restore(@RequestParam String filename) throws Exception {
|
||||
if (!isValidBackupFile(filename)) {
|
||||
throw new IllegalArgumentException("Invalid backup file: " + filename);
|
||||
}
|
||||
return backup.restore(filename);
|
||||
}
|
||||
|
||||
@ -80,8 +83,15 @@ public class BackupWeb {
|
||||
@Secured({"ROLE_ADMIN"})
|
||||
@RequestMapping(value = "/internalapi/backup/download", method = RequestMethod.GET, produces = MediaType.APPLICATION_OCTET_STREAM_VALUE)
|
||||
public FileSystemResource getFile(@RequestParam("filename") String filename) throws Exception {
|
||||
final FileSystemResource resource = new FileSystemResource(new File(backup.getBackupFolder(), filename));
|
||||
return resource;
|
||||
if (!isValidBackupFile(filename)) {
|
||||
throw new IllegalArgumentException("Invalid backup file: " + filename);
|
||||
}
|
||||
return new FileSystemResource(new File(backup.getBackupFolder(), filename));
|
||||
}
|
||||
|
||||
private boolean isValidBackupFile(String filename) {
|
||||
return backup.getExistingBackups().stream()
|
||||
.anyMatch(entry -> entry.getFilename().equals(filename));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -108,7 +108,9 @@ public class DiskCache extends AbstractValueAdaptingCache {
|
||||
|
||||
@NotNull
|
||||
private File buildKeyFile(String keyString) {
|
||||
return new File(cacheDir, keyString);
|
||||
// Sanitize key to prevent path traversal - replace unsafe characters
|
||||
String sanitizedKey = keyString.replaceAll("[/\\\\:*?\"<>|.]", "_");
|
||||
return new File(cacheDir, sanitizedKey);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
package org.nzbhydra.config.validation;
|
||||
|
||||
import org.nzbhydra.config.sensitive.SensitiveDataObfuscator;
|
||||
import org.nzbhydra.config.sensitive.HiddenInUI;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Component;
|
||||
@ -98,11 +98,11 @@ public class SensitiveDataConfigValidator {
|
||||
continue;
|
||||
}
|
||||
|
||||
// For encrypted sensitive string fields, replace with placeholder for display
|
||||
if (field.getType() == String.class && forDisplay) {
|
||||
// For fields marked as hidden in UI, replace with placeholder for display
|
||||
if (field.getType() == String.class && forDisplay && field.isAnnotationPresent(HiddenInUI.class)) {
|
||||
String value = (String) fieldValue;
|
||||
if (SensitiveDataObfuscator.isEncrypted(value)) {
|
||||
// Don't expose the encrypted value to frontend, just show placeholder
|
||||
// Don't expose hidden values to frontend, just show placeholder
|
||||
if (value != null && !value.isEmpty()) {
|
||||
field.set(obj, UNCHANGED_MARKER);
|
||||
continue;
|
||||
}
|
||||
|
||||
@ -69,10 +69,10 @@ public class DebugInfosWeb {
|
||||
@Secured({"ROLE_ADMIN"})
|
||||
@RequestMapping(value = "/internalapi/debuginfos/downloadlog", method = RequestMethod.GET, produces = MediaType.TEXT_PLAIN_VALUE)
|
||||
public FileSystemResource downloadLogFile(@RequestParam String logfilename) throws IOException {
|
||||
File file = new File(new File(NzbHydra.getDataFolder(), "logs"), logfilename);
|
||||
if (!file.getCanonicalFile().toPath().startsWith(new File(NzbHydra.getDataFolder()).getCanonicalFile().toPath())) {
|
||||
throw new IOException("Log file not in data folder");
|
||||
if (!logContentProvider.getLogFileNames().contains(logfilename)) {
|
||||
throw new IOException("Invalid log file: " + logfilename);
|
||||
}
|
||||
File file = new File(new File(NzbHydra.getDataFolder(), "logs"), logfilename);
|
||||
return new FileSystemResource(file);
|
||||
}
|
||||
|
||||
|
||||
@ -190,7 +190,7 @@ public class Torbox extends Downloader {
|
||||
|
||||
@Override
|
||||
public DownloaderStatus getStatus() throws DownloaderException {
|
||||
List<TorboxDownload> downloadingEntries = getLastTorboxDownloads().stream().filter(x -> x.getDownloadState().equals("downloading")).toList();
|
||||
List<TorboxDownload> downloadingEntries = getLastTorboxDownloads().stream().filter(x -> "downloading".equals(x.getDownloadState())).toList();
|
||||
long downloadSpeedKb = downloadingEntries.stream().mapToLong(TorboxDownload::getDownloadSpeedBytes).sum() / 1024;
|
||||
addDownloadRate(downloadSpeedKb);
|
||||
DownloaderStatus.DownloaderStatusBuilder statusBuilder = DownloaderStatus.builder()
|
||||
@ -222,7 +222,7 @@ public class Torbox extends Downloader {
|
||||
public List<DownloaderEntry> getHistory(Instant earliestDownload) throws DownloaderException {
|
||||
return getDownloaderEntries()
|
||||
.stream()
|
||||
.filter(x -> x.getStatus().equals("failed") || x.getStatus().equals("completed") || x.getStatus().equals("cached"))
|
||||
.filter(x -> "failed".equals(x.getStatus()) || "completed".equals(x.getStatus()) || "cached".equals(x.getStatus()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@ -230,7 +230,7 @@ public class Torbox extends Downloader {
|
||||
public List<DownloaderEntry> getQueue(@Nullable Instant earliestDownload) throws DownloaderException {
|
||||
return getDownloaderEntries()
|
||||
.stream()
|
||||
.filter(x -> !x.getStatus().equals("failed") && !x.getStatus().equals("completed") && !x.getStatus().equals("cached"))
|
||||
.filter(x -> !"failed".equals(x.getStatus()) && !"completed".equals(x.getStatus()) && !"cached".equals(x.getStatus()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@ -286,6 +286,9 @@ public class Torbox extends Downloader {
|
||||
|
||||
@Override
|
||||
protected FileDownloadStatus getDownloadStatusFromDownloaderEntry(DownloaderEntry entry, StatusCheckType statusCheckType) {
|
||||
if (entry.getStatus() == null) {
|
||||
return FileDownloadStatus.NONE;
|
||||
}
|
||||
switch (entry.getStatus()) {
|
||||
case "completed", "cached" -> {
|
||||
return FileDownloadStatus.CONTENT_DOWNLOAD_SUCCESSFUL;
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
package org.nzbhydra.downloading.downloaders.torbox.mapping;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.Data;
|
||||
import org.nzbhydra.springnative.ReflectionMarker;
|
||||
@ -11,8 +12,10 @@ import java.util.List;
|
||||
|
||||
@Data
|
||||
@ReflectionMarker
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public class TorboxDownload {
|
||||
private long id;
|
||||
private Instant cached_at;
|
||||
private Instant created_at;
|
||||
private Instant updated_at;
|
||||
private String auth_id;
|
||||
|
||||
@ -2,11 +2,13 @@
|
||||
|
||||
package org.nzbhydra.downloading.downloaders.torbox.mapping;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import lombok.Data;
|
||||
import org.nzbhydra.springnative.ReflectionMarker;
|
||||
|
||||
@Data
|
||||
@ReflectionMarker
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public class TorboxFile {
|
||||
private long id;
|
||||
private String md5;
|
||||
@ -19,4 +21,5 @@ public class TorboxFile {
|
||||
private String mimetype;
|
||||
private String short_name;
|
||||
private String absolute_path;
|
||||
private String opensubtitles_hash;
|
||||
}
|
||||
@ -90,7 +90,7 @@ public class NzbHandlingWeb {
|
||||
logger.debug("downloadNzbZip: {}", zipFilepath);
|
||||
final Optional<File> matchingFile = fileHandler.getTemporaryZipFiles().stream().filter(x -> x.getAbsolutePath().equals(zipFilepath)).findFirst();
|
||||
if (matchingFile.isPresent()) {
|
||||
return new FileSystemResource(zipFilepath);
|
||||
return new FileSystemResource(matchingFile.get());
|
||||
}
|
||||
throw new RuntimeException("Not allowed to access file " + zipFilepath + " as it was not created by NZBHydra");
|
||||
}
|
||||
|
||||
@ -7,6 +7,7 @@ import org.nzbhydra.config.BaseConfigHandler;
|
||||
import org.nzbhydra.config.ConfigChangedEvent;
|
||||
import org.nzbhydra.config.ConfigProvider;
|
||||
import org.nzbhydra.config.indexer.IndexerConfig;
|
||||
import org.nzbhydra.config.searching.SearchType;
|
||||
import org.nzbhydra.indexers.exceptions.IndexerAccessException;
|
||||
import org.nzbhydra.indexers.exceptions.IndexerAuthException;
|
||||
import org.nzbhydra.indexers.exceptions.IndexerErrorCodeException;
|
||||
@ -226,6 +227,7 @@ public abstract class Indexer<T> {
|
||||
} catch (Exception e) {
|
||||
error("Error mapping search result title for " + searchResultItem.getTitle(), e);
|
||||
}
|
||||
overwriteNaCategoryWithSearchTypeCategory(searchRequest, searchResultItem);
|
||||
}
|
||||
|
||||
indexerSearchResult.setPageSize(searchResultItems.size());
|
||||
@ -248,6 +250,20 @@ public abstract class Indexer<T> {
|
||||
return indexerSearchResult;
|
||||
}
|
||||
|
||||
private void overwriteNaCategoryWithSearchTypeCategory(SearchRequest searchRequest, SearchResultItem searchResultItem) {
|
||||
if (!categoryProvider.getNotAvailable().equals(searchResultItem.getCategory()) || !configProvider.getBaseConfig().getCategoriesConfig().isOverwriteNaWithSearchCategory()) {
|
||||
debug(LoggingMarkers.CATEGORY_MAPPING, "Search result {} with category {} not N/A or overwriting disabled ", searchResultItem.getTitle(), searchResultItem.getCategory().getName());
|
||||
return;
|
||||
}
|
||||
if (searchRequest.getSearchType() == SearchType.SEARCH) {
|
||||
debug(LoggingMarkers.CATEGORY_MAPPING, "Search type is SEARCH so no overwriting possible");
|
||||
return;
|
||||
}
|
||||
|
||||
debug(LoggingMarkers.CATEGORY_MAPPING, "Overwriting N/A category for {} with search category {}", searchResultItem.getTitle(), searchRequest.getCategory());
|
||||
searchResultItem.setCategory(searchRequest.getCategory());
|
||||
}
|
||||
|
||||
/**
|
||||
* Responsible for filling the meta data of the IndexerSearchResult, e.g. number of available results and the used offset
|
||||
*
|
||||
|
||||
@ -144,8 +144,13 @@ public class Newznab extends Indexer<Xml> {
|
||||
query = cleanupQuery(query);
|
||||
addFurtherParametersToUri(searchRequest, componentsBuilder, query);
|
||||
|
||||
//No reason not to get as many as we can
|
||||
componentsBuilder.queryParam("limit", 1000);
|
||||
//No reason not to get as many as we can. Use custom limit if configured.
|
||||
Integer effectiveLimit = config.getCustomParameters().stream()
|
||||
.filter(x -> x.toLowerCase().startsWith("limit="))
|
||||
.map(x -> Integer.parseInt(x.split("=")[1]))
|
||||
.findFirst()
|
||||
.orElse(1000);
|
||||
componentsBuilder.queryParam("limit", effectiveLimit);
|
||||
if (offset != null) {
|
||||
componentsBuilder.queryParam("offset", offset);
|
||||
}
|
||||
@ -205,10 +210,12 @@ public class Newznab extends Indexer<Xml> {
|
||||
|
||||
calculateAndAddCategories(searchRequest, componentsBuilder);
|
||||
|
||||
config.getCustomParameters().forEach(x -> {
|
||||
final String[] split = x.split("=");
|
||||
componentsBuilder.queryParam(split[0], split[1]);
|
||||
});
|
||||
config.getCustomParameters().stream()
|
||||
.filter(x -> !x.toLowerCase().startsWith("limit="))
|
||||
.forEach(x -> {
|
||||
final String[] split = x.split("=");
|
||||
componentsBuilder.queryParam(split[0], split[1]);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
|
||||
@ -70,4 +70,5 @@ public class NewznabCategoryComputer {
|
||||
searchResultItem.setCategory(categoryProvider.getNotAvailable());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -14,6 +14,7 @@ import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.annotation.Secured;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMethod;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
@ -23,6 +24,7 @@ import java.io.IOException;
|
||||
import java.security.Principal;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@RestController
|
||||
public class NewsWeb {
|
||||
@ -33,6 +35,8 @@ public class NewsWeb {
|
||||
private NewsProvider newsProvider;
|
||||
@Autowired
|
||||
private UpdateManager updateManager;
|
||||
@Autowired
|
||||
private UserNewsProvider userNewsProvider;
|
||||
|
||||
@RequestMapping(value = "/internalapi/news", method = RequestMethod.GET)
|
||||
@Secured({"ROLE_USER"})
|
||||
@ -73,5 +77,25 @@ public class NewsWeb {
|
||||
return transformedEntries;
|
||||
}
|
||||
|
||||
@RequestMapping(value = "/internalapi/usernews", method = RequestMethod.GET)
|
||||
@Secured({"ROLE_USER"})
|
||||
public List<UserNewsEntryForWeb> getUnreadUserNews(Principal principal) {
|
||||
String username = principal != null ? principal.getName() : "anonymous";
|
||||
return userNewsProvider.getUnreadUserNewsForUser(username).stream()
|
||||
.map(entry -> new UserNewsEntryForWeb(
|
||||
entry.getId(),
|
||||
entry.getTitle(),
|
||||
Markdown.renderMarkdownAsHtml(entry.getBody())
|
||||
))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@RequestMapping(value = "/internalapi/usernews/{id}/dismiss", method = RequestMethod.PUT)
|
||||
@Secured({"ROLE_USER"})
|
||||
public GenericResponse dismissUserNews(@PathVariable String id, Principal principal) {
|
||||
String username = principal != null ? principal.getName() : "anonymous";
|
||||
userNewsProvider.markNewsAsShownForUser(username, id);
|
||||
return GenericResponse.ok();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
16
core/src/main/java/org/nzbhydra/news/UserNewsEntry.java
Normal file
16
core/src/main/java/org/nzbhydra/news/UserNewsEntry.java
Normal file
@ -0,0 +1,16 @@
|
||||
package org.nzbhydra.news;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.nzbhydra.springnative.ReflectionMarker;
|
||||
|
||||
@Data
|
||||
@ReflectionMarker
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
public class UserNewsEntry {
|
||||
private String id;
|
||||
private String title;
|
||||
private String body;
|
||||
}
|
||||
90
core/src/main/java/org/nzbhydra/news/UserNewsProvider.java
Normal file
90
core/src/main/java/org/nzbhydra/news/UserNewsProvider.java
Normal file
@ -0,0 +1,90 @@
|
||||
package org.nzbhydra.news;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import org.nzbhydra.Jackson;
|
||||
import org.nzbhydra.NzbHydra;
|
||||
import org.nzbhydra.genericstorage.GenericStorage;
|
||||
import org.nzbhydra.springnative.ReflectionMarker;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.Serializable;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Component
|
||||
public class UserNewsProvider {
|
||||
|
||||
private static final String USER_NEWS_FILE = "userNews.json";
|
||||
private static final String SHOWN_USER_NEWS_KEY = "shownUserNews";
|
||||
|
||||
@Autowired
|
||||
private GenericStorage genericStorage;
|
||||
|
||||
public List<UserNewsEntry> getAllUserNews() {
|
||||
File userNewsFile = new File(NzbHydra.getDataFolder(), USER_NEWS_FILE);
|
||||
if (!userNewsFile.exists()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
try {
|
||||
return Jackson.JSON_MAPPER.readValue(userNewsFile, new TypeReference<>() {
|
||||
});
|
||||
} catch (Exception e) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
}
|
||||
|
||||
public List<UserNewsEntry> getUnreadUserNewsForUser(String username) {
|
||||
List<UserNewsEntry> allNews = getAllUserNews();
|
||||
if (allNews.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
Set<String> shownNewsIds = getShownNewsIdsForUser(username);
|
||||
return allNews.stream()
|
||||
.filter(entry -> !shownNewsIds.contains(entry.getId()))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
public void markNewsAsShownForUser(String username, String newsId) {
|
||||
Set<String> shownNewsIds = getShownNewsIdsForUser(username);
|
||||
shownNewsIds.add(newsId);
|
||||
genericStorage.save(getStorageKey(username), new ShownUserNewsIds(shownNewsIds));
|
||||
}
|
||||
|
||||
private Set<String> getShownNewsIdsForUser(String username) {
|
||||
String key = getStorageKey(username);
|
||||
return genericStorage.get(key, ShownUserNewsIds.class)
|
||||
.map(ShownUserNewsIds::getIds)
|
||||
.orElse(new HashSet<>());
|
||||
}
|
||||
|
||||
private String getStorageKey(String username) {
|
||||
return SHOWN_USER_NEWS_KEY + "-" + username;
|
||||
}
|
||||
|
||||
@ReflectionMarker
|
||||
public static class ShownUserNewsIds implements Serializable {
|
||||
private Set<String> ids;
|
||||
|
||||
public ShownUserNewsIds() {
|
||||
this.ids = new HashSet<>();
|
||||
}
|
||||
|
||||
public ShownUserNewsIds(Set<String> ids) {
|
||||
this.ids = ids;
|
||||
}
|
||||
|
||||
public Set<String> getIds() {
|
||||
return ids;
|
||||
}
|
||||
|
||||
public void setIds(Set<String> ids) {
|
||||
this.ids = ids;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -97,7 +97,8 @@ public class NotificationHandler {
|
||||
throw new RuntimeException("Unable to generate notification body", e);
|
||||
}
|
||||
|
||||
notificationRepository.save(new NotificationEntity(event.getEventType(), NotificationMessageType.valueOf(configEntry.getMessageType().name()), notificationTitle, notificationBody, configEntry.getAppriseUrls(), Instant.now()));
|
||||
final String truncatedBody = notificationBody.length() > 255 ? notificationBody.substring(0, 255) : notificationBody;
|
||||
notificationRepository.save(new NotificationEntity(event.getEventType(), NotificationMessageType.valueOf(configEntry.getMessageType().name()), notificationTitle, truncatedBody, configEntry.getAppriseUrls(), Instant.now()));
|
||||
|
||||
if (notificationConfig.getAppriseType() == NotificationConfig.AppriseType.NONE) {
|
||||
logger.debug(LoggingMarkers.NOTIFICATIONS, "Apprise type set to None");
|
||||
|
||||
@ -33,9 +33,13 @@ import java.util.stream.Collectors;
|
||||
@EnableConfigurationProperties
|
||||
public class CategoryProvider implements InitializingBean {
|
||||
|
||||
public static final Category naCategory = new Category("N/A");
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(CategoryProvider.class);
|
||||
|
||||
@Autowired
|
||||
protected BaseConfig baseConfig;
|
||||
|
||||
/**
|
||||
* List of all categories in the order in which they are configured and to be shown in the dropdown
|
||||
*/
|
||||
@ -52,10 +56,6 @@ public class CategoryProvider implements InitializingBean {
|
||||
protected Map<Integer, Category> categoryMapByNumber = new HashMap<>();
|
||||
protected Map<List<Integer>, Category> categoryMapByMultipleNumber = new HashMap<>();
|
||||
|
||||
@Autowired
|
||||
protected BaseConfig baseConfig;
|
||||
|
||||
public static final Category naCategory = new Category("N/A");
|
||||
|
||||
public CategoryProvider() {
|
||||
naCategory.setApplyRestrictionsType(SearchSourceRestriction.NONE);
|
||||
|
||||
@ -18,6 +18,11 @@ class SearchState {
|
||||
private int indexersSelected = 0;
|
||||
private int indexersFinished = 0;
|
||||
private List<SortableMessage> messages = new ArrayList<>();
|
||||
/**
|
||||
* Transient flag used to indicate if the state was modified and should be sent.
|
||||
* Not serialized to JSON.
|
||||
*/
|
||||
private transient boolean modified = true;
|
||||
|
||||
public SearchState(long searchRequestId) {
|
||||
this.searchRequestId = searchRequestId;
|
||||
|
||||
@ -57,7 +57,7 @@ public class SearchWeb {
|
||||
private final Lock lock = new ReentrantLock();
|
||||
|
||||
private final Map<Long, SearchState> searchStates = ExpiringMap.builder()
|
||||
.maxSize(10)
|
||||
.maxSize(50)
|
||||
.expiration(5, TimeUnit.MINUTES) //This should be more than enough... Nobody will wait that long
|
||||
.expirationPolicy(ExpirationPolicy.ACCESSED)
|
||||
.build();
|
||||
@ -73,11 +73,15 @@ public class SearchWeb {
|
||||
|
||||
SearchResponse searchResponse = searchResultProcessor.createSearchResponse(searchResult);
|
||||
|
||||
SearchState searchState;
|
||||
lock.lock();
|
||||
SearchState searchState = searchStates.get(searchRequest.getSearchRequestId());
|
||||
searchState.setSearchFinished(true);
|
||||
try {
|
||||
searchState = searchStates.get(searchRequest.getSearchRequestId());
|
||||
searchState.setSearchFinished(true);
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
sendSearchState(searchState);
|
||||
lock.unlock();
|
||||
|
||||
logger.info("Web search took {}ms", stopwatch.elapsed(TimeUnit.MILLISECONDS));
|
||||
return searchResponse;
|
||||
@ -163,49 +167,57 @@ public class SearchWeb {
|
||||
|
||||
@EventListener
|
||||
public void handleSearchMessageEvent(SearchMessageEvent event) {
|
||||
if (searchStates.containsKey(event.getSearchRequest().getSearchRequestId())) {
|
||||
lock.lock();
|
||||
SearchState searchState = searchStates.get(event.getSearchRequest().getSearchRequestId());
|
||||
updateAndSendSearchState(event.getSearchRequest().getSearchRequestId(), searchState -> {
|
||||
if (!searchState.getMessages().contains(event.getMessage())) {
|
||||
searchState.getMessages().add(event.getMessage());
|
||||
searchState.getMessages().sort(Comparator.comparing(x -> x.getMessageSortValue().toLowerCase(Locale.ROOT)));
|
||||
sendSearchState(searchState);
|
||||
searchState.setModified(true);
|
||||
}
|
||||
lock.unlock();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@EventListener
|
||||
public void handleIndexerSelectionEvent(IndexerSelectionEvent event) {
|
||||
if (searchStates.containsKey(event.getSearchRequest().getSearchRequestId())) {
|
||||
lock.lock();
|
||||
SearchState searchState = searchStates.get(event.getSearchRequest().getSearchRequestId());
|
||||
updateAndSendSearchState(event.getSearchRequest().getSearchRequestId(), searchState -> {
|
||||
searchState.setIndexerSelectionFinished(true);
|
||||
searchState.setIndexersSelected(event.getIndexersSelected());
|
||||
lock.unlock();
|
||||
sendSearchState(searchState);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@EventListener
|
||||
public void handleFallbackSearchInitatedEvent(FallbackSearchInitiatedEvent event) {
|
||||
//An indexer will do a fallback search, meaning we'll have to wait for another indexer search. On the GUI side that's the same as if one more indexer had been selected
|
||||
if (searchStates.containsKey(event.getSearchRequest().getSearchRequestId())) {
|
||||
lock.lock();
|
||||
SearchState searchState = searchStates.get(event.getSearchRequest().getSearchRequestId());
|
||||
searchState.setIndexersSelected(searchState.getIndexersSelected() + 1);
|
||||
lock.unlock();
|
||||
sendSearchState(searchState);
|
||||
}
|
||||
updateAndSendSearchState(event.getSearchRequest().getSearchRequestId(), searchState ->
|
||||
searchState.setIndexersSelected(searchState.getIndexersSelected() + 1));
|
||||
}
|
||||
|
||||
@EventListener
|
||||
public void handleIndexerSearchFinishedEvent(IndexerSearchFinishedEvent event) {
|
||||
if (searchStates.containsKey(event.getSearchRequest().getSearchRequestId())) {
|
||||
lock.lock();
|
||||
SearchState searchState = searchStates.get(event.getSearchRequest().getSearchRequestId());
|
||||
searchState.setIndexersFinished(searchState.getIndexersFinished() + 1);
|
||||
updateAndSendSearchState(event.getSearchRequest().getSearchRequestId(), searchState ->
|
||||
searchState.setIndexersFinished(searchState.getIndexersFinished() + 1));
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the search state under lock and sends it after releasing the lock.
|
||||
* The updater can set searchState.setModified(false) to skip sending.
|
||||
*/
|
||||
private void updateAndSendSearchState(Long searchRequestId, java.util.function.Consumer<SearchState> updater) {
|
||||
if (!searchStates.containsKey(searchRequestId)) {
|
||||
return;
|
||||
}
|
||||
SearchState searchState;
|
||||
lock.lock();
|
||||
try {
|
||||
searchState = searchStates.get(searchRequestId);
|
||||
if (searchState == null) {
|
||||
return;
|
||||
}
|
||||
searchState.setModified(true);
|
||||
updater.accept(searchState);
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
if (searchState.isModified()) {
|
||||
sendSearchState(searchState);
|
||||
}
|
||||
}
|
||||
|
||||
@ -29,7 +29,7 @@ import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
@ -71,6 +71,8 @@ public class Searcher {
|
||||
private ApplicationEventPublisher eventPublisher;
|
||||
@Autowired
|
||||
private ConfigProvider configProvider;
|
||||
@Autowired
|
||||
private TransactionTemplate transactionTemplate;
|
||||
private final Set<ExecutorService> executors = Collections.synchronizedSet(new HashSet<>());
|
||||
private final Map<Long, List<Future<IndexerSearchResult>>> searchCallables = ExpiringMap.builder()
|
||||
.maxSize(10)
|
||||
@ -89,7 +91,6 @@ public class Searcher {
|
||||
.expirationListener((k, v) -> logger.debug("Removing expired search cache entry {}", ((SearchCacheEntry) v).getSearchRequest()))
|
||||
.build();
|
||||
|
||||
@Transactional
|
||||
public SearchResult search(SearchRequest searchRequest) {
|
||||
Stopwatch stopwatch = Stopwatch.createStarted();
|
||||
eventPublisher.publishEvent(new SearchEvent(searchRequest));
|
||||
@ -260,7 +261,7 @@ public class Searcher {
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private void createOrUpdateIndexerSearchEnties(SearchCacheEntry searchCacheEntry) {
|
||||
protected void createOrUpdateIndexerSearchEnties(SearchCacheEntry searchCacheEntry) {
|
||||
Stopwatch stopwatch = Stopwatch.createStarted();
|
||||
int countEntities = 0;
|
||||
|
||||
@ -275,14 +276,18 @@ public class Searcher {
|
||||
entity.setSuccessful(indexerSearchResult.isWasSuccessful());
|
||||
indexerSearchCacheEntry.setIndexerSearchEntity(entity);
|
||||
}
|
||||
if (configProvider.getBaseConfig().getMain().isKeepHistory()) {
|
||||
entity = indexerSearchRepository.save(entity);
|
||||
for (SearchResultEntity x : indexerSearchResult.getSearchResultEntities()) {
|
||||
x.setIndexerSearchEntityId(entity.getId());
|
||||
IndexerSearchEntity finalEntity = entity;
|
||||
transactionTemplate.executeWithoutResult(status -> {
|
||||
IndexerSearchEntity savedEntity = finalEntity;
|
||||
if (configProvider.getBaseConfig().getMain().isKeepHistory()) {
|
||||
savedEntity = indexerSearchRepository.save(finalEntity);
|
||||
for (SearchResultEntity x : indexerSearchResult.getSearchResultEntities()) {
|
||||
x.setIndexerSearchEntityId(savedEntity.getId());
|
||||
}
|
||||
}
|
||||
}
|
||||
searchResultRepository.saveAll(indexerSearchResult.getSearchResultEntities());
|
||||
searchCacheEntry.getIndexerCacheEntries().get(indexerSearchResult.getIndexer().getName()).setIndexerSearchEntity(entity);
|
||||
searchResultRepository.saveAll(indexerSearchResult.getSearchResultEntities());
|
||||
searchCacheEntry.getIndexerCacheEntries().get(indexerSearchResult.getIndexer().getName()).setIndexerSearchEntity(savedEntity);
|
||||
});
|
||||
countEntities++;
|
||||
}
|
||||
}
|
||||
@ -309,7 +314,7 @@ public class Searcher {
|
||||
searchRequest.extractQueryAndForbiddenWords();
|
||||
|
||||
if (configProvider.getBaseConfig().getMain().isKeepHistory()) {
|
||||
searchRepository.save(searchEntity);
|
||||
transactionTemplate.executeWithoutResult(status -> searchRepository.save(searchEntity));
|
||||
}
|
||||
|
||||
IndexerForSearchSelection pickingResult = indexerSelector.pickIndexers(searchRequest);
|
||||
|
||||
@ -2,6 +2,7 @@ package org.nzbhydra.web;
|
||||
|
||||
import com.fasterxml.jackson.databind.module.SimpleModule;
|
||||
import jakarta.xml.bind.Marshaller;
|
||||
import lombok.SneakyThrows;
|
||||
import org.nzbhydra.NzbHydra;
|
||||
import org.nzbhydra.api.stats.HistoryRequestConverter;
|
||||
import org.nzbhydra.api.stats.StatsRequestConverter;
|
||||
@ -56,6 +57,7 @@ public class WebConfiguration extends WebMvcConfigurationSupport {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(WebConfiguration.class);
|
||||
|
||||
@SneakyThrows
|
||||
@Override
|
||||
public void addResourceHandlers(ResourceHandlerRegistry registry) {
|
||||
String[] locations = new String[]{"classpath:/static/"};
|
||||
@ -77,6 +79,15 @@ public class WebConfiguration extends WebMvcConfigurationSupport {
|
||||
.addResourceLocations(locations)
|
||||
.setCacheControl(CacheControl.noCache())
|
||||
.resourceChain(false);
|
||||
File additionalStatic = new File(NzbHydra.getDataFolder(), "additionalStatic");
|
||||
if (additionalStatic.exists()) {
|
||||
logger.warn("Files in the data/additionalStatic folder will be exposed");
|
||||
}
|
||||
registry.addResourceHandler("/additionalStatic/**")
|
||||
.addResourceLocations(additionalStatic.toURI().toURL().toString())
|
||||
.setCacheControl(CacheControl.noCache())
|
||||
.resourceChain(false);
|
||||
|
||||
registry.addResourceHandler("/favicon.*")
|
||||
.addResourceLocations("classpath:/static/img/")
|
||||
.setCacheControl(CacheControl.noCache())
|
||||
|
||||
@ -1,9 +1,36 @@
|
||||
#@formatter:off
|
||||
- version: "v8.4.1"
|
||||
date: "2026-02-01"
|
||||
final: true
|
||||
changes:
|
||||
- type: "fix"
|
||||
text: "Fix several path traversal vulnerabilities."
|
||||
- type: "fix"
|
||||
text: "Use custom page size parameter (limit) for Newznab indexers instead of the hardcoded value of 1000."
|
||||
- type: "fix"
|
||||
text: "Releases were incorrectly shown as BETA in the updates view."
|
||||
- type: "fix"
|
||||
text: "Hopefully fix NullPointerException when loading Torbox downloads."
|
||||
- type: "fix"
|
||||
text: "SSL keystore password is no longer required. See #1036"
|
||||
- type: "fix"
|
||||
text: "Prevent startup if the data folder has less than 500 MB free space to ensure the database can be written. See #850"
|
||||
- version: "v8.4.0"
|
||||
date: "2026-01-31"
|
||||
changes:
|
||||
- type: "feature"
|
||||
text: "Option to overwrite result category N/A with the category of the search"
|
||||
- type: "fix"
|
||||
text: "Truncate notification body to 255 characters before saving to database to prevent DataIntegrityViolationException causing massive log files. See #1040"
|
||||
- type: "fix"
|
||||
text: "If a custom page size parameter (limit) is configured for a newznab indexer use that one instead of the hardcoded 1000."
|
||||
final: true
|
||||
- version: "v8.3.0"
|
||||
date: "2026-01-16"
|
||||
changes:
|
||||
- type: "feature"
|
||||
text: "Added an option to show a quality indicator for movie releases. This helps identify release quality at a glance without needing to understand terms like HC, TS or DV. You can enable it in the searching section. The parser is adapted from Radarr. To comply with their GPL license, NZBHydra is now GPL as well. This doesn't change anything for you as a user."
|
||||
final: true
|
||||
- version: "v8.2.3"
|
||||
date: "2026-01-15"
|
||||
changes:
|
||||
@ -11,16 +38,19 @@
|
||||
text: "Make filter icons and sort indicators visible in bright theme :-)"
|
||||
- type: "fix"
|
||||
text: "Send content to sabnzbd in correct format. See #1037"
|
||||
final: true
|
||||
- version: "v8.2.2"
|
||||
date: "2026-01-11"
|
||||
changes:
|
||||
- type: "fix"
|
||||
text: "Fix attribute whitelist not matching multi-value attributes (e.g., subs with values like \"English - French - German\"). See #983"
|
||||
final: true
|
||||
- version: "v8.2.1"
|
||||
date: "2026-01-06"
|
||||
changes:
|
||||
- type: "fix"
|
||||
text: "Fix reading prowlarr config. I've extended the tests to ensure this kind of bug doesn't happen again (the one which only appears with the released binaries, not when developing). See #1035"
|
||||
final: true
|
||||
- version: "v8.2.0"
|
||||
date: "2026-01-05"
|
||||
changes:
|
||||
|
||||
@ -27,7 +27,7 @@ spring.jpa.properties.hibernate.order_updates=true
|
||||
spring.jpa.properties.hibernate.jdbc.batch_versioned_data=true
|
||||
spring.jpa.open-in-view=false
|
||||
spring.h2.console.enabled=false
|
||||
spring.datasource.hikari.maximum-pool-size=20
|
||||
spring.datasource.hikari.maximum-pool-size=40
|
||||
|
||||
#Migration
|
||||
spring.flyway.enabled=true
|
||||
@ -72,7 +72,7 @@ spring.mvc.throw-exception-if-no-handler-found=true
|
||||
|
||||
#Performance / Memory
|
||||
server.tomcat.threads.min-spare=5
|
||||
server.tomcat.threads.max=25
|
||||
server.tomcat.threads.max=40
|
||||
server.servlet.jsp.registered=false
|
||||
spring.freemarker.enabled=false
|
||||
spring.groovy.template.enabled=false
|
||||
|
||||
@ -14,6 +14,7 @@ auth:
|
||||
users: []
|
||||
categoriesConfig:
|
||||
enableCategorySizes: true
|
||||
overwriteNaWithSearchCategory: true
|
||||
defaultCategory: "All"
|
||||
categories:
|
||||
- name: Anime
|
||||
|
||||
@ -2048,6 +2048,7 @@ function formatClassname() {
|
||||
|
||||
NewsModalInstanceCtrl.$inject = ["$scope", "$uibModalInstance", "news"];
|
||||
WelcomeModalInstanceCtrl.$inject = ["$scope", "$uibModalInstance", "$state", "MigrationService"];
|
||||
UserNewsModalInstanceCtrl.$inject = ["$scope", "$uibModalInstance", "$http", "currentNews"];
|
||||
angular
|
||||
.module('nzbhydraApp')
|
||||
.directive('hydraChecksFooter', hydraChecksFooter);
|
||||
@ -2288,6 +2289,7 @@ function hydraChecksFooter() {
|
||||
welcomeIsBeingShown = false;
|
||||
});
|
||||
} else {
|
||||
_.defer(checkAndShowUserNews);
|
||||
if (HydraAuthService.getUserInfos().maySeeAdmin) {
|
||||
_.defer(checkAndShowNews);
|
||||
_.defer(checkExpiredIndexers);
|
||||
@ -2299,6 +2301,39 @@ function hydraChecksFooter() {
|
||||
});
|
||||
}
|
||||
|
||||
function checkAndShowUserNews() {
|
||||
RequestsErrorHandler.specificallyHandled(function () {
|
||||
$http.get("internalapi/usernews").then(function (response) {
|
||||
var userNews = response.data;
|
||||
if (userNews && userNews.length > 0) {
|
||||
showUserNewsSequentially(userNews, 0);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function showUserNewsSequentially(userNews, index) {
|
||||
if (index >= userNews.length) {
|
||||
return;
|
||||
}
|
||||
var currentNews = userNews[index];
|
||||
var modalInstance = $uibModal.open({
|
||||
templateUrl: 'static/html/user-news-modal.html',
|
||||
controller: UserNewsModalInstanceCtrl,
|
||||
size: "lg",
|
||||
resolve: {
|
||||
currentNews: function () {
|
||||
return currentNews;
|
||||
}
|
||||
}
|
||||
});
|
||||
modalInstance.result.then(function () {
|
||||
showUserNewsSequentially(userNews, index + 1);
|
||||
}, function () {
|
||||
showUserNewsSequentially(userNews, index + 1);
|
||||
});
|
||||
}
|
||||
|
||||
checkAndShowWelcome();
|
||||
|
||||
function showUnreadNotifications(unreadNotifications, stompClient) {
|
||||
@ -2399,6 +2434,22 @@ function WelcomeModalInstanceCtrl($scope, $uibModalInstance, $state, MigrationSe
|
||||
}
|
||||
}
|
||||
|
||||
angular
|
||||
.module('nzbhydraApp')
|
||||
.controller('UserNewsModalInstanceCtrl', UserNewsModalInstanceCtrl);
|
||||
|
||||
function UserNewsModalInstanceCtrl($scope, $uibModalInstance, $http, currentNews) {
|
||||
$scope.currentNews = currentNews;
|
||||
|
||||
$scope.dismiss = function () {
|
||||
$http.put("internalapi/usernews/" + currentNews.id + "/dismiss").then(function () {
|
||||
$uibModalInstance.close();
|
||||
}, function () {
|
||||
$uibModalInstance.close();
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
angular
|
||||
@ -3905,7 +3956,7 @@ function getIndexerBoxFields(indexerModel, parentModel, isInitial, CategoriesSer
|
||||
fieldset.push(
|
||||
{
|
||||
key: 'apiKey',
|
||||
type: 'horizontalInput',
|
||||
type: 'passwordSwitch',
|
||||
templateOptions: {
|
||||
type: 'text',
|
||||
label: 'API Key'
|
||||
@ -3948,7 +3999,7 @@ function getIndexerBoxFields(indexerModel, parentModel, isInitial, CategoriesSer
|
||||
fieldset.push(
|
||||
{
|
||||
key: 'username',
|
||||
type: 'horizontalInput',
|
||||
type: 'passwordSwitch',
|
||||
templateOptions: {
|
||||
type: 'text',
|
||||
required: false,
|
||||
@ -3970,7 +4021,7 @@ function getIndexerBoxFields(indexerModel, parentModel, isInitial, CategoriesSer
|
||||
fieldset.push(
|
||||
{
|
||||
key: 'username',
|
||||
type: 'horizontalInput',
|
||||
type: 'passwordSwitch',
|
||||
templateOptions: {
|
||||
type: 'text',
|
||||
required: true,
|
||||
@ -6010,7 +6061,7 @@ angular
|
||||
if (model.downloaderType === "SABNZBD" || model.downloaderType === "TORBOX") {
|
||||
fieldset.push({
|
||||
key: 'apiKey',
|
||||
type: 'horizontalInput',
|
||||
type: 'passwordSwitch',
|
||||
showFor: ["SABNZBD", "TORBOX"],
|
||||
templateOptions: {
|
||||
type: 'text',
|
||||
@ -6027,7 +6078,7 @@ angular
|
||||
} else if (model.downloaderType === "NZBGET") {
|
||||
fieldset.push({
|
||||
key: 'username',
|
||||
type: 'horizontalInput',
|
||||
type: 'passwordSwitch',
|
||||
templateOptions: {
|
||||
type: 'text',
|
||||
label: 'Username'
|
||||
@ -6362,7 +6413,7 @@ angular
|
||||
template: [
|
||||
'<div class="input-group">',
|
||||
'<input ng-attr-type="{{ hidePassword ? \'password\' : \'text\' }}" class="form-control" ng-model="internalValue"',
|
||||
'ng-attr-placeholder="{{ isUnchangedPassword ? \'Password unchanged\' : \'\' }}"',
|
||||
'ng-attr-placeholder="{{ isUnchangedPassword ? \'Value unchanged\' : \'\' }}"',
|
||||
'ng-change="onPasswordChange()" ng-focus="onPasswordFocus()" ng-blur="onPasswordBlur()"',
|
||||
'ng-required="false"/>', // Disable HTML5 required validation
|
||||
'<span class="input-group-btn input-group-btn2">',
|
||||
@ -7382,12 +7433,9 @@ function ConfigFields($injector) {
|
||||
templateOptions: {
|
||||
type: 'password',
|
||||
label: 'SSL keystore password',
|
||||
required: true,
|
||||
help: 'Requires restart.'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
]
|
||||
},
|
||||
{
|
||||
@ -7435,7 +7483,7 @@ function ConfigFields($injector) {
|
||||
},
|
||||
{
|
||||
key: 'proxyUsername',
|
||||
type: 'horizontalInput',
|
||||
type: 'passwordSwitch',
|
||||
hideExpression: 'model.proxyType==="NONE"',
|
||||
templateOptions: {
|
||||
type: 'text',
|
||||
@ -8868,6 +8916,16 @@ function ConfigFields($injector) {
|
||||
$scope.to.options = options;
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'overwriteNaWithSearchCategory',
|
||||
type: 'horizontalSwitch',
|
||||
templateOptions: {
|
||||
type: 'switch',
|
||||
label: 'Overwrite N/A with search category',
|
||||
help: "Use search category for items with N/A category",
|
||||
tooltip: 'Some indexers may return N/A as category for a result or the category mapping may have failed. With this option enabled the selected search category will be used.'
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'help',
|
||||
templateOptions: {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@ -71,4 +71,5 @@ $templateCache.put('static/html/states/search.html','<script type="text/ng-templ
|
||||
$templateCache.put('static/html/states/stats.html','<ul class="nav nav-tabs" role="tablist">\r\n <li role="presentation" ui-sref-active="active"><a ui-sref="root.stats.indexers" role="tab">Indexer statuses</a></li>\r\n <li role="presentation" ui-sref-active="active" ng-if="bootstrapped.safeConfig.keepHistory"><a ui-sref="root.stats.searches" role="tab">Search history</a></li>\r\n <li role="presentation" ui-sref-active="active" ng-if="bootstrapped.safeConfig.keepHistory"><a ui-sref="root.stats.downloads" role="tab">Download history</a></li>\r\n <li role="presentation" ui-sref-active="active"><a ui-sref="root.stats.notifications" role="tab">Notification history</a></li>\r\n <li role="presentation" ui-sref-active="active" ng-if="bootstrapped.safeConfig.keepHistory"><a ui-sref="root.stats.main" role="tab">Stats</a></li>\r\n</ul>\r\n\r\n<div ui-view="stats"></div>\r\n');
|
||||
$templateCache.put('static/html/states/system.html','<ul class="nav nav-tabs" role="tablist">\n <li ng-repeat="tab in allTabs" ng-class="{\'active\': $index == activeTab}">\n <a href="" ng-click="goToSystemState($index)">{{ tab.name }}</a>\n </li>\n</ul>\n\n<div class="tab-content" style="text-align: center">\n\n <div class="system-tab-content" ng-if="activeTab==0">\n <button class="btn btn-default" type="button" ng-click="shutdown()">Shutdown</button>\n <button class="btn btn-default" type="button" ng-click="restart()">Restart</button>\n <br>\n <button class="btn btn-info" type="button" ng-click="reloadConfig()" style="margin-top: 20px">Reload config from\n file\n </button>\n <br>\n <button class="btn btn-info" type="button" ng-click="migrate()" style="margin-top: 20px">Migrate from NZBHydra\n 1\n </button>\n </div>\n\n <div class="system-tab-content" ng-if="activeTab==1">\n <hydraupdates></hydraupdates>\n </div>\n\n <div class="system-tab-content" ng-if="activeTab==2">\n <hydralog></hydralog>\n </div>\n\n <div class="system-tab-content" ng-if="activeTab==3">\n <hydra-tasks></hydra-tasks>\n </div>\n\n <div class="system-tab-content" ng-if="activeTab==4" style="text-align: center">\n <hydrabackup></hydrabackup>\n </div>\n\n <div class="system-tab-content" ng-if="activeTab==5">\n <ng-include src="\'static/html/bugreport.html\'"></ng-include>\n </div>\n\n <div class="system-tab-content" ng-if="activeTab==6" style="text-align: left">\n <hydra-news></hydra-news>\n </div>\n\n <div class="system-tab-content" ng-if="activeTab==7">\n <ng-include src="\'static/html/about.html\'"></ng-include>\n </div>\n</div>\n\n');
|
||||
$templateCache.put('static/html/update-modal.html','\n\n<div class="modal-header">\n <h3 class="modal-title">Update in progress</h3>\n</div>\n<div class="modal-body" style="text-align: left">\n <img src="static/img/spinner.gif" ng-if="!messages"/>\n\n <div ng-if="messages" style="text-align: left">\n\n <ul style="padding-left: 0">\n <li ng-repeat="message in messages" style="list-style-type: none">\n {{message}} <img src="static/img/spinner.gif" ng-show="$last"/>\n </li>\n\n </ul>\n\n </div>\n</div>\n');
|
||||
$templateCache.put('static/html/user-news-modal.html','<div class="modal-header">\n <h3 class="modal-title">{{currentNews.title}}</h3>\n</div>\n<div class="modal-body" style="text-align: left">\n <div ng-bind-html="currentNews.newsAsHtml"></div>\n</div>\n<div class="modal-footer">\n <button class="btn btn-success" type="button" ng-click="dismiss()">OK</button>\n</div>\n');
|
||||
$templateCache.put('static/html/welcome-modal.html','<div class="modal-header">\r\n <h3 class="modal-title">Welcome to NZBHydra 2</h3>\r\n</div>\r\n<div class="modal-body" style="text-align: left">\r\n This seems to be the first time that you started NZBHydra 2.\r\n <br><br>\r\n If you\'re already using NZBHydra 1 (python based) you can <a href="#" ng-click="startMigration()">migrate your\r\n data</a>.\r\n <br><br>\r\n If you\'re a new user (or don\'t want to migrate your data right now) you can start by <a href="#"\r\n ng-click="goToConfig()">configuring\r\n NZBHydra 2</a>.\r\n <br>\r\n You will not be able to use it until you\'ve added at least one indexer.\r\n <br><br>\r\n If you\'re stuck you can refer to <a href="https://github.com/theotherp/nzbhydra2/wiki">the wiki</a> or the online\r\n help (available from the config).<br>\r\n If you haven\'t found an answer there you\'re welcome to <a href="https://github.com/theotherp/nzbhydra2/issues">raise\r\n a GitHub issue</a>.\r\n\r\n</div>\r\n<div class="modal-footer">\r\n <button class="btn btn-success" type="button" ng-click="close()">Close</button>\r\n</div>\r\n');}]);
|
||||
@ -822,6 +822,38 @@ public class NewznabTest {
|
||||
assertThat(uri).contains("imdbid=123");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldUseCustomLimitFromCustomParameters() throws Exception {
|
||||
testee.config.setCustomParameters(Arrays.asList("limit=100"));
|
||||
SearchRequest request = new SearchRequest(SearchSource.INTERNAL, SearchType.SEARCH, 0, 100);
|
||||
String uri = buildCleanedSearchUrl(request, 0, 100).toUriString();
|
||||
assertThat(uri).contains("limit=100");
|
||||
assertThat(uri).doesNotContain("limit=1000");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldUseDefaultLimitWhenNoCustomLimitConfigured() throws Exception {
|
||||
testee.config.setCustomParameters(Arrays.asList("otherparam=value"));
|
||||
SearchRequest request = new SearchRequest(SearchSource.INTERNAL, SearchType.SEARCH, 0, 100);
|
||||
String uri = buildCleanedSearchUrl(request, 0, 100).toUriString();
|
||||
assertThat(uri).contains("limit=100");
|
||||
assertThat(uri).contains("otherparam=value");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldNotDuplicateLimitInCustomParameters() throws Exception {
|
||||
testee.config.setCustomParameters(Arrays.asList("limit=50", "otherparam=value"));
|
||||
SearchRequest request = new SearchRequest(SearchSource.INTERNAL, SearchType.SEARCH, 0, 100);
|
||||
String uri = buildCleanedSearchUrl(request, 0, 100).toUriString();
|
||||
// Should only have one limit parameter with value 50
|
||||
assertThat(uri).contains("limit=50");
|
||||
assertThat(uri).doesNotContain("limit=1000");
|
||||
assertThat(uri).contains("otherparam=value");
|
||||
// Verify limit appears only once
|
||||
int limitCount = uri.split("limit=").length - 1;
|
||||
assertThat(limitCount).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldHandleEmptyNewznabResponseElement() throws Exception {
|
||||
// Simulates empty response element from Bitmagnet:
|
||||
@ -851,4 +883,52 @@ public class NewznabTest {
|
||||
assertThat(indexerSearchResult.isHasMoreResults()).isEqualTo(false);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldParseEffectiveLimitFromCustomParameters() {
|
||||
// With limit parameter
|
||||
testee.config.setCustomParameters(Arrays.asList("limit=250"));
|
||||
Integer effectiveLimit = testee.config.getCustomParameters().stream()
|
||||
.filter(x -> x.toLowerCase().startsWith("limit="))
|
||||
.map(x -> Integer.parseInt(x.split("=")[1]))
|
||||
.findFirst()
|
||||
.orElse(1000);
|
||||
assertThat(effectiveLimit).isEqualTo(250);
|
||||
|
||||
// Without limit parameter - should default to 1000
|
||||
testee.config.setCustomParameters(Arrays.asList("otherparam=value"));
|
||||
effectiveLimit = testee.config.getCustomParameters().stream()
|
||||
.filter(x -> x.toLowerCase().startsWith("limit="))
|
||||
.map(x -> Integer.parseInt(x.split("=")[1]))
|
||||
.findFirst()
|
||||
.orElse(1000);
|
||||
assertThat(effectiveLimit).isEqualTo(1000);
|
||||
|
||||
// With limit parameter (case insensitive)
|
||||
testee.config.setCustomParameters(Arrays.asList("LIMIT=500"));
|
||||
effectiveLimit = testee.config.getCustomParameters().stream()
|
||||
.filter(x -> x.toLowerCase().startsWith("limit="))
|
||||
.map(x -> Integer.parseInt(x.split("=")[1]))
|
||||
.findFirst()
|
||||
.orElse(1000);
|
||||
assertThat(effectiveLimit).isEqualTo(500);
|
||||
|
||||
// With empty custom parameters - should default to 1000
|
||||
testee.config.setCustomParameters(Collections.emptyList());
|
||||
effectiveLimit = testee.config.getCustomParameters().stream()
|
||||
.filter(x -> x.toLowerCase().startsWith("limit="))
|
||||
.map(x -> Integer.parseInt(x.split("=")[1]))
|
||||
.findFirst()
|
||||
.orElse(1000);
|
||||
assertThat(effectiveLimit).isEqualTo(1000);
|
||||
|
||||
// With multiple parameters including limit
|
||||
testee.config.setCustomParameters(Arrays.asList("param1=value1", "limit=75", "param2=value2"));
|
||||
effectiveLimit = testee.config.getCustomParameters().stream()
|
||||
.filter(x -> x.toLowerCase().startsWith("limit="))
|
||||
.map(x -> Integer.parseInt(x.split("=")[1]))
|
||||
.findFirst()
|
||||
.orElse(1000);
|
||||
assertThat(effectiveLimit).isEqualTo(75);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ package org.nzbhydra.indexers.torbox.mapping;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.nzbhydra.Jackson;
|
||||
import org.nzbhydra.downloading.downloaders.torbox.mapping.UsenetListResponse;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
@ -27,7 +28,8 @@ class TorboxMappingTest {
|
||||
assertThat(container.getNzbs()).hasSize(2);
|
||||
assertThat(container.getNzbs().get(0).getTitle()).isEqualTo("The Crazy Android.");
|
||||
|
||||
|
||||
UsenetListResponse usenetListResponse = Jackson.JSON_MAPPER.readValue(getClass().getResource("/org/nzbhydra/mapping/torboxMyDownloads.json"), UsenetListResponse.class);
|
||||
assertThat(usenetListResponse).isNotNull();
|
||||
}
|
||||
|
||||
}
|
||||
163
core/src/test/java/org/nzbhydra/news/UserNewsProviderTest.java
Normal file
163
core/src/test/java/org/nzbhydra/news/UserNewsProviderTest.java
Normal file
@ -0,0 +1,163 @@
|
||||
package org.nzbhydra.news;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
import org.nzbhydra.NzbHydra;
|
||||
import org.nzbhydra.genericstorage.GenericStorage;
|
||||
import org.nzbhydra.news.UserNewsProvider.ShownUserNewsIds;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
public class UserNewsProviderTest {
|
||||
|
||||
@TempDir
|
||||
Path tempDir;
|
||||
|
||||
@Mock
|
||||
private GenericStorage genericStorageMock;
|
||||
|
||||
@InjectMocks
|
||||
private UserNewsProvider testee = new UserNewsProvider();
|
||||
|
||||
@BeforeEach
|
||||
public void setUp() {
|
||||
NzbHydra.setDataFolder(tempDir.toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnEmptyListWhenFileDoesNotExist() {
|
||||
List<UserNewsEntry> news = testee.getAllUserNews();
|
||||
assertThat(news).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnEmptyListWhenFileIsInvalid() throws IOException {
|
||||
Files.writeString(tempDir.resolve("userNews.json"), "invalid json");
|
||||
|
||||
List<UserNewsEntry> news = testee.getAllUserNews();
|
||||
assertThat(news).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldParseValidUserNewsFile() throws IOException {
|
||||
String json = """
|
||||
[
|
||||
{"id": "news1", "title": "Title 1", "body": "Body 1"},
|
||||
{"id": "news2", "title": "Title 2", "body": "## Markdown body"}
|
||||
]
|
||||
""";
|
||||
Files.writeString(tempDir.resolve("userNews.json"), json);
|
||||
|
||||
List<UserNewsEntry> news = testee.getAllUserNews();
|
||||
|
||||
assertThat(news).hasSize(2);
|
||||
assertThat(news.get(0).getId()).isEqualTo("news1");
|
||||
assertThat(news.get(0).getTitle()).isEqualTo("Title 1");
|
||||
assertThat(news.get(0).getBody()).isEqualTo("Body 1");
|
||||
assertThat(news.get(1).getId()).isEqualTo("news2");
|
||||
assertThat(news.get(1).getTitle()).isEqualTo("Title 2");
|
||||
assertThat(news.get(1).getBody()).isEqualTo("## Markdown body");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnOnlyUnreadNews() throws IOException {
|
||||
String json = """
|
||||
[
|
||||
{"id": "news1", "title": "Title 1", "body": "Body 1"},
|
||||
{"id": "news2", "title": "Title 2", "body": "Body 2"},
|
||||
{"id": "news3", "title": "Title 3", "body": "Body 3"}
|
||||
]
|
||||
""";
|
||||
Files.writeString(tempDir.resolve("userNews.json"), json);
|
||||
|
||||
Set<String> shownIds = new HashSet<>();
|
||||
shownIds.add("news1");
|
||||
shownIds.add("news3");
|
||||
when(genericStorageMock.get(eq("shownUserNews-testuser"), eq(ShownUserNewsIds.class)))
|
||||
.thenReturn(Optional.of(new ShownUserNewsIds(shownIds)));
|
||||
|
||||
List<UserNewsEntry> unreadNews = testee.getUnreadUserNewsForUser("testuser");
|
||||
|
||||
assertThat(unreadNews).hasSize(1);
|
||||
assertThat(unreadNews.get(0).getId()).isEqualTo("news2");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnAllNewsWhenNoneShown() throws IOException {
|
||||
String json = """
|
||||
[
|
||||
{"id": "news1", "title": "Title 1", "body": "Body 1"},
|
||||
{"id": "news2", "title": "Title 2", "body": "Body 2"}
|
||||
]
|
||||
""";
|
||||
Files.writeString(tempDir.resolve("userNews.json"), json);
|
||||
|
||||
when(genericStorageMock.get(eq("shownUserNews-testuser"), eq(ShownUserNewsIds.class)))
|
||||
.thenReturn(Optional.empty());
|
||||
|
||||
List<UserNewsEntry> unreadNews = testee.getUnreadUserNewsForUser("testuser");
|
||||
|
||||
assertThat(unreadNews).hasSize(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldMarkNewsAsShown() throws IOException {
|
||||
when(genericStorageMock.get(eq("shownUserNews-testuser"), eq(ShownUserNewsIds.class)))
|
||||
.thenReturn(Optional.empty());
|
||||
|
||||
testee.markNewsAsShownForUser("testuser", "news1");
|
||||
|
||||
verify(genericStorageMock).save(eq("shownUserNews-testuser"), any(ShownUserNewsIds.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldAddToExistingShownNews() throws IOException {
|
||||
Set<String> existingIds = new HashSet<>();
|
||||
existingIds.add("news1");
|
||||
when(genericStorageMock.get(eq("shownUserNews-testuser"), eq(ShownUserNewsIds.class)))
|
||||
.thenReturn(Optional.of(new ShownUserNewsIds(existingIds)));
|
||||
|
||||
testee.markNewsAsShownForUser("testuser", "news2");
|
||||
|
||||
verify(genericStorageMock).save(eq("shownUserNews-testuser"), any(ShownUserNewsIds.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldUseUserSpecificStorageKey() throws IOException {
|
||||
String json = """
|
||||
[{"id": "news1", "title": "Title 1", "body": "Body 1"}]
|
||||
""";
|
||||
Files.writeString(tempDir.resolve("userNews.json"), json);
|
||||
|
||||
Set<String> user1ShownIds = new HashSet<>();
|
||||
user1ShownIds.add("news1");
|
||||
when(genericStorageMock.get(eq("shownUserNews-user1"), eq(ShownUserNewsIds.class)))
|
||||
.thenReturn(Optional.of(new ShownUserNewsIds(user1ShownIds)));
|
||||
when(genericStorageMock.get(eq("shownUserNews-user2"), eq(ShownUserNewsIds.class)))
|
||||
.thenReturn(Optional.empty());
|
||||
|
||||
List<UserNewsEntry> user1News = testee.getUnreadUserNewsForUser("user1");
|
||||
List<UserNewsEntry> user2News = testee.getUnreadUserNewsForUser("user2");
|
||||
|
||||
assertThat(user1News).isEmpty();
|
||||
assertThat(user2News).hasSize(1);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,90 @@
|
||||
package org.nzbhydra.notifications;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Captor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
import org.nzbhydra.config.BaseConfig;
|
||||
import org.nzbhydra.config.ConfigProvider;
|
||||
import org.nzbhydra.config.NotificationConfig;
|
||||
import org.nzbhydra.config.NotificationConfigEntry;
|
||||
import org.nzbhydra.config.indexer.IndexerConfig;
|
||||
import org.nzbhydra.config.notification.NotificationEventType;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
public class NotificationHandlerTest {
|
||||
|
||||
@Mock
|
||||
private ConfigProvider configProvider;
|
||||
@Mock
|
||||
private NotificationRepository notificationRepository;
|
||||
@Captor
|
||||
private ArgumentCaptor<NotificationEntity> notificationEntityCaptor;
|
||||
@InjectMocks
|
||||
private NotificationHandler testee = new NotificationHandler();
|
||||
|
||||
private BaseConfig baseConfig = new BaseConfig();
|
||||
private NotificationConfig notificationConfig = new NotificationConfig();
|
||||
|
||||
@BeforeEach
|
||||
public void setUp() {
|
||||
when(configProvider.getBaseConfig()).thenReturn(baseConfig);
|
||||
baseConfig.setNotificationConfig(notificationConfig);
|
||||
notificationConfig.setAppriseType(NotificationConfig.AppriseType.NONE);
|
||||
notificationConfig.setFilterOuts(Collections.emptyList());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldTruncateBodyTo255Characters() {
|
||||
// Given a long notification body that exceeds 255 characters
|
||||
String longBody = "A".repeat(300);
|
||||
NotificationConfigEntry entry = new NotificationConfigEntry();
|
||||
entry.setEventType(NotificationEventType.INDEXER_DISABLED);
|
||||
entry.setBodyTemplate(longBody);
|
||||
entry.setMessageType(NotificationConfigEntry.MessageType.WARNING);
|
||||
notificationConfig.setEntries(List.of(entry));
|
||||
|
||||
NotificationEvent event = new IndexerDisabledNotificationEvent("test", IndexerConfig.State.ENABLED, "message");
|
||||
|
||||
// When handling the notification
|
||||
testee.handleNotification(event);
|
||||
|
||||
// Then the saved notification body should be truncated to 255 characters
|
||||
verify(notificationRepository).save(notificationEntityCaptor.capture());
|
||||
NotificationEntity savedEntity = notificationEntityCaptor.getValue();
|
||||
assertThat(savedEntity.getBody()).hasSize(255);
|
||||
assertThat(savedEntity.getBody()).isEqualTo("A".repeat(255));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldNotTruncateBodyWhenUnder255Characters() {
|
||||
// Given a notification body under 255 characters
|
||||
String shortBody = "Short notification body";
|
||||
NotificationConfigEntry entry = new NotificationConfigEntry();
|
||||
entry.setEventType(NotificationEventType.INDEXER_DISABLED);
|
||||
entry.setBodyTemplate(shortBody);
|
||||
entry.setMessageType(NotificationConfigEntry.MessageType.INFO);
|
||||
notificationConfig.setEntries(List.of(entry));
|
||||
|
||||
NotificationEvent event = new IndexerDisabledNotificationEvent("test", IndexerConfig.State.ENABLED, "message");
|
||||
|
||||
// When handling the notification
|
||||
testee.handleNotification(event);
|
||||
|
||||
// Then the saved notification body should remain unchanged
|
||||
verify(notificationRepository).save(notificationEntityCaptor.capture());
|
||||
NotificationEntity savedEntity = notificationEntityCaptor.getValue();
|
||||
assertThat(savedEntity.getBody()).isEqualTo(shortBody);
|
||||
}
|
||||
}
|
||||
@ -36,6 +36,7 @@ import org.nzbhydra.searching.dtoseventsenums.SearchResultItem;
|
||||
import org.nzbhydra.searching.searchrequests.InternalData;
|
||||
import org.nzbhydra.searching.searchrequests.SearchRequest;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
@ -95,6 +96,8 @@ public class SearcherUnitTest {
|
||||
private ApplicationEventPublisher applicationEventPublisherMock;
|
||||
@Mock
|
||||
private ConfigProvider configProviderMock;
|
||||
@Mock
|
||||
private TransactionTemplate transactionTemplateMock;
|
||||
private Random random = new Random();
|
||||
|
||||
|
||||
@ -133,6 +136,11 @@ public class SearcherUnitTest {
|
||||
BaseConfig value = new BaseConfig();
|
||||
value.getSearching().setLoadAllCachedOnInternal(false);
|
||||
when(configProviderMock.getBaseConfig()).thenReturn(value);
|
||||
|
||||
doAnswer(invocation -> {
|
||||
invocation.getArgument(0, java.util.function.Consumer.class).accept(null);
|
||||
return null;
|
||||
}).when(transactionTemplateMock).executeWithoutResult(any());
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -0,0 +1,97 @@
|
||||
{
|
||||
"success": true,
|
||||
"error": null,
|
||||
"detail": "Usenet downloads list retrieved successfully.",
|
||||
"data": [
|
||||
{
|
||||
"id": 705346,
|
||||
"created_at": "2026-01-24T04:34:41Z",
|
||||
"updated_at": "2026-01-24T04:34:41Z",
|
||||
"auth_id": "<redacted>",
|
||||
"name": "Extrabasic.S01E01.German.AC3.DL.1080p.BDRip.x265-FuN",
|
||||
"hash": "<redacted>",
|
||||
"download_state": "cached",
|
||||
"download_speed": 0,
|
||||
"original_url": null,
|
||||
"eta": 0,
|
||||
"progress": 1,
|
||||
"size": 961128865,
|
||||
"download_id": "SABnzbd_nzo_kcc_ovp6",
|
||||
"files": [
|
||||
{
|
||||
"id": 0,
|
||||
"md5": null,
|
||||
"hash": "<redacted>",
|
||||
"name": "Extrabasic.S01E01.German.AC3.DL.1080p.BDRip.x265-FuN/Extrabasic.S01E01.German.AC3.DL.1080p.BDRip.x265-FuN.mkv",
|
||||
"size": 961128865,
|
||||
"zipped": false,
|
||||
"s3_path": "<redacted>/Extrabasic.S01E01.German.AC3.DL.1080p.BDRip.x265-FuN/Extrabasic.S01E01.German.AC3.DL.1080p.BDRip.x265-FuN.mkv",
|
||||
"infected": false,
|
||||
"mimetype": "video/x-matroska",
|
||||
"short_name": "Extrabasic.S01E01.German.AC3.DL.1080p.BDRip.x265-FuN.mkv",
|
||||
"absolute_path": "/downloads/<redacted>/Extrabasic.S01E01.German.AC3.DL.1080p.BDRip.x265-FuN/Extrabasic.S01E01.German.AC3.DL.1080p.BDRip.x265-FuN.mkv",
|
||||
"opensubtitles_hash": "adff2f144d853eda"
|
||||
}
|
||||
],
|
||||
"active": false,
|
||||
"cached": true,
|
||||
"download_present": true,
|
||||
"download_finished": true,
|
||||
"expires_at": null,
|
||||
"server": null,
|
||||
"cached_at": "2026-01-21T17:20:51Z"
|
||||
},
|
||||
{
|
||||
"id": 700190,
|
||||
"created_at": "2026-01-23T06:06:42Z",
|
||||
"updated_at": "2026-01-23T06:11:26Z",
|
||||
"auth_id": "<redacted>",
|
||||
"name": "figure of knowledge S01E09 German AC3 DL 1080p BDRip x265-FuN",
|
||||
"hash": "<redacted>",
|
||||
"download_state": "completed",
|
||||
"download_speed": 0,
|
||||
"original_url": null,
|
||||
"eta": 0,
|
||||
"progress": 1,
|
||||
"size": 864769076,
|
||||
"download_id": "SABnzbd_nzo_je4fegy8",
|
||||
"files": [
|
||||
{
|
||||
"id": 1,
|
||||
"md5": null,
|
||||
"hash": "<redacted>",
|
||||
"name": "figure of knowledge S01E09 German AC3 DL 1080p BDRip x265-FuN/House-of-Usenet.com.url",
|
||||
"size": 181,
|
||||
"zipped": false,
|
||||
"s3_path": "<redacted>/figure of knowledge S01E09 German AC3 DL 1080p BDRip x265-FuN/House-of-Usenet.com.url",
|
||||
"infected": false,
|
||||
"mimetype": "application/x-wine-extension-ini",
|
||||
"short_name": "House-of-Usenet.com.url",
|
||||
"absolute_path": "/downloads/<redacted>/figure of knowledge S01E09 German AC3 DL 1080p BDRip x265-FuN/House-of-Usenet.com.url",
|
||||
"opensubtitles_hash": null
|
||||
},
|
||||
{
|
||||
"id": 0,
|
||||
"md5": null,
|
||||
"hash": "<redacted>",
|
||||
"name": "figure of knowledge S01E09 German AC3 DL 1080p BDRip x265-FuN/figure.of.knowledge.S01E09.German.AC3.DL.1080p.BDRip.x265-FuN.mkv",
|
||||
"size": 864768895,
|
||||
"zipped": false,
|
||||
"s3_path": "<redacted>/figure of knowledge S01E09 German AC3 DL 1080p BDRip x265-FuN/figure.of.knowledge.S01E09.German.AC3.DL.1080p.BDRip.x265-FuN.mkv",
|
||||
"infected": false,
|
||||
"mimetype": "video/x-matroska",
|
||||
"short_name": "figure.of.knowledge.S01E09.German.AC3.DL.1080p.BDRip.x265-FuN.mkv",
|
||||
"absolute_path": "/downloads/<redacted>/figure of knowledge S01E09 German AC3 DL 1080p BDRip x265-FuN/figure.of.knowledge.S01E09.German.AC3.DL.1080p.BDRip.x265-FuN.mkv",
|
||||
"opensubtitles_hash": "c6df8f5df6cf8779"
|
||||
}
|
||||
],
|
||||
"active": false,
|
||||
"cached": true,
|
||||
"download_present": true,
|
||||
"download_finished": true,
|
||||
"expires_at": "2026-02-22T06:11:26Z",
|
||||
"server": 107,
|
||||
"cached_at": "2026-01-23T06:11:26Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
9
core/ui-src/html/user-news-modal.html
Normal file
9
core/ui-src/html/user-news-modal.html
Normal file
@ -0,0 +1,9 @@
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title">{{currentNews.title}}</h3>
|
||||
</div>
|
||||
<div class="modal-body" style="text-align: left">
|
||||
<div ng-bind-html="currentNews.newsAsHtml"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-success" type="button" ng-click="dismiss()">OK</button>
|
||||
</div>
|
||||
@ -125,12 +125,9 @@ function ConfigFields($injector) {
|
||||
templateOptions: {
|
||||
type: 'password',
|
||||
label: 'SSL keystore password',
|
||||
required: true,
|
||||
help: 'Requires restart.'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
]
|
||||
},
|
||||
{
|
||||
@ -178,7 +175,7 @@ function ConfigFields($injector) {
|
||||
},
|
||||
{
|
||||
key: 'proxyUsername',
|
||||
type: 'horizontalInput',
|
||||
type: 'passwordSwitch',
|
||||
hideExpression: 'model.proxyType==="NONE"',
|
||||
templateOptions: {
|
||||
type: 'text',
|
||||
@ -1611,6 +1608,16 @@ function ConfigFields($injector) {
|
||||
$scope.to.options = options;
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'overwriteNaWithSearchCategory',
|
||||
type: 'horizontalSwitch',
|
||||
templateOptions: {
|
||||
type: 'switch',
|
||||
label: 'Overwrite N/A with search category',
|
||||
help: "Use search category for items with N/A category",
|
||||
tooltip: 'Some indexers may return N/A as category for a result or the category mapping may have failed. With this option enabled the selected search category will be used.'
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'help',
|
||||
templateOptions: {
|
||||
|
||||
@ -93,7 +93,7 @@ angular
|
||||
template: [
|
||||
'<div class="input-group">',
|
||||
'<input ng-attr-type="{{ hidePassword ? \'password\' : \'text\' }}" class="form-control" ng-model="internalValue"',
|
||||
'ng-attr-placeholder="{{ isUnchangedPassword ? \'Password unchanged\' : \'\' }}"',
|
||||
'ng-attr-placeholder="{{ isUnchangedPassword ? \'Value unchanged\' : \'\' }}"',
|
||||
'ng-change="onPasswordChange()" ng-focus="onPasswordFocus()" ng-blur="onPasswordBlur()"',
|
||||
'ng-required="false"/>', // Disable HTML5 required validation
|
||||
'<span class="input-group-btn input-group-btn2">',
|
||||
|
||||
@ -168,7 +168,7 @@ angular
|
||||
if (model.downloaderType === "SABNZBD" || model.downloaderType === "TORBOX") {
|
||||
fieldset.push({
|
||||
key: 'apiKey',
|
||||
type: 'horizontalInput',
|
||||
type: 'passwordSwitch',
|
||||
showFor: ["SABNZBD", "TORBOX"],
|
||||
templateOptions: {
|
||||
type: 'text',
|
||||
@ -185,7 +185,7 @@ angular
|
||||
} else if (model.downloaderType === "NZBGET") {
|
||||
fieldset.push({
|
||||
key: 'username',
|
||||
type: 'horizontalInput',
|
||||
type: 'passwordSwitch',
|
||||
templateOptions: {
|
||||
type: 'text',
|
||||
label: 'Username'
|
||||
|
||||
@ -140,7 +140,7 @@ function getIndexerBoxFields(indexerModel, parentModel, isInitial, CategoriesSer
|
||||
fieldset.push(
|
||||
{
|
||||
key: 'apiKey',
|
||||
type: 'horizontalInput',
|
||||
type: 'passwordSwitch',
|
||||
templateOptions: {
|
||||
type: 'text',
|
||||
label: 'API Key'
|
||||
@ -183,7 +183,7 @@ function getIndexerBoxFields(indexerModel, parentModel, isInitial, CategoriesSer
|
||||
fieldset.push(
|
||||
{
|
||||
key: 'username',
|
||||
type: 'horizontalInput',
|
||||
type: 'passwordSwitch',
|
||||
templateOptions: {
|
||||
type: 'text',
|
||||
required: false,
|
||||
@ -205,7 +205,7 @@ function getIndexerBoxFields(indexerModel, parentModel, isInitial, CategoriesSer
|
||||
fieldset.push(
|
||||
{
|
||||
key: 'username',
|
||||
type: 'horizontalInput',
|
||||
type: 'passwordSwitch',
|
||||
templateOptions: {
|
||||
type: 'text',
|
||||
required: true,
|
||||
|
||||
@ -239,6 +239,7 @@ function hydraChecksFooter() {
|
||||
welcomeIsBeingShown = false;
|
||||
});
|
||||
} else {
|
||||
_.defer(checkAndShowUserNews);
|
||||
if (HydraAuthService.getUserInfos().maySeeAdmin) {
|
||||
_.defer(checkAndShowNews);
|
||||
_.defer(checkExpiredIndexers);
|
||||
@ -250,6 +251,39 @@ function hydraChecksFooter() {
|
||||
});
|
||||
}
|
||||
|
||||
function checkAndShowUserNews() {
|
||||
RequestsErrorHandler.specificallyHandled(function () {
|
||||
$http.get("internalapi/usernews").then(function (response) {
|
||||
var userNews = response.data;
|
||||
if (userNews && userNews.length > 0) {
|
||||
showUserNewsSequentially(userNews, 0);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function showUserNewsSequentially(userNews, index) {
|
||||
if (index >= userNews.length) {
|
||||
return;
|
||||
}
|
||||
var currentNews = userNews[index];
|
||||
var modalInstance = $uibModal.open({
|
||||
templateUrl: 'static/html/user-news-modal.html',
|
||||
controller: UserNewsModalInstanceCtrl,
|
||||
size: "lg",
|
||||
resolve: {
|
||||
currentNews: function () {
|
||||
return currentNews;
|
||||
}
|
||||
}
|
||||
});
|
||||
modalInstance.result.then(function () {
|
||||
showUserNewsSequentially(userNews, index + 1);
|
||||
}, function () {
|
||||
showUserNewsSequentially(userNews, index + 1);
|
||||
});
|
||||
}
|
||||
|
||||
checkAndShowWelcome();
|
||||
|
||||
function showUnreadNotifications(unreadNotifications, stompClient) {
|
||||
@ -349,3 +383,19 @@ function WelcomeModalInstanceCtrl($scope, $uibModalInstance, $state, MigrationSe
|
||||
$state.go("root.config.main");
|
||||
}
|
||||
}
|
||||
|
||||
angular
|
||||
.module('nzbhydraApp')
|
||||
.controller('UserNewsModalInstanceCtrl', UserNewsModalInstanceCtrl);
|
||||
|
||||
function UserNewsModalInstanceCtrl($scope, $uibModalInstance, $http, currentNews) {
|
||||
$scope.currentNews = currentNews;
|
||||
|
||||
$scope.dismiss = function () {
|
||||
$http.put("internalapi/usernews/" + currentNews.id + "/dismiss").then(function () {
|
||||
$uibModalInstance.close();
|
||||
}, function () {
|
||||
$uibModalInstance.close();
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@ -1,291 +0,0 @@
|
||||
#@formatter:off
|
||||
|
||||
function Exec([scriptblock]$cmd, [string]$errorMessage = "Error executing command: " + $cmd) {
|
||||
& $cmd
|
||||
if ($LastExitCode -ne 0) {
|
||||
git reset --hard
|
||||
throw $errorMessage
|
||||
}
|
||||
}
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$version = $args[0]
|
||||
$nextVersion = $args[1]
|
||||
$dryRun = $args[2]
|
||||
|
||||
if (!$version) {
|
||||
Write-Error "Version is required"
|
||||
exit 1
|
||||
}
|
||||
|
||||
if (!$nextVersion) {
|
||||
Write-Error "Next version is required"
|
||||
exit 1
|
||||
}
|
||||
|
||||
if ($version -eq $nextVersion) {
|
||||
Write-Error "next version $nextVersion must be different from current version $version"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$env:githubReleasesUrl = "https://api.github.com/repos/theotherp/nzbhydra2/releases"
|
||||
|
||||
if ($dryRun -ne "true" -and $dryRun -ne "false") {
|
||||
Write-Error "Dry run must be true or false"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$dryRun = [System.Convert]::ToBoolean($dryRun)
|
||||
|
||||
if ($dryRun) {
|
||||
Write-Host "Dry run is enabled"
|
||||
} else {
|
||||
Write-Host "Dry run is disabled"
|
||||
}
|
||||
|
||||
if (Test-Path "discordtoken.txt") {
|
||||
$discordToken = Get-Content "discordtoken.txt"
|
||||
$env:DISCORD_TOKEN = $discordToken
|
||||
Write-Host "Discord token is set"
|
||||
}
|
||||
|
||||
if (Test-Path "githubtoken.txt") {
|
||||
$githubToken = Get-Content "githubtoken.txt"
|
||||
$env:GITHUB_TOKEN = $githubToken
|
||||
Write-Host "Github token is set"
|
||||
|
||||
$response = Invoke-WebRequest -Uri https://api.github.com -Method Head -Headers @{"Authorization" = "token $githubToken"}
|
||||
|
||||
if ($response.StatusCode -eq 200) {
|
||||
Write-Host "GitHub token seems to be valid - HTTP status code is 200 OK"
|
||||
} else {
|
||||
Write-Error "GitHub token seems to be invalid - HTTP status code is $($response.StatusCode)"
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
if ($discordToken -eq $null) {
|
||||
Write-Error "Discord token is required"
|
||||
exit 1
|
||||
}
|
||||
|
||||
if ($githubToken -eq $null) {
|
||||
Write-Error "Github token is required"
|
||||
exit 1
|
||||
}
|
||||
|
||||
if (!(Test-Path "readme.md")) {
|
||||
Write-Error "Readme.md is required"
|
||||
exit 1
|
||||
}
|
||||
|
||||
if ((git status --porcelain) -ne $null) {
|
||||
Write-Error "Git has untracked or changed files"
|
||||
exit 1
|
||||
}
|
||||
else {
|
||||
Write-Host "Git is clean"
|
||||
}
|
||||
|
||||
$dockerInfo = wsl -d Ubuntu -- sh -c "docker info"
|
||||
if (!$dockerInfo -contains "Docker Root Dir") {
|
||||
Write-Error "Docker is not running in WSL"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$env:Path = "$HOME\.jdks\openjdk-21.0.2\bin\;"+$env:Path
|
||||
$env:JAVA_HOME = "$HOME\.jdks\openjdk-21.0.2"
|
||||
|
||||
|
||||
Write-Host "Setting release version"
|
||||
exec { mvn -q -B versions:set `-DnewVersion="$version" }
|
||||
|
||||
if (-not $?) {
|
||||
Write-Error "Setting release version failed"
|
||||
git reset --hard
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "Checking preconditions"
|
||||
exec { mvn -q -B org.nzbhydra:github-release-plugin:3.0.0:precheck }
|
||||
if (-not $?) {
|
||||
Write-Error "Preconditions failed"
|
||||
git reset --hard
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "Generating changelog"
|
||||
exec { mvn -q -B org.nzbhydra:github-release-plugin:3.0.0:generate-changelog }
|
||||
if (-not $?) {
|
||||
Write-Error "Changing log generation failed"
|
||||
git reset --hard
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "Generating wrapper hashes"
|
||||
exec { mvn -q -B org.nzbhydra:github-release-plugin:3.0.0:generate-wrapper-hashes }
|
||||
if (-not $?) {
|
||||
Write-Error "Wrapper hash generation failed"
|
||||
git reset --hard
|
||||
exit 1
|
||||
}
|
||||
|
||||
|
||||
Write-Host "Making versions effective"
|
||||
exec { mvn -q -B versions:commit }
|
||||
if (-not $?) {
|
||||
Write-Error "Making versions effective failed"
|
||||
git reset --hard
|
||||
exit 1
|
||||
}
|
||||
|
||||
|
||||
Write-Host "Building core jar"
|
||||
exec { mvn -q -pl org.nzbhydra:nzbhydra2,org.nzbhydra:shared,org.nzbhydra:mapping,org.nzbhydra:release-parser,org.nzbhydra:core clean install -B -T 1C `-DskipTests=true}
|
||||
erase .\releases\generic-release\include\*.jar
|
||||
copy .\core\target\*-exec.jar .\releases\generic-release\include\
|
||||
if (-not $?) {
|
||||
Write-Error "Clean install of core failed"
|
||||
git reset --hard
|
||||
exit 1
|
||||
}
|
||||
|
||||
$genericVersion = java -jar releases/generic-release/include/core-$version-exec.jar -version
|
||||
if ($genericVersion -ne $version) {
|
||||
Write-Error "Generic version $version expected but is $genericVersion"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "Building windows executable"
|
||||
try {
|
||||
.\buildCore.cmd
|
||||
copy .\core\target\core.exe .\releases\windows-release\include\
|
||||
copy .\core\target\*.dll .\releases\windows-release\include\
|
||||
} catch {
|
||||
exit 1
|
||||
}
|
||||
|
||||
$windowsVersion = releases/windows-release/include/core.exe -version
|
||||
if ($windowsVersion -ne $version) {
|
||||
Write-Error "Windows version $version expected but is $windowsVersion"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "Building linux amd64 executables"
|
||||
wsl -d Ubuntu -- sh -c ./misc/buildLinuxCore/buildBoth.sh
|
||||
|
||||
$linuxAmd64Version = wsl -d Ubuntu releases/linux-amd64-release/include/executables/core -version
|
||||
if ($linuxAmd64Version -ne $version) {
|
||||
Write-Error "Linux amd64 version $version expected but is $linuxAmd64Version"
|
||||
exit 1
|
||||
}
|
||||
|
||||
#We must ask the build machine because we can't run the binary locally
|
||||
$linuxArm64Version = wsl -d Ubuntu -- sh -c "ssh -i ~/.ssh/oraclecloud.key build@141.147.54.141 /home/build/nzbhydra2/core/target/core -version"
|
||||
if ($linuxArm64Version -ne $version) {
|
||||
Write-Error "Linux arm64 version $version expected but is $linuxArm64Version"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "All required files exist and versions match"
|
||||
|
||||
Write-Host "Building releases ***********************************************************************"
|
||||
exec { mvn -q -pl org.nzbhydra:windows-release,org.nzbhydra:generic-release,org.nzbhydra:linux-amd64-release,org.nzbhydra:linux-arm64-release clean install -T 1C `-DskipTests=true}
|
||||
|
||||
|
||||
#We need to commit and push the source code now so that it's packaged in the release
|
||||
if ($dryRun) {
|
||||
Write-Host "Committing (not really, just dry run) ***********************************************************************"
|
||||
} else {
|
||||
Write-Host "Committing ***********************************************************************"
|
||||
git commit -am "Update to $version"
|
||||
if (-not $?) {
|
||||
Write-Error "Commit failed"
|
||||
git reset --hard
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
Write-Host "Tagging (not really, just dry run) ***********************************************************************"
|
||||
} else {
|
||||
Write-Host "Tagging ***********************************************************************"
|
||||
git tag -a v$version -m v$version
|
||||
if (-not $?) {
|
||||
Write-Error "Tagging failed"
|
||||
git reset --hard
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
Write-Host "Pushing (not really, just dry run) ***********************************************************************"
|
||||
} else {
|
||||
Write-Host "Pushing ***********************************************************************"
|
||||
git push
|
||||
git push origin v$version
|
||||
if (-not $?) {
|
||||
Write-Error "Pushing failed"
|
||||
git reset --hard
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
Write-Host "Releasing to github (not really, just dry run) ***********************************************************************"
|
||||
exec { mvn -B org.nzbhydra:github-release-plugin:3.0.0:release `-DdryRun }
|
||||
|
||||
} else {
|
||||
Write-Host "Releasing to github ***********************************************************************"
|
||||
exec { mvn -B org.nzbhydra:github-release-plugin:3.0.0:release }
|
||||
}
|
||||
if (-not $?) {
|
||||
Write-Error "Releasing to github failed"
|
||||
exit 1
|
||||
}
|
||||
|
||||
|
||||
if ($dryRun) {
|
||||
Write-Host "Publishing to discord (not really, just dry run) ***********************************************************************"
|
||||
exec { java -jar other/discord-releaser/target/discordreleaser-jar-with-dependencies.jar core/src/main/resources/changelog.yaml $version discordtoken.txt true }
|
||||
} else {
|
||||
Write-Host "Publishing to discord ***********************************************************************"
|
||||
exec { java -jar other/discord-releaser/target/discordreleaser-jar-with-dependencies.jar core/src/main/resources/changelog.yaml $version discordtoken.txt false }
|
||||
}
|
||||
if (-not $?) {
|
||||
Write-Error "Publishing to discord failed"
|
||||
Read-Host -Prompt "Press enter to continue"
|
||||
}
|
||||
|
||||
Write-Host "Setting new snapshot version"
|
||||
|
||||
exec { mvn -B versions:set `-DnewVersion="$nextVersion"-SNAPSHOT }
|
||||
|
||||
if (-not $?) {
|
||||
Write-Error "Setting new snapshot version failed"
|
||||
git reset --hard
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "Making snapshot version effective"
|
||||
exec { mvn -B versions:commit }
|
||||
if (-not $?) {
|
||||
Write-Error "Making snapshot version effective failed"
|
||||
git reset --hard
|
||||
exit 1
|
||||
}
|
||||
if ($dryRun) {
|
||||
Write-Host "Committing update to $nextVersion-SNAPSHOT (not really, just dry run) ***********************************************************************"
|
||||
} else {
|
||||
Write-Host "Committing ***********************************************************************"
|
||||
git commit -am "Update to $nextVersion-SNAPSHOT"
|
||||
if (-not $?) {
|
||||
Write-Error "Commit failed"
|
||||
git reset --hard
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Write-Host "Done"
|
||||
@ -1,19 +1,29 @@
|
||||
#!/bin/bash
|
||||
set -e # Exit on any error
|
||||
|
||||
# Prepares and runs the docker container to build the core executable
|
||||
|
||||
if [[ ! -d "${PWD}/core" ]] ; then
|
||||
echo "${PWD}/core not found - you must be in the project main folder"
|
||||
exit
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo Removing old amd64 executable
|
||||
rm -f releases/linux-amd64-release/include/executables/core
|
||||
|
||||
echo Syncing with build directory
|
||||
rsync -ru --delete --exclude "target" --exclude "bower_components" --exclude "node_modules" --exclude ".git" --exclude ".idea" --exclude "results" --exclude "*.db" --exclude "venv*" ${PWD}/ ~/nzbhydra2/
|
||||
rsync -ru --delete --exclude "target" --exclude "bower_components" --exclude "node_modules" --exclude ".git" --exclude ".idea" --exclude "results" --exclude "*.db" --exclude "venv*" ${PWD}/ /home/user/nzbhydra2/
|
||||
|
||||
echo Running build script using docker
|
||||
docker run -v ~/nzbhydra2/:/nzbhydra2:rw -v ~/.m2/repository:/home/sist/.m2/repository:rw --rm hydrabuild:latest
|
||||
if [[ ! -f ~/nzbhydra2/core/target/core ]] ; then
|
||||
echo "core executable does not exist"
|
||||
else
|
||||
cp ~/nzbhydra2/core/target/core ${PWD}/core/target/
|
||||
cp ~/nzbhydra2/core/target/core ${PWD}/releases/linux-amd64-release/include/executables/
|
||||
docker run -v /home/user/nzbhydra2/:/nzbhydra2:rw -v /home/user/.m2/repository:/home/user/.m2/repository:rw --rm hydrabuild:latest
|
||||
|
||||
if [[ ! -f /home/user/nzbhydra2/core/target/core ]] ; then
|
||||
echo "ERROR: core executable does not exist after build"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo Copying executable to target directories
|
||||
cp /home/user/nzbhydra2/core/target/core ${PWD}/core/target/
|
||||
cp /home/user/nzbhydra2/core/target/core ${PWD}/releases/linux-amd64-release/include/executables/
|
||||
|
||||
echo "amd64 build completed successfully"
|
||||
|
||||
@ -1,17 +1,23 @@
|
||||
#!/bin/bash
|
||||
set -e # Exit on any error
|
||||
|
||||
# Prepares and runs the docker container to build the core executable
|
||||
|
||||
if [[ ! -d "${PWD}/core" ]] ; then
|
||||
echo "${PWD}/core not found - you must be in the project main folder"
|
||||
exit
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo Removing old arm64 executable
|
||||
rm -f releases/linux-arm64-release/include/executables/core
|
||||
|
||||
echo Syncing with remote server
|
||||
rsync -e "ssh -i ~/.ssh/oraclecloud.key" -rvu --exclude "target" --exclude "executables/core" --exclude "bower_components" --exclude "node_modules" --exclude ".git" --exclude ".idea" --exclude "results" --exclude "*.db" --exclude "*.zip" --exclude "*.jar" --exclude "*.exe" --exclude "venv*" ${PWD}/ build@141.147.54.141:~/nzbhydra2/ --delete
|
||||
|
||||
echo Running build script on remote server
|
||||
ssh -i ~/.ssh/oraclecloud.key build@141.147.54.141 /home/build/nzbhydra2/misc/buildLinuxCore/arm64/runOnRemoteMachine.sh
|
||||
|
||||
echo Writing file from remote server to ${PWD}/releases/linux-arm64-release/include/executables/
|
||||
scp -i ~/.ssh/oraclecloud.key build@141.147.54.141:/home/build/nzbhydra2/core/target/core ${PWD}/releases/linux-arm64-release/include/executables/
|
||||
echo Copying file from remote server to ${PWD}/releases/linux-arm64-release/include/executables/
|
||||
scp -i ~/.ssh/oraclecloud.key build@141.147.54.141:/home/build/nzbhydra2/core/target/core ${PWD}/releases/linux-arm64-release/include/executables/
|
||||
|
||||
echo "arm64 build completed successfully"
|
||||
12
misc/buildLinuxCore/arm64/getVersion.sh
Normal file
12
misc/buildLinuxCore/arm64/getVersion.sh
Normal file
@ -0,0 +1,12 @@
|
||||
#!/bin/bash
|
||||
set -e # Exit on any error
|
||||
|
||||
# Retrieves the version from the arm64 core executable on the remote machine
|
||||
|
||||
if [[ ! -d "${PWD}/core" ]] ; then
|
||||
echo "${PWD}/core not found - you must be in the project main folder"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Execute version check on remote server
|
||||
ssh -i ~/.ssh/oraclecloud.key build@141.147.54.141 /home/build/nzbhydra2/core/target/core -version
|
||||
@ -1,7 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
rm releases/linux-amd64-release/include/executables/core
|
||||
rm releases/linux-arm64-release/include/executables/core
|
||||
misc/buildLinuxCore/amd64/buildLinuxCore.sh &
|
||||
misc/buildLinuxCore/arm64/buildLinuxCore.sh &
|
||||
wait
|
||||
1269
misc/build_and_release.py
Normal file
1269
misc/build_and_release.py
Normal file
File diff suppressed because it is too large
Load Diff
3
misc/requirements-build.txt
Normal file
3
misc/requirements-build.txt
Normal file
@ -0,0 +1,3 @@
|
||||
# Dependencies for build_and_release.py
|
||||
click>=8.0.0
|
||||
rich>=13.0.0
|
||||
@ -59,7 +59,7 @@
|
||||
<dependency>
|
||||
<groupId>org.nzbhydra</groupId>
|
||||
<artifactId>mapping</artifactId>
|
||||
<version>8.3.0</version>
|
||||
<version>8.4.2-SNAPSHOT</version>
|
||||
<exclusions>
|
||||
<exclusion>
|
||||
<artifactId>jaxb-impl</artifactId>
|
||||
|
||||
2
pom.xml
2
pom.xml
@ -4,7 +4,7 @@
|
||||
<groupId>org.nzbhydra</groupId>
|
||||
<artifactId>nzbhydra2</artifactId>
|
||||
<packaging>pom</packaging>
|
||||
<version>8.3.0</version>
|
||||
<version>8.4.2-SNAPSHOT</version>
|
||||
|
||||
<modules>
|
||||
<module>shared</module>
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
<parent>
|
||||
<groupId>org.nzbhydra</groupId>
|
||||
<artifactId>releases</artifactId>
|
||||
<version>8.3.0</version>
|
||||
<version>8.4.2-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>generic-release</artifactId>
|
||||
@ -15,7 +15,7 @@
|
||||
<dependency>
|
||||
<groupId>org.nzbhydra</groupId>
|
||||
<artifactId>core</artifactId>
|
||||
<version>8.3.0</version>
|
||||
<version>8.4.2-SNAPSHOT</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
<parent>
|
||||
<groupId>org.nzbhydra</groupId>
|
||||
<artifactId>releases</artifactId>
|
||||
<version>8.3.0</version>
|
||||
<version>8.4.2-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>linux-amd64-release</artifactId>
|
||||
@ -15,7 +15,7 @@
|
||||
<dependency>
|
||||
<groupId>org.nzbhydra</groupId>
|
||||
<artifactId>core</artifactId>
|
||||
<version>8.3.0</version>
|
||||
<version>8.4.2-SNAPSHOT</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
<parent>
|
||||
<groupId>org.nzbhydra</groupId>
|
||||
<artifactId>releases</artifactId>
|
||||
<version>8.3.0</version>
|
||||
<version>8.4.2-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>linux-arm64-release</artifactId>
|
||||
@ -15,7 +15,7 @@
|
||||
<dependency>
|
||||
<groupId>org.nzbhydra</groupId>
|
||||
<artifactId>core</artifactId>
|
||||
<version>8.3.0</version>
|
||||
<version>8.4.2-SNAPSHOT</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
|
||||
@ -4,12 +4,12 @@
|
||||
<parent>
|
||||
<groupId>org.nzbhydra</groupId>
|
||||
<artifactId>nzbhydra2</artifactId>
|
||||
<version>8.3.0</version>
|
||||
<version>8.4.2-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>releases</artifactId>
|
||||
<packaging>pom</packaging>
|
||||
<version>8.3.0</version>
|
||||
<version>8.4.2-SNAPSHOT</version>
|
||||
|
||||
<modules>
|
||||
<module>generic-release</module>
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
<parent>
|
||||
<groupId>org.nzbhydra</groupId>
|
||||
<artifactId>releases</artifactId>
|
||||
<version>8.3.0</version>
|
||||
<version>8.4.2-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>windows-release</artifactId>
|
||||
@ -15,7 +15,7 @@
|
||||
<dependency>
|
||||
<groupId>org.nzbhydra</groupId>
|
||||
<artifactId>core</artifactId>
|
||||
<version>8.3.0</version>
|
||||
<version>8.4.2-SNAPSHOT</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
<parent>
|
||||
<groupId>org.nzbhydra</groupId>
|
||||
<artifactId>shared</artifactId>
|
||||
<version>8.3.0</version>
|
||||
<version>8.4.2-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>mapping</artifactId>
|
||||
|
||||
@ -8,6 +8,7 @@ import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.google.common.base.Strings;
|
||||
import lombok.Data;
|
||||
import org.javers.core.metamodel.annotation.DiffIgnore;
|
||||
import org.nzbhydra.config.sensitive.HiddenInUI;
|
||||
import org.nzbhydra.config.sensitive.SensitiveData;
|
||||
import org.nzbhydra.springnative.ReflectionMarker;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
@ -43,8 +44,10 @@ public class MainConfig {
|
||||
private boolean proxyIgnoreLocal = true;
|
||||
private List<String> proxyIgnoreDomains = new ArrayList<>();
|
||||
@SensitiveData
|
||||
@HiddenInUI
|
||||
private String proxyUsername;
|
||||
@SensitiveData
|
||||
@HiddenInUI
|
||||
private String proxyPassword;
|
||||
|
||||
private boolean proxyImages;
|
||||
|
||||
@ -33,7 +33,7 @@ public class CategoriesConfig {
|
||||
private boolean enableCategorySizes = true;
|
||||
private List<Category> categories = new ArrayList<>();
|
||||
private String defaultCategory = "All";
|
||||
|
||||
private boolean overwriteNaWithSearchCategory;
|
||||
|
||||
public void setCategories(List<Category> categories) {
|
||||
categories.sort(Comparator.comparing(Category::getName));
|
||||
|
||||
@ -97,7 +97,6 @@ public class Category {
|
||||
com.google.common.base.Objects.equal(requiredRegex, other.requiredRegex);
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) {
|
||||
|
||||
@ -7,6 +7,7 @@ import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.nzbhydra.config.sensitive.HiddenInUI;
|
||||
import org.nzbhydra.config.sensitive.SensitiveData;
|
||||
import org.nzbhydra.downloading.DownloaderType;
|
||||
import org.nzbhydra.springnative.ReflectionMarker;
|
||||
@ -23,6 +24,7 @@ import java.util.Optional;
|
||||
public class DownloaderConfig {
|
||||
|
||||
@SensitiveData
|
||||
@HiddenInUI
|
||||
private String apiKey;
|
||||
private String defaultCategory;
|
||||
private DownloadType downloadType;
|
||||
@ -34,8 +36,10 @@ public class DownloaderConfig {
|
||||
@SensitiveData
|
||||
private String url;
|
||||
@SensitiveData
|
||||
@HiddenInUI
|
||||
private String username;
|
||||
@SensitiveData
|
||||
@HiddenInUI
|
||||
private String password;
|
||||
private boolean addPaused;
|
||||
|
||||
|
||||
@ -12,6 +12,7 @@ import com.google.common.base.Strings;
|
||||
import lombok.Data;
|
||||
import org.nzbhydra.config.SearchSourceRestriction;
|
||||
import org.nzbhydra.config.mediainfo.MediaIdType;
|
||||
import org.nzbhydra.config.sensitive.HiddenInUI;
|
||||
import org.nzbhydra.config.sensitive.SensitiveData;
|
||||
import org.nzbhydra.mapping.newznab.ActionAttribute;
|
||||
import org.nzbhydra.mapping.newznab.json.JsonPubdateDeserializer;
|
||||
@ -54,6 +55,7 @@ public class IndexerConfig {
|
||||
|
||||
private boolean allCapsChecked;
|
||||
@SensitiveData
|
||||
@HiddenInUI
|
||||
private String apiKey;
|
||||
private String apiPath;
|
||||
@JsonFormat(shape = Shape.STRING)
|
||||
@ -84,6 +86,7 @@ public class IndexerConfig {
|
||||
private Integer minSeeders;
|
||||
private String name;
|
||||
@SensitiveData
|
||||
@HiddenInUI
|
||||
private String password = null;
|
||||
private List<String> customParameters = new ArrayList<>();
|
||||
private boolean preselect = true;
|
||||
@ -96,6 +99,7 @@ public class IndexerConfig {
|
||||
private List<ActionAttribute> supportedSearchTypes = new ArrayList<>();
|
||||
private Integer timeout = null;
|
||||
@SensitiveData
|
||||
@HiddenInUI
|
||||
private String username = null;
|
||||
private String userAgent = null;
|
||||
private String vipExpirationDate;
|
||||
|
||||
@ -0,0 +1,19 @@
|
||||
package org.nzbhydra.config.sensitive;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
/**
|
||||
* Marks a field that should be hidden in the UI.
|
||||
* When loading config for display, fields with this annotation will be replaced
|
||||
* with a placeholder marker (***UNCHANGED***) to prevent exposing sensitive values.
|
||||
* <p>
|
||||
* This is different from @SensitiveData which marks fields for log masking and encryption.
|
||||
* A field can have both annotations if it should be both hidden in UI and masked in logs.
|
||||
*/
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Target(ElementType.FIELD)
|
||||
public @interface HiddenInUI {
|
||||
}
|
||||
@ -17,7 +17,7 @@ public class ChangelogVersionEntry implements Comparable<ChangelogVersionEntry>
|
||||
|
||||
private String version;
|
||||
private String date;
|
||||
private boolean isFinal;
|
||||
private boolean isFinal = true;
|
||||
private List<ChangelogChangeEntry> changes;
|
||||
|
||||
@Override
|
||||
|
||||
@ -0,0 +1,16 @@
|
||||
package org.nzbhydra.news;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.nzbhydra.springnative.ReflectionMarker;
|
||||
|
||||
@Data
|
||||
@ReflectionMarker
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
public class UserNewsEntryForWeb {
|
||||
private String id;
|
||||
private String title;
|
||||
private String newsAsHtml;
|
||||
}
|
||||
@ -4,12 +4,12 @@
|
||||
<parent>
|
||||
<groupId>org.nzbhydra</groupId>
|
||||
<artifactId>nzbhydra2</artifactId>
|
||||
<version>8.3.0</version>
|
||||
<version>8.4.2-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>shared</artifactId>
|
||||
<packaging>pom</packaging>
|
||||
<version>8.3.0</version>
|
||||
<version>8.4.2-SNAPSHOT</version>
|
||||
|
||||
<modules>
|
||||
<module>mapping</module>
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
<parent>
|
||||
<groupId>org.nzbhydra</groupId>
|
||||
<artifactId>shared</artifactId>
|
||||
<version>8.3.0</version>
|
||||
<version>8.4.2-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>release-parser</artifactId>
|
||||
|
||||
@ -69,6 +69,8 @@ class ReleaseParserTest {
|
||||
void shouldParseTSSource() {
|
||||
ReleaseInfo info = parser.parse("Movie.Title.2023.TS.x264-GROUP");
|
||||
assertThat(info.getSource()).isEqualTo(Source.TELESYNC);
|
||||
info = parser.parse(" 69.Days.Later.The.Fuck.Palace.2026.1080p.HDTS.x265.Dual.YG ");
|
||||
assertThat(info.getSource()).isEqualTo(Source.TELESYNC);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
117
tests/concurrent_test.py
Normal file
117
tests/concurrent_test.py
Normal file
@ -0,0 +1,117 @@
|
||||
"""
|
||||
Concurrent load test script using Playwright.
|
||||
Opens 20 browser instances simultaneously to test application responsiveness.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import signal
|
||||
from playwright.async_api import async_playwright
|
||||
|
||||
|
||||
URL = "http://127.0.0.1:5076/?category=All&query=123&mode=search&indexers=Mock1%252CMock2%252CMock3"
|
||||
NUM_INSTANCES = 30
|
||||
|
||||
# Track all open browsers for cleanup
|
||||
open_browsers = []
|
||||
shutdown_event = asyncio.Event()
|
||||
|
||||
|
||||
async def open_page(browser_id: int, playwright, keep_open: float):
|
||||
"""Open a single browser instance and navigate to the URL."""
|
||||
print(f"[Browser {browser_id}] Starting...")
|
||||
|
||||
browser = await playwright.chromium.launch(headless=True)
|
||||
open_browsers.append(browser)
|
||||
context = await browser.new_context()
|
||||
page = await context.new_page()
|
||||
|
||||
try:
|
||||
print(f"[Browser {browser_id}] Navigating to URL...")
|
||||
response = await page.goto(URL, timeout=60000)
|
||||
|
||||
if response:
|
||||
print(f"[Browser {browser_id}] Response status: {response.status}")
|
||||
else:
|
||||
print(f"[Browser {browser_id}] No response received")
|
||||
|
||||
# Wait for the page to be fully loaded
|
||||
await page.wait_for_load_state("networkidle", timeout=60000)
|
||||
print(f"[Browser {browser_id}] Page loaded successfully")
|
||||
|
||||
# Keep the browser open for observation, but check for shutdown
|
||||
try:
|
||||
await asyncio.wait_for(shutdown_event.wait(), timeout=keep_open)
|
||||
except asyncio.TimeoutError:
|
||||
pass # Normal timeout, not a shutdown
|
||||
|
||||
except asyncio.CancelledError:
|
||||
print(f"[Browser {browser_id}] Cancelled")
|
||||
except Exception as e:
|
||||
print(f"[Browser {browser_id}] Error: {type(e).__name__}: {e}")
|
||||
|
||||
finally:
|
||||
if browser in open_browsers:
|
||||
open_browsers.remove(browser)
|
||||
await browser.close()
|
||||
print(f"[Browser {browser_id}] Closed")
|
||||
|
||||
|
||||
async def cleanup_browsers():
|
||||
"""Close all open browsers."""
|
||||
print("\nClosing all browsers...")
|
||||
for browser in list(open_browsers):
|
||||
try:
|
||||
await browser.close()
|
||||
except Exception:
|
||||
pass
|
||||
open_browsers.clear()
|
||||
|
||||
|
||||
async def main(keep_open: float):
|
||||
print(f"Starting concurrent test with {NUM_INSTANCES} browser instances...")
|
||||
print(f"Target URL: {URL}")
|
||||
print(f"Keep open duration: {keep_open}s")
|
||||
print("Press Ctrl+C to close all browsers and exit\n")
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
def signal_handler():
|
||||
print("\nReceived interrupt signal...")
|
||||
shutdown_event.set()
|
||||
|
||||
# Register signal handlers
|
||||
for sig in (signal.SIGINT, signal.SIGTERM):
|
||||
try:
|
||||
loop.add_signal_handler(sig, signal_handler)
|
||||
except NotImplementedError:
|
||||
# Windows doesn't support add_signal_handler
|
||||
signal.signal(sig, lambda s, f: signal_handler())
|
||||
|
||||
try:
|
||||
async with async_playwright() as playwright:
|
||||
# Launch all browsers concurrently
|
||||
tasks = [open_page(i + 1, playwright, keep_open) for i in range(NUM_INSTANCES)]
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
finally:
|
||||
await cleanup_browsers()
|
||||
|
||||
print("\nAll browsers closed. Test complete.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="Concurrent load test using Playwright")
|
||||
parser.add_argument(
|
||||
"--keep-open", "-k",
|
||||
type=float,
|
||||
default=1,
|
||||
help="Seconds to keep browsers open after page load (default: 1)"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
asyncio.run(main(args.keep_open))
|
||||
except KeyboardInterrupt:
|
||||
print("\nInterrupted.")
|
||||
@ -5,7 +5,7 @@
|
||||
<parent>
|
||||
<groupId>org.nzbhydra</groupId>
|
||||
<artifactId>nzbhydra2</artifactId>
|
||||
<version>8.3.0</version>
|
||||
<version>8.4.2-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>tests</artifactId>
|
||||
|
||||
@ -9,7 +9,7 @@
|
||||
<parent>
|
||||
<groupId>org.nzbhydra</groupId>
|
||||
<artifactId>tests</artifactId>
|
||||
<version>8.3.0</version>
|
||||
<version>8.4.2-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<groupId>org.nzbhydra.tests</groupId>
|
||||
@ -87,7 +87,7 @@
|
||||
<dependency>
|
||||
<groupId>org.nzbhydra</groupId>
|
||||
<artifactId>mapping</artifactId>
|
||||
<version>8.3.0</version>
|
||||
<version>8.4.2-SNAPSHOT</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.github.docker-java</groupId>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user