Compare commits

...

35 Commits

Author SHA1 Message Date
TheOtherP
b3490a4cc8 Update to 8.4.2-SNAPSHOT
Some checks failed
system-test / waitForNative (push) Has been cancelled
system-test / buildMockserver (push) Has been cancelled
system-test / runSystemTestsLinux (map[name:core port:5076]) (push) Has been cancelled
system-test / runSystemTestsLinux (map[name:v1Migration port:5077]) (push) Has been cancelled
system-test / runSystemTestsWindows (push) Has been cancelled
2026-02-01 08:55:22 +01:00
TheOtherP
01b0d0e451 Update to 8.4.1 2026-02-01 08:53:12 +01:00
TheOtherP
de2ad9da1b Actually 8.4.1, improve release notes wording 2026-02-01 08:25:21 +01:00
TheOtherP
3f6e72e056 Check free space on startup
Closes #580
2026-02-01 08:07:18 +01:00
TheOtherP
a141b2d111 Remove password requirement for SSL Keystore
Closes #1036
2026-02-01 07:56:31 +01:00
TheOtherP
8908e1c069 Fix path traversal vulnerabilities 2026-02-01 07:46:57 +01:00
TheOtherP
d1e065ccee Hopefully fix Torbox download list retrieval 2026-02-01 07:34:41 +01:00
TheOtherP
384a9a4091 Make changelog entry true by default in updates view
Some checks are pending
system-test / waitForNative (push) Waiting to run
system-test / buildMockserver (push) Waiting to run
system-test / runSystemTestsLinux (map[name:core port:5076]) (push) Blocked by required conditions
system-test / runSystemTestsLinux (map[name:v1Migration port:5077]) (push) Blocked by required conditions
system-test / runSystemTestsWindows (push) Blocked by required conditions
2026-01-31 16:59:51 +01:00
TheOtherP
dfa8b1ecb6 Mark versions as not beta 2026-01-31 16:35:03 +01:00
TheOtherP
9a96e68b48 Fix SearcherUnitTest 2026-01-31 16:29:32 +01:00
TheOtherP
68d435c4e4 Hide more sensitive data like indexer API keys in the UI
Closes #1038
2026-01-31 15:16:39 +01:00
TheOtherP
b08287572e Hide more sensitive data like indexer API keys in the UI
Closes #1038
2026-01-31 15:15:59 +01:00
TheOtherP
47ac446d47 Increase DB and tomcat connection pool, improve lock handling
Closes #1039
2026-01-31 14:40:04 +01:00
TheOtherP
97854cc48f Update to 8.4.1-SNAPSHOT 2026-01-31 13:23:43 +01:00
TheOtherP
03185b0d6b Update to 8.4.0 2026-01-31 13:20:32 +01:00
TheOtherP
7828fbdfcc n/a overwrite is actually a feature 2026-01-31 13:09:47 +01:00
TheOtherP
67a3dea758 Add additionalStatic feature 2026-01-31 13:07:30 +01:00
TheOtherP
4f5b198e6a Revert to 8.3.1-SNAPSHOT 2026-01-31 12:45:13 +01:00
TheOtherP
a9f4653c4a Use version 8.4.0 2026-01-31 12:35:20 +01:00
TheOtherP
7857268823 Revert to 8.3.0 2026-01-31 12:34:55 +01:00
TheOtherP
ab34100a24 Do not git commit on dry run mode 2026-01-31 12:34:05 +01:00
TheOtherP
f3e961197d Reset changes on build errors 2026-01-31 12:32:35 +01:00
TheOtherP
8b1cecda44 Update to 8.3.1 2026-01-31 12:29:20 +01:00
TheOtherP
9a58e4d558 Run core on arm64 cloud to retrieve version 2026-01-31 12:18:42 +01:00
TheOtherP
1673d4d1c2 Use absolute path in buildLinuxCore.sh 2026-01-31 11:57:18 +01:00
TheOtherP
dec8a3d5be Fix tests 2026-01-31 11:57:05 +01:00
TheOtherP
e6ec142c09 Option to overwrite result category N/A with the category of the search 2026-01-31 11:42:31 +01:00
TheOtherP
8dbfec3309 Option to overwrite result category N/A with the category of the search 2026-01-31 11:36:29 +01:00
TheOtherP
5a13abfde0 Do not overwrite custom limit for newznab 2026-01-31 09:32:11 +01:00
TheOtherP
e70f1edc4b Show user news
Some checks are pending
system-test / waitForNative (push) Waiting to run
system-test / buildMockserver (push) Waiting to run
system-test / runSystemTestsLinux (map[name:core port:5076]) (push) Blocked by required conditions
system-test / runSystemTestsLinux (map[name:v1Migration port:5077]) (push) Blocked by required conditions
system-test / runSystemTestsWindows (push) Blocked by required conditions
2026-01-30 19:47:09 +01:00
TheOtherP
d913548b74 Truncate notifications when saving to db
Closes #1040
2026-01-30 15:09:26 +01:00
TheOtherP
d72623435a Truncate notifications when saving to db
Some checks are pending
system-test / waitForNative (push) Waiting to run
system-test / buildMockserver (push) Waiting to run
system-test / runSystemTestsLinux (map[name:core port:5076]) (push) Blocked by required conditions
system-test / runSystemTestsLinux (map[name:v1Migration port:5077]) (push) Blocked by required conditions
system-test / runSystemTestsWindows (push) Blocked by required conditions
Closes #1040
2026-01-30 14:55:29 +01:00
TheOtherP
440d337415 Test HDTS parsing 2026-01-19 17:01:26 +01:00
TheOtherP
285adaaadf Convert build script to python
Some checks failed
system-test / waitForNative (push) Has been cancelled
system-test / buildMockserver (push) Has been cancelled
system-test / runSystemTestsLinux (map[name:core port:5076]) (push) Has been cancelled
system-test / runSystemTestsLinux (map[name:v1Migration port:5077]) (push) Has been cancelled
system-test / runSystemTestsWindows (push) Has been cancelled
2026-01-17 21:24:49 +01:00
TheOtherP
dad346379c Update to 8.3.1-SNAPSHOT 2026-01-16 17:35:31 +01:00
74 changed files with 2701 additions and 424 deletions

