Merge branch 'beta' of github.com:sirrobot01/decypharr into beta

This commit is contained in:
Mukhtar Akere
2025-10-22 16:52:04 +01:00
25 changed files with 529 additions and 74 deletions

View File

@@ -18,6 +18,15 @@ jobs:
with:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.24'
cache: true
- name: Run tests
run: go test -v ./...
- name: Calculate beta version
id: calculate_version
run: |

View File

@@ -19,6 +19,15 @@ jobs:
with:
fetch-depth: 1
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.24'
cache: true
- name: Run tests
run: go test -v ./...
- name: Get tag name
id: get_tag
run: |

2
.gitignore vendored
View File

@@ -3,7 +3,9 @@ config.json
.idea/
.DS_Store
*.torrent
!testdata/*.torrent
*.magnet
!testdata/*.magnet
*.db
*.log
*.log.*

View File

@@ -0,0 +1,117 @@
# Private Tracker Downloads
It is against the rules of most private trackers to download using debrid services. That's because debrid services do not seed back.
Despite that, **many torrents from private trackers are cached on debrid services**.
This can happen if the exact same torrent is uploaded to a public tracker or if another user downloads the torrent from the private tracker using their debrid account.
However, you do **_NOT_** want to be the first person who downloads and caches the private tracker torrent because it is a very quick way to get your private tracker account banned.
Fortunately, decypharr offers a feature that allows you to check whether a private tracker torrent has _already_ been cached.
In a way, this feature lets you use your private trackers to find hashes for the latest releases that have not yet been indexed by zilean, torrentio, and other debrid-focused indexers.
This allows you to add private tracker torrents to your debrid account without breaking the most common private tracker rules. This significantly reduces the chance of account bans, **but please read the `Risks` section below** for more details and other precautions you should make.
## Risks
A lot of care has gone into ensuring this feature is compliant with most private tracker rules:
- The passkey is not leaked
- The private tracker announce URLs are not leaked
- The private tracker swarm is not leaked
- Even the torrent content is not leaked (by you)
You are merely downloading it from another source. It's not much different than downloading a torrent that has been uploaded to MegaUpload or another file hoster.
**But it is NOT completely risk-free.**
### Suspicious-looking activity
To use this feature, you must download the `.torrent` file from the private tracker. But since you will never leech the content, it can make your account look suspicious.
In fact, there is a strictly forbidden technique called `ghostleeching` that also requires downloading of the `.torrent` file, and tracker admins might suspect that this is what you are doing.
We know of one user who got banned from a Unit3D-based tracker for this.
**Here is what is recommended:**
- Be a good private tracker user in general. Perma-seed, upload, contribute
- Only enable `Interactive Search` in the arrs (disable `Automatic Search`)
- Only use it for content that is not on public sources yet, and you need to watch **RIGHT NOW** without having time to wait for the download to finish
- Do **NOT** use it to avoid seeding
### Accidentally disable this feature
Another big risk is that you might accidentally disable the feature. The consequence will be that you actually leech the torrent from the tracker, don't seed it, and expose the private swarm to an untrusted third party.
You should avoid this at all costs.
Therefore, to reduce the risk further, it is recommended to enable the feature using both methods:
1. Using the global `Always Remove Tracker URLs` setting in your decypharr `config.json`
2. And by enabling the `First and Last First` setting in Radarr / Sonarr
This way, if one of them gets disabled, you have another backup.
## How to enable this feature
### Always Remove Tracker URLs
- In the web UI under `Settings -> QBitTorrent -> Always Remove Tracker URLs`
- Or in your `config.json` by setting the `qbittorrent.always_rm_tracker_url` to `true`
This ensures that the Tracker URLs are removed from **ALL torrents** (regardless of whether they are public, private, or how they were added).
But this can make downloads of uncached torrents slower or stall because the tracker helps the client find peers to download from.
If the torrent file has no tracker URLs, the torrent client can try to find peers for public torrents using [DHT](https://en.wikipedia.org/wiki/Mainline_DHT). However, this may be less efficient than connecting to a tracker, and the downloads may be slower or stall.
If you only download cached torrents, there is no further downside to enabling this option.
### Only on specific Arr-app clients and indexers
Alternatively, you can toggle it only for specific download clients and indexers in the Arr-apps...
- Enable `Show Advanced Settings` in your Arr app
- Add a new download client in `Settings -> Download Clients` and call it something like `Decypharr (Private)`
- Enable the `First and Last First` checkbox, which will tell Decypharr to remove the tracker URLs
- Add a duplicate version of your private tracker indexer for Decypharr downloads
- Untick `Enable Automatic Search`
- Tick `Enable Interactive Search`
- Set `Download Client` to your new `Decypharr (Private)` client (requires `Show Advanced Settings`)
If you are using Prowlarr to sync your indexers, you can't set the `Download Client` in Prowlarr. You must update it directly in your Arr-apps after the indexers get synced. But future updates to the indexers won't reset the setting.
### Test it
After enabling the feature, try adding a [public torrent](https://ubuntu.com/download/alternative-downloads) through the Decypharr UI and a **public torrent** through your Arr-apps.
Then check the decypharr log to check for a log entry like...
```log
Removed 2 tracker URLs from torrent file
```
If you see this log entry, it means the tracker URLs are being stripped from your torrents and you can safely enable it on private tracker indexers.
## How it works
When you add a new torrent through the QBitTorrent API or through the Web UI, decypharr converts your torrent into a magnet link and then uses your debrid service's API to download that magnet link.
The torrent magnet link contains:
1. The `info hash` that uniquely identifies the torrent, files, and file names
2. The torrent name
3. The URLs of the tracker to connect to
Private tracker URLs in torrents contain a `passkey`. This is a unique identifier that ties the torrent file to your private tracker account.
Only if the `passkey` is valid will the tracker allow the torrent client to connect and download the files. This is also how private torrent trackers measure your downloads and uploads.
The `Remove Tracker URLs` feature removes all the tracker URLs (which include your private `passkey`). This means when decypharr attempts to download the torrent, it only passes the `info hash` and torrent name to the debrid service.
Without the tracker URLs, your debrid service has no way to connect to the private tracker to download the files, and your `passkey` and the private torrent tracker swarm are not exposed.
**But if the torrent is already cached, it's immediately added to your account.**

View File

@@ -21,6 +21,7 @@ If it's the first time you're accessing the UI, you will be prompted to set up y
- Click on **Qbittorrent** in the tab
- Set the **Download Folder** to where you want Decypharr to save downloaded files. These files will be symlinked to the mount folder you configured earlier.
- Set **Always Remove Tracker URLs** if you want to always remove the tracker URLs torrents and magnet links. This is useful if you want to [download private tracker torrents](features/private-tracker-downloads.md) without breaking the rules, but will make uncached torrents always stall.
You can leave the remaining settings as default for now.
### Arrs Configuration
@@ -42,6 +43,7 @@ To connect Decypharr to your Sonarr or Radarr instance:
- **Category**: e.g., `sonarr`, `radarr` (match what you configured in Decypharr)
- **Use SSL**: `No`
- **Sequential Download**: `No` or `Yes` (if you want to download torrents locally instead of symlink)
- **First and Last First**: `No` by default or `Yes` if you want to remove torrent tracker URLs from the torrents. This can make it possible to [download private trackers torrents without breaking the rules](features/private-tracker-downloads.md).
3. Click **Test** to verify the connection
4. Click **Save** to add the download client

View File

@@ -66,6 +66,7 @@ nav:
- Features:
- Overview: features/index.md
- Repair Worker: features/repair-worker.md
- Private Tracker Downloads: features/private-tracker-downloads.md
- Guides:
- Overview: guides/index.md
- Manual Downloading: guides/downloading.md

View File

@@ -49,14 +49,15 @@ type Debrid struct {
}
type QBitTorrent struct {
Username string `json:"username,omitempty"`
Password string `json:"password,omitempty"`
Port string `json:"port,omitempty"` // deprecated
DownloadFolder string `json:"download_folder,omitempty"`
Categories []string `json:"categories,omitempty"`
RefreshInterval int `json:"refresh_interval,omitempty"`
SkipPreCache bool `json:"skip_pre_cache,omitempty"`
MaxDownloads int `json:"max_downloads,omitempty"`
Username string `json:"username,omitempty"`
Password string `json:"password,omitempty"`
Port string `json:"port,omitempty"` // deprecated
DownloadFolder string `json:"download_folder,omitempty"`
Categories []string `json:"categories,omitempty"`
RefreshInterval int `json:"refresh_interval,omitempty"`
SkipPreCache bool `json:"skip_pre_cache,omitempty"`
MaxDownloads int `json:"max_downloads,omitempty"`
AlwaysRmTrackerUrls bool `json:"always_rm_tracker_urls,omitempty"`
}
type Arr struct {

View File

@@ -0,0 +1,45 @@
package testutil
import (
"os"
"path/filepath"
"strings"
)
// GetTestDataPath returns the path to the testdata directory in the project root
func GetTestDataPath() string {
return filepath.Join("..", "..", "testdata")
}
// GetTestDataFilePath returns the path to a specific file in the testdata directory
func GetTestDataFilePath(filename string) string {
return filepath.Join(GetTestDataPath(), filename)
}
// GetTestTorrentPath returns the path to the Ubuntu test torrent file
func GetTestTorrentPath() string {
return GetTestDataFilePath("ubuntu-25.04-desktop-amd64.iso.torrent")
}
// GetTestMagnetPath returns the path to the Ubuntu test magnet file
func GetTestMagnetPath() string {
return GetTestDataFilePath("ubuntu-25.04-desktop-amd64.iso.magnet")
}
// GetTestDataBytes reads and returns the raw bytes of a test data file
func GetTestDataBytes(filename string) ([]byte, error) {
filePath := GetTestDataFilePath(filename)
return os.ReadFile(filePath)
}
// GetTestDataContent reads and returns the content of a test data file
func GetTestDataContent(filename string) (string, error) {
content, err := GetTestDataBytes(filename)
return strings.TrimSpace(string(content)), err
}
// GetTestMagnetContent reads and returns the content of the Ubuntu test magnet file
func GetTestMagnetContent() (string, error) {
return GetTestDataContent("ubuntu-25.04-desktop-amd64.iso.magnet")
}

View File

@@ -7,8 +7,6 @@ import (
"encoding/base32"
"encoding/hex"
"fmt"
"github.com/anacrolix/torrent/metainfo"
"github.com/sirrobot01/decypharr/internal/request"
"io"
"log"
"net/http"
@@ -18,6 +16,9 @@ import (
"regexp"
"strings"
"time"
"github.com/anacrolix/torrent/metainfo"
"github.com/sirrobot01/decypharr/internal/request"
)
var (
@@ -36,7 +37,17 @@ func (m *Magnet) IsTorrent() bool {
return m.File != nil
}
func GetMagnetFromFile(file io.Reader, filePath string) (*Magnet, error) {
// stripTrackersFromMagnet removes trackers from a magnet and returns a modified copy
func stripTrackersFromMagnet(mi metainfo.Magnet, fileType string) metainfo.Magnet {
originalTrackerCount := len(mi.Trackers)
if len(mi.Trackers) > 0 {
mi.Trackers = nil
log.Printf("Removed %d tracker URLs from %s", originalTrackerCount, fileType)
}
return mi
}
func GetMagnetFromFile(file io.Reader, filePath string, rmTrackerUrls bool) (*Magnet, error) {
var (
m *Magnet
err error
@@ -46,14 +57,14 @@ func GetMagnetFromFile(file io.Reader, filePath string) (*Magnet, error) {
if err != nil {
return nil, err
}
m, err = GetMagnetFromBytes(torrentData)
m, err = GetMagnetFromBytes(torrentData, rmTrackerUrls)
if err != nil {
return nil, err
}
} else {
// .magnet file
magnetLink := ReadMagnetFile(file)
m, err = GetMagnetInfo(magnetLink)
m, err = GetMagnetInfo(magnetLink, rmTrackerUrls)
if err != nil {
return nil, err
}
@@ -62,32 +73,37 @@ func GetMagnetFromFile(file io.Reader, filePath string) (*Magnet, error) {
return m, nil
}
func GetMagnetFromUrl(url string) (*Magnet, error) {
func GetMagnetFromUrl(url string, rmTrackerUrls bool) (*Magnet, error) {
if strings.HasPrefix(url, "magnet:") {
return GetMagnetInfo(url)
return GetMagnetInfo(url, rmTrackerUrls)
} else if strings.HasPrefix(url, "http") {
return OpenMagnetHttpURL(url)
return OpenMagnetHttpURL(url, rmTrackerUrls)
}
return nil, fmt.Errorf("invalid url")
}
func GetMagnetFromBytes(torrentData []byte) (*Magnet, error) {
func GetMagnetFromBytes(torrentData []byte, rmTrackerUrls bool) (*Magnet, error) {
// Create a scanner to read the file line by line
mi, err := metainfo.Load(bytes.NewReader(torrentData))
if err != nil {
return nil, err
}
hash := mi.HashInfoBytes()
infoHash := hash.HexString()
info, err := mi.UnmarshalInfo()
if err != nil {
return nil, err
}
magnetMeta := mi.Magnet(&hash, &info)
if rmTrackerUrls {
magnetMeta = stripTrackersFromMagnet(magnetMeta, "torrent file")
}
magnet := &Magnet{
InfoHash: infoHash,
Name: info.Name,
Size: info.Length,
Link: mi.Magnet(&hash, &info).String(),
Link: magnetMeta.String(),
File: torrentData,
}
return magnet, nil
@@ -124,7 +140,7 @@ func ReadMagnetFile(file io.Reader) string {
return ""
}
func OpenMagnetHttpURL(magnetLink string) (*Magnet, error) {
func OpenMagnetHttpURL(magnetLink string, rmTrackerUrls bool) (*Magnet, error) {
resp, err := http.Get(magnetLink)
if err != nil {
return nil, fmt.Errorf("error making GET request: %v", err)
@@ -139,34 +155,35 @@ func OpenMagnetHttpURL(magnetLink string) (*Magnet, error) {
if err != nil {
return nil, fmt.Errorf("error reading response body: %v", err)
}
return GetMagnetFromBytes(torrentData)
return GetMagnetFromBytes(torrentData, rmTrackerUrls)
}
func GetMagnetInfo(magnetLink string) (*Magnet, error) {
func GetMagnetInfo(magnetLink string, rmTrackerUrls bool) (*Magnet, error) {
if magnetLink == "" {
return nil, fmt.Errorf("error getting magnet from file")
}
magnetURI, err := url.Parse(magnetLink)
mi, err := metainfo.ParseMagnetUri(magnetLink)
if err != nil {
return nil, fmt.Errorf("error parsing magnet link")
return nil, fmt.Errorf("error parsing magnet link: %w", err)
}
query := magnetURI.Query()
xt := query.Get("xt")
dn := query.Get("dn")
// Extract BTIH
parts := strings.Split(xt, ":")
btih := ""
if len(parts) > 2 {
btih = parts[2]
// Strip all announce URLs if requested
if rmTrackerUrls {
mi = stripTrackersFromMagnet(mi, "magnet link")
}
btih := mi.InfoHash.HexString()
dn := mi.DisplayName
// Reconstruct the magnet link using the (possibly modified) spec
finalLink := mi.String()
magnet := &Magnet{
InfoHash: btih,
Name: dn,
Size: 0,
Link: magnetLink,
Link: finalLink,
}
return magnet, nil
}

View File

@@ -0,0 +1,198 @@
package utils
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/sirrobot01/decypharr/internal/testutil"
)
// checkMagnet is a helper function that verifies magnet properties
func checkMagnet(t *testing.T, magnet *Magnet, expectedInfoHash, expectedName, expectedLink string, expectedTrackerCount int, shouldBeTorrent bool) {
t.Helper() // This marks the function as a test helper
// Verify basic properties
if magnet.Name != expectedName {
t.Errorf("Expected name '%s', got '%s'", expectedName, magnet.Name)
}
if magnet.InfoHash != expectedInfoHash {
t.Errorf("Expected InfoHash '%s', got '%s'", expectedInfoHash, magnet.InfoHash)
}
if magnet.Link != expectedLink {
t.Errorf("Expected Link '%s', got '%s'", expectedLink, magnet.Link)
}
// Verify the magnet link contains the essential info hash
if !strings.Contains(magnet.Link, "xt=urn:btih:"+expectedInfoHash) {
t.Error("Magnet link should contain info hash")
}
// Verify tracker count
trCount := strings.Count(magnet.Link, "tr=")
if trCount != expectedTrackerCount {
t.Errorf("Expected %d tracker URLs, got %d", expectedTrackerCount, trCount)
}
}
// testMagnetFromFile is a helper function for tests that use GetMagnetFromFile with file operations
func testMagnetFromFile(t *testing.T, filePath string, rmTrackerUrls bool, expectedInfoHash, expectedName, expectedLink string, expectedTrackerCount int) {
t.Helper()
file, err := os.Open(filePath)
if err != nil {
t.Fatalf("Failed to open torrent file %s: %v", filePath, err)
}
defer file.Close()
magnet, err := GetMagnetFromFile(file, filepath.Base(filePath), rmTrackerUrls)
if err != nil {
t.Fatalf("GetMagnetFromFile failed: %v", err)
}
checkMagnet(t, magnet, expectedInfoHash, expectedName, expectedLink, expectedTrackerCount, true)
// Log the result
if rmTrackerUrls {
t.Logf("Generated clean magnet link: %s", magnet.Link)
} else {
t.Logf("Generated magnet link with trackers: %s", magnet.Link)
}
}
func TestGetMagnetFromFile_RealTorrentFile_StripTrue(t *testing.T) {
expectedInfoHash := "8a19577fb5f690970ca43a57ff1011ae202244b8"
expectedName := "ubuntu-25.04-desktop-amd64.iso"
expectedLink := "magnet:?xt=urn:btih:8a19577fb5f690970ca43a57ff1011ae202244b8&dn=ubuntu-25.04-desktop-amd64.iso"
expectedTrackerCount := 0 // Should be 0 when stripping trackers
torrentPath := testutil.GetTestTorrentPath()
testMagnetFromFile(t, torrentPath, true, expectedInfoHash, expectedName, expectedLink, expectedTrackerCount)
}
func TestGetMagnetFromFile_RealTorrentFile_StripFalse(t *testing.T) {
expectedInfoHash := "8a19577fb5f690970ca43a57ff1011ae202244b8"
expectedName := "ubuntu-25.04-desktop-amd64.iso"
expectedLink := "magnet:?xt=urn:btih:8a19577fb5f690970ca43a57ff1011ae202244b8&dn=ubuntu-25.04-desktop-amd64.iso&tr=https%3A%2F%2Ftorrent.ubuntu.com%2Fannounce&tr=https%3A%2F%2Fipv6.torrent.ubuntu.com%2Fannounce"
expectedTrackerCount := 2 // Should be 2 when preserving trackers
torrentPath := testutil.GetTestTorrentPath()
testMagnetFromFile(t, torrentPath, false, expectedInfoHash, expectedName, expectedLink, expectedTrackerCount)
}
func TestGetMagnetFromFile_MagnetFile_StripTrue(t *testing.T) {
expectedInfoHash := "8a19577fb5f690970ca43a57ff1011ae202244b8"
expectedName := "ubuntu-25.04-desktop-amd64.iso"
expectedLink := "magnet:?xt=urn:btih:8a19577fb5f690970ca43a57ff1011ae202244b8&dn=ubuntu-25.04-desktop-amd64.iso"
expectedTrackerCount := 0 // Should be 0 when stripping trackers
torrentPath := testutil.GetTestMagnetPath()
testMagnetFromFile(t, torrentPath, true, expectedInfoHash, expectedName, expectedLink, expectedTrackerCount)
}
func TestGetMagnetFromFile_MagnetFile_StripFalse(t *testing.T) {
expectedInfoHash := "8a19577fb5f690970ca43a57ff1011ae202244b8"
expectedName := "ubuntu-25.04-desktop-amd64.iso"
expectedLink := "magnet:?xt=urn:btih:8a19577fb5f690970ca43a57ff1011ae202244b8&dn=ubuntu-25.04-desktop-amd64.iso&tr=https%3A%2F%2Fipv6.torrent.ubuntu.com%2Fannounce&tr=https%3A%2F%2Ftorrent.ubuntu.com%2Fannounce"
expectedTrackerCount := 2
torrentPath := testutil.GetTestMagnetPath()
testMagnetFromFile(t, torrentPath, false, expectedInfoHash, expectedName, expectedLink, expectedTrackerCount)
}
func TestGetMagnetFromUrl_MagnetLink_StripTrue(t *testing.T) {
expectedInfoHash := "8a19577fb5f690970ca43a57ff1011ae202244b8"
expectedName := "ubuntu-25.04-desktop-amd64.iso"
expectedLink := "magnet:?xt=urn:btih:8a19577fb5f690970ca43a57ff1011ae202244b8&dn=ubuntu-25.04-desktop-amd64.iso"
expectedTrackerCount := 0
// Load the magnet URL from the test file
magnetUrl, err := testutil.GetTestMagnetContent()
if err != nil {
t.Fatalf("Failed to load magnet URL from test file: %v", err)
}
magnet, err := GetMagnetFromUrl(magnetUrl, true)
if err != nil {
t.Fatalf("GetMagnetFromUrl failed: %v", err)
}
checkMagnet(t, magnet, expectedInfoHash, expectedName, expectedLink, expectedTrackerCount, false)
t.Logf("Generated clean magnet link: %s", magnet.Link)
}
func TestGetMagnetFromUrl_MagnetLink_StripFalse(t *testing.T) {
expectedInfoHash := "8a19577fb5f690970ca43a57ff1011ae202244b8"
expectedName := "ubuntu-25.04-desktop-amd64.iso"
expectedLink := "magnet:?xt=urn:btih:8a19577fb5f690970ca43a57ff1011ae202244b8&dn=ubuntu-25.04-desktop-amd64.iso&tr=https%3A%2F%2Fipv6.torrent.ubuntu.com%2Fannounce&tr=https%3A%2F%2Ftorrent.ubuntu.com%2Fannounce"
expectedTrackerCount := 2
// Load the magnet URL from the test file
magnetUrl, err := testutil.GetTestMagnetContent()
if err != nil {
t.Fatalf("Failed to load magnet URL from test file: %v", err)
}
magnet, err := GetMagnetFromUrl(magnetUrl, false)
if err != nil {
t.Fatalf("GetMagnetFromUrl failed: %v", err)
}
checkMagnet(t, magnet, expectedInfoHash, expectedName, expectedLink, expectedTrackerCount, false)
t.Logf("Generated magnet link with trackers: %s", magnet.Link)
}
// testMagnetFromHttpTorrent is a helper function for tests that use GetMagnetFromUrl with HTTP torrent links
func testMagnetFromHttpTorrent(t *testing.T, torrentPath string, rmTrackerUrls bool, expectedInfoHash, expectedName, expectedLink string, expectedTrackerCount int) {
t.Helper()
// Read the torrent file content
torrentData, err := testutil.GetTestDataBytes(torrentPath)
if err != nil {
t.Fatalf("Failed to read torrent file: %v", err)
}
// Create a test HTTP server that serves the torrent file
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/x-bittorrent")
w.Write(torrentData)
}))
defer server.Close()
// Test the function with the mock server URL
magnet, err := GetMagnetFromUrl(server.URL, rmTrackerUrls)
if err != nil {
t.Fatalf("GetMagnetFromUrl failed: %v", err)
}
checkMagnet(t, magnet, expectedInfoHash, expectedName, expectedLink, expectedTrackerCount, true)
// Log the result
if rmTrackerUrls {
t.Logf("Generated clean magnet link from HTTP torrent: %s", magnet.Link)
} else {
t.Logf("Generated magnet link with trackers from HTTP torrent: %s", magnet.Link)
}
}
func TestGetMagnetFromUrl_TorrentLink_StripTrue(t *testing.T) {
expectedInfoHash := "8a19577fb5f690970ca43a57ff1011ae202244b8"
expectedName := "ubuntu-25.04-desktop-amd64.iso"
expectedLink := "magnet:?xt=urn:btih:8a19577fb5f690970ca43a57ff1011ae202244b8&dn=ubuntu-25.04-desktop-amd64.iso"
expectedTrackerCount := 0
testMagnetFromHttpTorrent(t, "ubuntu-25.04-desktop-amd64.iso.torrent", true, expectedInfoHash, expectedName, expectedLink, expectedTrackerCount)
}
func TestGetMagnetFromUrl_TorrentLink_StripFalse(t *testing.T) {
expectedInfoHash := "8a19577fb5f690970ca43a57ff1011ae202244b8"
expectedName := "ubuntu-25.04-desktop-amd64.iso"
expectedLink := "magnet:?xt=urn:btih:8a19577fb5f690970ca43a57ff1011ae202244b8&dn=ubuntu-25.04-desktop-amd64.iso&tr=https%3A%2F%2Ftorrent.ubuntu.com%2Fannounce&tr=https%3A%2F%2Fipv6.torrent.ubuntu.com%2Fannounce"
expectedTrackerCount := 2
testMagnetFromHttpTorrent(t, "ubuntu-25.04-desktop-amd64.iso.torrent", false, expectedInfoHash, expectedName, expectedLink, expectedTrackerCount)
}

View File

@@ -102,6 +102,13 @@ func (q *QBit) handleTorrentsAdd(w http.ResponseWriter, r *http.Request) {
if strings.ToLower(r.FormValue("sequentialDownload")) == "true" {
action = "download"
}
rmTrackerUrls := strings.ToLower(r.FormValue("firstLastPiecePrio")) == "true"
// Check config setting - if always remove tracker URLs is enabled, force it to true
if q.AlwaysRmTrackerUrls {
rmTrackerUrls = true
}
debridName := r.FormValue("debrid")
category := r.FormValue("category")
_arr := getArrFromContext(ctx)
@@ -118,7 +125,7 @@ func (q *QBit) handleTorrentsAdd(w http.ResponseWriter, r *http.Request) {
urlList = append(urlList, strings.TrimSpace(u))
}
for _, url := range urlList {
if err := q.addMagnet(ctx, url, _arr, debridName, action); err != nil {
if err := q.addMagnet(ctx, url, _arr, debridName, action, rmTrackerUrls); err != nil {
q.logger.Debug().Msgf("Error adding magnet: %s", err.Error())
http.Error(w, err.Error(), http.StatusBadRequest)
return
@@ -131,7 +138,7 @@ func (q *QBit) handleTorrentsAdd(w http.ResponseWriter, r *http.Request) {
if r.MultipartForm != nil && r.MultipartForm.File != nil {
if files := r.MultipartForm.File["torrents"]; len(files) > 0 {
for _, fileHeader := range files {
if err := q.addTorrent(ctx, fileHeader, _arr, debridName, action); err != nil {
if err := q.addTorrent(ctx, fileHeader, _arr, debridName, action, rmTrackerUrls); err != nil {
q.logger.Debug().Err(err).Msgf("Error adding torrent")
http.Error(w, err.Error(), http.StatusBadRequest)
return

View File

@@ -8,25 +8,27 @@ import (
)
type QBit struct {
Username string
Password string
DownloadFolder string
Categories []string
storage *wire.TorrentStorage
logger zerolog.Logger
Tags []string
Username string
Password string
DownloadFolder string
Categories []string
AlwaysRmTrackerUrls bool
storage *wire.TorrentStorage
logger zerolog.Logger
Tags []string
}
func New() *QBit {
_cfg := config.Get()
cfg := _cfg.QBitTorrent
return &QBit{
Username: cfg.Username,
Password: cfg.Password,
DownloadFolder: cfg.DownloadFolder,
Categories: cfg.Categories,
storage: wire.Get().Torrents(),
logger: logger.New("qbit"),
Username: cfg.Username,
Password: cfg.Password,
DownloadFolder: cfg.DownloadFolder,
Categories: cfg.Categories,
AlwaysRmTrackerUrls: cfg.AlwaysRmTrackerUrls,
storage: wire.Get().Torrents(),
logger: logger.New("qbit"),
}
}

View File

@@ -14,8 +14,8 @@ import (
)
// All torrent-related helpers goes here
func (q *QBit) addMagnet(ctx context.Context, url string, arr *arr.Arr, debrid string, action string) error {
magnet, err := utils.GetMagnetFromUrl(url)
func (q *QBit) addMagnet(ctx context.Context, url string, arr *arr.Arr, debrid string, action string, rmTrackerUrls bool) error {
magnet, err := utils.GetMagnetFromUrl(url, rmTrackerUrls)
if err != nil {
return fmt.Errorf("error parsing magnet link: %w", err)
}
@@ -30,11 +30,11 @@ func (q *QBit) addMagnet(ctx context.Context, url string, arr *arr.Arr, debrid s
return nil
}
func (q *QBit) addTorrent(ctx context.Context, fileHeader *multipart.FileHeader, arr *arr.Arr, debrid string, action string) error {
func (q *QBit) addTorrent(ctx context.Context, fileHeader *multipart.FileHeader, arr *arr.Arr, debrid string, action string, rmTrackerUrls bool) error {
file, _ := fileHeader.Open()
defer file.Close()
var reader io.Reader = file
magnet, err := utils.GetMagnetFromFile(reader, fileHeader.Filename)
magnet, err := utils.GetMagnetFromFile(reader, fileHeader.Filename, rmTrackerUrls)
if err != nil {
return fmt.Errorf("error reading file: %s \n %w", fileHeader.Filename, err)
}

View File

@@ -46,6 +46,13 @@ func (wb *Web) handleAddContent(w http.ResponseWriter, r *http.Request) {
skipMultiSeason := r.FormValue("skipMultiSeason") == "true"
downloadUncached := r.FormValue("downloadUncached") == "true"
rmTrackerUrls := r.FormValue("rmTrackerUrls") == "true"
// Check config setting - if always remove tracker URLs is enabled, force it to true
cfg := config.Get()
if cfg.QBitTorrent.AlwaysRmTrackerUrls {
rmTrackerUrls = true
}
_arr := _store.Arr().Get(arrName)
if _arr == nil {
@@ -63,7 +70,7 @@ func (wb *Web) handleAddContent(w http.ResponseWriter, r *http.Request) {
}
for _, url := range urlList {
magnet, err := utils.GetMagnetFromUrl(url)
magnet, err := utils.GetMagnetFromUrl(url, rmTrackerUrls)
if err != nil {
errs = append(errs, fmt.Sprintf("Failed to parse URL %s: %v", url, err))
continue
@@ -88,7 +95,7 @@ func (wb *Web) handleAddContent(w http.ResponseWriter, r *http.Request) {
continue
}
magnet, err := utils.GetMagnetFromFile(file, fileHeader.Filename)
magnet, err := utils.GetMagnetFromFile(file, fileHeader.Filename, rmTrackerUrls)
if err != nil {
errs = append(errs, fmt.Sprintf("Failed to parse torrent file %s: %v", fileHeader.Filename, err))
continue

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -388,7 +388,7 @@ class DecypharrUtils {
if (versionBadge) {
versionBadge.innerHTML = `
<a href="https://github.com/sirrobot01/decypharr/releases/tag/${data.version}"
<a href="https://github.com/sirrobot01/decypharr/releases/tag/v${data.version}"
target="_blank"
class="text-current hover:text-primary transition-colors">
${data.channel}-${data.version}
@@ -718,4 +718,4 @@ window.createToast = (message, type, duration) => window.decypharrUtils.createTo
// Export for ES6 modules if needed
if (typeof module !== 'undefined' && module.exports) {
module.exports = DecypharrUtils;
}
}

View File

@@ -114,7 +114,7 @@ class ConfigManager {
populateQBittorrentSettings(qbitConfig) {
if (!qbitConfig) return;
const fields = ['download_folder', 'refresh_interval', 'max_downloads', 'skip_pre_cache'];
const fields = ['download_folder', 'refresh_interval', 'max_downloads', 'skip_pre_cache', 'always_rm_tracker_urls'];
fields.forEach(field => {
const element = document.querySelector(`[name="qbit.${field}"]`);
@@ -1183,7 +1183,8 @@ class ConfigManager {
download_folder: document.querySelector('[name="qbit.download_folder"]').value,
refresh_interval: parseInt(document.querySelector('[name="qbit.refresh_interval"]').value) || 30,
max_downloads: parseInt(document.querySelector('[name="qbit.max_downloads"]').value) || 0,
skip_pre_cache: document.querySelector('[name="qbit.skip_pre_cache"]').checked
skip_pre_cache: document.querySelector('[name="qbit.skip_pre_cache"]').checked,
always_rm_tracker_urls: document.querySelector('[name="qbit.always_rm_tracker_urls"]').checked
};
}

View File

@@ -9,6 +9,7 @@ class DownloadManager {
arr: document.getElementById('arr'),
downloadAction: document.getElementById('downloadAction'),
downloadUncached: document.getElementById('downloadUncached'),
rmTrackerUrls: document.getElementById('rmTrackerUrls'),
downloadFolder: document.getElementById('downloadFolder'),
debrid: document.getElementById('debrid'),
submitBtn: document.getElementById('submitDownload'),
@@ -34,6 +35,7 @@ class DownloadManager {
this.refs.arr.addEventListener('change', () => this.saveOptions());
this.refs.downloadAction.addEventListener('change', () => this.saveOptions());
this.refs.downloadUncached.addEventListener('change', () => this.saveOptions());
this.refs.rmTrackerUrls.addEventListener('change', () => this.saveOptions());
this.refs.downloadFolder.addEventListener('change', () => this.saveOptions());
// File input enhancement
@@ -48,12 +50,14 @@ class DownloadManager {
category: localStorage.getItem('downloadCategory') || '',
action: localStorage.getItem('downloadAction') || 'symlink',
uncached: localStorage.getItem('downloadUncached') === 'true',
rmTrackerUrls: localStorage.getItem('rmTrackerUrls') === 'true',
folder: localStorage.getItem('downloadFolder') || this.downloadFolder
};
this.refs.arr.value = savedOptions.category;
this.refs.downloadAction.value = savedOptions.action;
this.refs.downloadUncached.checked = savedOptions.uncached;
this.refs.rmTrackerUrls.checked = savedOptions.rmTrackerUrls;
this.refs.downloadFolder.value = savedOptions.folder;
}
@@ -61,6 +65,12 @@ class DownloadManager {
localStorage.setItem('downloadCategory', this.refs.arr.value);
localStorage.setItem('downloadAction', this.refs.downloadAction.value);
localStorage.setItem('downloadUncached', this.refs.downloadUncached.checked.toString());
// Only save rmTrackerUrls if not disabled (i.e., not forced by config)
if (!this.refs.rmTrackerUrls.disabled) {
localStorage.setItem('rmTrackerUrls', this.refs.rmTrackerUrls.checked.toString());
}
localStorage.setItem('downloadFolder', this.refs.downloadFolder.value);
}
@@ -114,6 +124,7 @@ class DownloadManager {
formData.append('downloadFolder', this.refs.downloadFolder.value);
formData.append('action', this.refs.downloadAction.value);
formData.append('downloadUncached', this.refs.downloadUncached.checked);
formData.append('rmTrackerUrls', this.refs.rmTrackerUrls.checked);
if (this.refs.debrid) {
formData.append('debrid', this.refs.debrid.value);

View File

@@ -346,6 +346,16 @@
</div>
</label>
</div>
<div class="form-control">
<label class="label cursor-pointer justify-start gap-3">
<input type="checkbox" class="checkbox" name="qbit.always_rm_tracker_urls" id="qbit.always_rm_tracker_urls">
<div>
<span class="label-text font-medium">Always Remove Tracker URLs</span>
<div class="label-text-alt">Allows you to <a href="https://sirrobot01.github.io/decypharr/features/repair-worker/private-tracker-downloads" class="link link-hover font-semibold" target="_blank">download private tracker torrents</a> with lower risk</div>
</div>
</label>
</div>
</div>
</div>
</div>

View File

@@ -131,6 +131,15 @@
</div>
</label>
</div>
<div class="form-control">
<label class="label cursor-pointer justify-start gap-3">
<input type="checkbox" class="checkbox" name="rmTrackerUrls" id="rmTrackerUrls" {{ if .AlwaysRmTrackerUrls }}checked disabled{{ end }}>
<div>
<span class="label-text font-medium">Remove Tracker</span>
<div class="label-text-alt">Allows you to <a href="https://sirrobot01.github.io/decypharr/features/repair-worker/private-tracker-downloads" class="link link-hover font-semibold" target="_blank">download private tracker torrents</a> with lower risk</div>
</div>
</label>
</div>
</div>
<div class="form-control">

View File

@@ -130,14 +130,15 @@ func (wb *Web) DownloadHandler(w http.ResponseWriter, r *http.Request) {
debrids = append(debrids, d.Name)
}
data := map[string]interface{}{
"URLBase": cfg.URLBase,
"Page": "download",
"Title": "Download",
"Debrids": debrids,
"HasMultiDebrid": len(debrids) > 1,
"DownloadFolder": cfg.QBitTorrent.DownloadFolder,
"NeedSetup": cfg.CheckSetup() != nil,
"SetupError": cfg.CheckSetup(),
"URLBase": cfg.URLBase,
"Page": "download",
"Title": "Download",
"Debrids": debrids,
"HasMultiDebrid": len(debrids) > 1,
"DownloadFolder": cfg.QBitTorrent.DownloadFolder,
"AlwaysRmTrackerUrls": cfg.QBitTorrent.AlwaysRmTrackerUrls,
"NeedSetup": cfg.CheckSetup() != nil,
"SetupError": cfg.CheckSetup(),
}
_ = wb.templates.ExecuteTemplate(w, "layout", data)
}

View File

@@ -101,13 +101,18 @@ func (h *Handler) RemoveAll(ctx context.Context, name string) error {
if len(parts) >= 2 {
if utils.Contains(h.getParentItems(), parts[0]) {
torrentName := parts[1]
filename := filepath.Clean(path.Join(parts[2:]...))
if err := h.cache.RemoveFile(torrentName, filename); err != nil {
h.logger.Error().Err(err).Msgf("Failed to remove file %s from torrent %s", filename, torrentName)
return err
cached := h.cache.GetTorrentByName(torrentName)
if cached != nil && len(parts) >= 3 {
filename := filepath.Clean(path.Join(parts[2:]...))
if file, ok := cached.GetFile(filename); ok {
if err := h.cache.RemoveFile(cached.Id, file.Name); err != nil {
h.logger.Error().Err(err).Msgf("Failed to remove file %s from torrent %s", file.Name, torrentName)
return err
}
// If the file was successfully removed, we can return nil
return nil
}
}
// If the file was successfully removed, we can return nil
return nil
}
}

View File

@@ -0,0 +1 @@
magnet:?xt=urn:btih:8a19577fb5f690970ca43a57ff1011ae202244b8&dn=ubuntu-25.04-desktop-amd64.iso&tr=https%3A//ipv6.torrent.ubuntu.com/announce&tr=https%3A//torrent.ubuntu.com/announce

Binary file not shown.