Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions pkg/manager/queue.go
Original file line number Diff line number Diff line change
Expand Up @@ -170,14 +170,18 @@ func (q *Queue) Update(torrent *storage.Entry) error {

func (q *Queue) ListFilterFunc(category string, protocol config.Protocol, state storage.TorrentState, hashes []string) func(*storage.Entry) bool {
hashSet := make(map[string]struct{}, len(hashes))
if len(hashes) > 0 {
// qBittorrent uses "all" as a sentinel for "every torrent", so `hashes=all`
// must not be matched as a literal infohash -- doing so filters for a
// torrent whose hash is the string "all" and returns nothing.
allHashes := len(hashes) == 1 && strings.EqualFold(strings.TrimSpace(hashes[0]), "all")
if len(hashes) > 0 && !allHashes {
for _, h := range hashes {
hashSet[strings.ToLower(h)] = struct{}{}
}
}

var filterFunc func(*storage.Entry) bool
if category != "" || len(hashes) != 0 || state != "" || protocol != config.ProtocolAll {
if category != "" || (len(hashes) != 0 && !allHashes) || state != "" || protocol != config.ProtocolAll {
filterFunc = func(t *storage.Entry) bool {
if category != "" && t.Category != category {
return false
Expand Down
69 changes: 69 additions & 0 deletions pkg/manager/queue_all_sentinel_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package manager

import (
"testing"

"github.com/sirrobot01/decypharr/internal/config"
"github.com/sirrobot01/decypharr/pkg/storage"
)

// qBittorrent uses "all" as a sentinel meaning "no filtering" for both the
// `filter` and `hashes` parameters. Treated as a literal it matches nothing:
// no entry has the state "all", and no torrent has the infohash "all". A client
// that sends either explicitly then receives an empty list.
func TestListFilterFuncTreatsHashesAllAsUnfiltered(t *testing.T) {
q := &Queue{}

entries := []*storage.Entry{
{InfoHash: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Category: "radarr", Protocol: config.ProtocolTorrent},
{InfoHash: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", Category: "sonarr", Protocol: config.ProtocolTorrent},
}

count := func(filter func(*storage.Entry) bool) int {
if filter == nil {
return len(entries)
}
n := 0
for _, e := range entries {
if filter(e) {
n++
}
}
return n
}

t.Run("hashes=all matches everything", func(t *testing.T) {
got := count(q.ListFilterFunc("", config.ProtocolAll, "", []string{"all"}))
if got != len(entries) {
t.Fatalf("hashes=all matched %d of %d entries; the sentinel was treated as a literal infohash", got, len(entries))
}
})

t.Run("hashes=ALL is case-insensitive", func(t *testing.T) {
if got := count(q.ListFilterFunc("", config.ProtocolAll, "", []string{"ALL"})); got != len(entries) {
t.Fatalf("hashes=ALL matched %d of %d entries", got, len(entries))
}
})

t.Run("a real infohash still filters", func(t *testing.T) {
got := count(q.ListFilterFunc("", config.ProtocolAll, "", []string{entries[0].InfoHash}))
if got != 1 {
t.Fatalf("explicit infohash matched %d entries, want 1", got)
}
})

t.Run("hashes=all still honours other filters", func(t *testing.T) {
got := count(q.ListFilterFunc("radarr", config.ProtocolAll, "", []string{"all"}))
if got != 1 {
t.Fatalf("category=radarr with hashes=all matched %d entries, want 1", got)
}
})

t.Run("multiple hashes including all are treated literally", func(t *testing.T) {
// Only a lone "all" is the sentinel; a list is a genuine selection.
got := count(q.ListFilterFunc("", config.ProtocolAll, "", []string{"all", entries[0].InfoHash}))
if got != 1 {
t.Fatalf("mixed hash list matched %d entries, want 1", got)
}
})
}
47 changes: 47 additions & 0 deletions pkg/server/qbit/filter_all_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package qbit

import (
"strings"
"testing"
)

// qBittorrent's `filter` parameter defaults to "all", meaning unfiltered. It is
// not a storage.TorrentState, so forwarding it as one matched no entry and
// returned an empty list to any client that sent it explicitly.
//
// This exercises the function handleTorrentsInfo actually calls, not a copy of
// its logic.
func TestNormalizeStateFilter(t *testing.T) {
cases := []struct {
name string
raw string
want string
}{
{"all is the default sentinel", "all", ""},
{"sentinel is case-insensitive", "ALL", ""},
{"sentinel tolerates surrounding space", " all ", ""},
{"absent filter is unfiltered", "", ""},
{"whitespace-only is unfiltered", " ", ""},
{"a real state is preserved", "downloading", "downloading"},
{"an unrecognised value is preserved verbatim", "seeding", "seeding"},
{"a real state is trimmed", " downloading ", "downloading"},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := normalizeStateFilter(tc.raw); got != tc.want {
t.Fatalf("normalizeStateFilter(%q) = %q, want %q", tc.raw, got, tc.want)
}
})
}
}

// strings.Trim with an empty cutset trims nothing — including the whitespace it
// looks like it should remove. Pinned so the replacement is not quietly
// reverted to it.
func TestTrimWithEmptyCutsetIsANoOp(t *testing.T) {
const padded = " all "
if strings.Trim(padded, "") != padded {
t.Fatal("strings.Trim with an empty cutset unexpectedly trimmed something")
}
}
17 changes: 16 additions & 1 deletion pkg/server/qbit/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,11 +66,26 @@ func (q *QBit) handleShutdown(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}

// normalizeStateFilter maps qBittorrent's `filter` query parameter onto a
// storage.TorrentState.
//
// qBittorrent's filter defaults to "all", meaning unfiltered. "all" is not a
// TorrentState, so forwarding it as one matched no entry and returned an empty
// list to any client that sent it explicitly. The previous
// strings.Trim(value, "") was also a no-op — an empty cutset trims nothing.
func normalizeStateFilter(raw string) string {
state := strings.TrimSpace(raw)
if strings.EqualFold(state, "all") {
return ""
}
return state
}

func (q *QBit) handleTorrentsInfo(w http.ResponseWriter, r *http.Request) {
//log all url params
ctx := r.Context()
category := getCategory(ctx)
state := strings.Trim(r.URL.Query().Get("filter"), "")
state := normalizeStateFilter(r.URL.Query().Get("filter"))
hashes := getHashes(ctx)

// Convert hashes to filter function
Expand Down