View File

@ -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
View File

@ -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

View File

@ -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
View 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

View File

@ -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.

View File

@ -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) -->

View File

@ -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;
}

View File

@ -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));
}
}

View File

@ -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

View File

@ -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;
}

View File

@ -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);
}

View 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;

View File

@ -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;

View File

@ -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;
}

View File

@ -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");
}

View File

@ -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
*

View File

@ -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]);
});
}

View File

@ -70,4 +70,5 @@ public class NewznabCategoryComputer {
searchResultItem.setCategory(categoryProvider.getNotAvailable());
}
}
}

View File

@ -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();
}
}

View 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;
}

View 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;
}
}
}

View File

@ -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");

View File

@ -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);

View File

@ -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;

View File

@ -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);
}
}

View File

@ -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);

View File

@ -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())

View File

@ -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:

View File

@ -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

View File

@ -14,6 +14,7 @@ auth:
users: []
categoriesConfig:
enableCategorySizes: true
overwriteNaWithSearchCategory: true
defaultCategory: "All"
categories:
- name: Anime

View File

@ -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

View File

@ -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');}]);

View File

@ -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);
}
}

View File

@ -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();
}
}

View 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);
}
}

View File

@ -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);
}
}

View File

@ -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());
}

View File

@ -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"
}
]
}

View 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>

View File

@ -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: {

View File

@ -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">',

View File

@ -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'

View File

@ -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,

View File

@ -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();
});
};
}

View File

@ -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"

View File

@ -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"

View File

@ -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"

View 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

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,3 @@
# Dependencies for build_and_release.py
click>=8.0.0
rich>=13.0.0

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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;

View File

@ -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));

View File

@ -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) {

View File

@ -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;

View File

@ -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;

View File

@ -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 {
}

View File

@ -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

View 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 UserNewsEntryForWeb {
private String id;
private String title;
private String newsAsHtml;
}

View File

@ -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>

View File

@ -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>

View File

@ -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
View 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.")

View File

@ -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>

View File

@ -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>