Skip to content

Commit 7d633bd

Browse files
authored
fix(crossseed): use ContentPath for manually-managed single-file torrents (#832)
1 parent dac0173 commit 7d633bd

3 files changed

Lines changed: 432 additions & 0 deletions

File tree

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
package crossseed
2+
3+
import (
4+
"testing"
5+
6+
qbt "github.com/autobrr/go-qbittorrent"
7+
"github.com/stretchr/testify/require"
8+
)
9+
10+
func TestResolveRootlessContentDir(t *testing.T) {
11+
t.Parallel()
12+
13+
tests := []struct {
14+
name string
15+
torrent *qbt.Torrent
16+
candidateFiles qbt.TorrentFiles
17+
expected string
18+
}{
19+
{
20+
name: "nil torrent",
21+
torrent: nil,
22+
candidateFiles: qbt.TorrentFiles{{Name: "f.mkv"}},
23+
expected: "",
24+
},
25+
{
26+
name: "empty content path",
27+
torrent: &qbt.Torrent{ContentPath: ""},
28+
candidateFiles: qbt.TorrentFiles{{Name: "f.mkv"}},
29+
expected: "",
30+
},
31+
{
32+
name: "no candidate files",
33+
torrent: &qbt.Torrent{ContentPath: "/downloads/show/f.mkv"},
34+
candidateFiles: nil,
35+
expected: "",
36+
},
37+
{
38+
name: "dot content path",
39+
torrent: &qbt.Torrent{ContentPath: "."},
40+
candidateFiles: qbt.TorrentFiles{{Name: "f.mkv"}},
41+
expected: "",
42+
},
43+
{
44+
name: "single file extracts dir",
45+
torrent: &qbt.Torrent{ContentPath: "/downloads/show/f.mkv"},
46+
candidateFiles: qbt.TorrentFiles{{Name: "f.mkv"}},
47+
expected: "/downloads/show",
48+
},
49+
{
50+
name: "single file relative path returns empty",
51+
torrent: &qbt.Torrent{ContentPath: "file.mkv"},
52+
candidateFiles: qbt.TorrentFiles{{Name: "file.mkv"}},
53+
expected: "",
54+
},
55+
{
56+
name: "single file normalizes backslashes",
57+
torrent: &qbt.Torrent{ContentPath: "/downloads\\tv\\Show\\file.mkv"},
58+
candidateFiles: qbt.TorrentFiles{{Name: "file.mkv"}},
59+
expected: "/downloads/tv/Show",
60+
},
61+
{
62+
name: "multi-file uses content path",
63+
torrent: &qbt.Torrent{ContentPath: "/downloads/show"},
64+
candidateFiles: qbt.TorrentFiles{{Name: "f1.mkv"}, {Name: "f2.mkv"}},
65+
expected: "/downloads/show",
66+
},
67+
{
68+
name: "multi-file cleans trailing slash",
69+
torrent: &qbt.Torrent{ContentPath: "/downloads/show/"},
70+
candidateFiles: qbt.TorrentFiles{{Name: "f1.mkv"}, {Name: "f2.mkv"}},
71+
expected: "/downloads/show",
72+
},
73+
}
74+
75+
for _, tt := range tests {
76+
t.Run(tt.name, func(t *testing.T) {
77+
t.Parallel()
78+
require.Equal(t, tt.expected, resolveRootlessContentDir(tt.torrent, tt.candidateFiles))
79+
})
80+
}
81+
}
82+
Lines changed: 308 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,308 @@
1+
package crossseed
2+
3+
import (
4+
"context"
5+
"strings"
6+
"testing"
7+
8+
qbt "github.com/autobrr/go-qbittorrent"
9+
"github.com/stretchr/testify/require"
10+
11+
"github.com/autobrr/qui/internal/models"
12+
internalqb "github.com/autobrr/qui/internal/qbittorrent"
13+
"github.com/autobrr/qui/pkg/stringutils"
14+
)
15+
16+
type rootlessSavePathSyncManager struct {
17+
files map[string]qbt.TorrentFiles
18+
props map[string]*qbt.TorrentProperties
19+
addedOptions map[string]string
20+
}
21+
22+
func (m *rootlessSavePathSyncManager) GetTorrents(_ context.Context, _ int, filter qbt.TorrentFilterOptions) ([]qbt.Torrent, error) {
23+
if len(filter.Hashes) > 0 {
24+
torrents := make([]qbt.Torrent, 0, len(filter.Hashes))
25+
for _, hash := range filter.Hashes {
26+
torrents = append(torrents, qbt.Torrent{Hash: hash})
27+
}
28+
return torrents, nil
29+
}
30+
return []qbt.Torrent{{Hash: "dummy"}}, nil
31+
}
32+
33+
func (m *rootlessSavePathSyncManager) GetTorrentFilesBatch(_ context.Context, _ int, hashes []string) (map[string]qbt.TorrentFiles, error) {
34+
result := make(map[string]qbt.TorrentFiles, len(hashes))
35+
for _, h := range hashes {
36+
if files, ok := m.files[strings.ToLower(h)]; ok {
37+
cp := make(qbt.TorrentFiles, len(files))
38+
copy(cp, files)
39+
result[normalizeHash(h)] = cp
40+
}
41+
}
42+
return result, nil
43+
}
44+
45+
func (*rootlessSavePathSyncManager) HasTorrentByAnyHash(context.Context, int, []string) (*qbt.Torrent, bool, error) {
46+
return nil, false, nil
47+
}
48+
49+
func (m *rootlessSavePathSyncManager) GetTorrentProperties(_ context.Context, _ int, hash string) (*qbt.TorrentProperties, error) {
50+
if props, ok := m.props[strings.ToLower(hash)]; ok {
51+
cp := *props
52+
return &cp, nil
53+
}
54+
return &qbt.TorrentProperties{SavePath: "/downloads"}, nil
55+
}
56+
57+
func (*rootlessSavePathSyncManager) GetAppPreferences(context.Context, int) (qbt.AppPreferences, error) {
58+
return qbt.AppPreferences{TorrentContentLayout: "Original"}, nil
59+
}
60+
61+
func (m *rootlessSavePathSyncManager) AddTorrent(_ context.Context, _ int, _ []byte, options map[string]string) error {
62+
m.addedOptions = make(map[string]string, len(options))
63+
for key, value := range options {
64+
m.addedOptions[key] = value
65+
}
66+
return nil
67+
}
68+
69+
func (*rootlessSavePathSyncManager) BulkAction(context.Context, int, []string, string) error {
70+
return nil
71+
}
72+
73+
func (*rootlessSavePathSyncManager) SetTags(context.Context, int, []string, string) error {
74+
return nil
75+
}
76+
77+
func (*rootlessSavePathSyncManager) GetCachedInstanceTorrents(context.Context, int) ([]internalqb.CrossInstanceTorrentView, error) {
78+
return nil, nil
79+
}
80+
81+
func (*rootlessSavePathSyncManager) ExtractDomainFromURL(string) string {
82+
return ""
83+
}
84+
85+
func (*rootlessSavePathSyncManager) GetQBittorrentSyncManager(context.Context, int) (*qbt.SyncManager, error) {
86+
return nil, nil
87+
}
88+
89+
func (*rootlessSavePathSyncManager) RenameTorrent(context.Context, int, string, string) error {
90+
return nil
91+
}
92+
93+
func (*rootlessSavePathSyncManager) RenameTorrentFile(context.Context, int, string, string, string) error {
94+
return nil
95+
}
96+
97+
func (*rootlessSavePathSyncManager) RenameTorrentFolder(context.Context, int, string, string, string) error {
98+
return nil
99+
}
100+
101+
func (*rootlessSavePathSyncManager) GetCategories(context.Context, int) (map[string]qbt.Category, error) {
102+
return map[string]qbt.Category{}, nil
103+
}
104+
105+
func (*rootlessSavePathSyncManager) CreateCategory(context.Context, int, string, string) error {
106+
return nil
107+
}
108+
109+
func TestProcessCrossSeedCandidate_RootlessContentDirOverridesSavePath(t *testing.T) {
110+
t.Parallel()
111+
112+
ctx := context.Background()
113+
instanceID := 1
114+
matchedHash := "matchedhash"
115+
newHash := "newhash"
116+
matchedName := "Show.S01E01.1080p.WEB-DL-GROUP"
117+
118+
candidateFiles := qbt.TorrentFiles{
119+
{Name: "Show.S01E01.mkv", Size: 1024},
120+
}
121+
sourceFiles := qbt.TorrentFiles{
122+
{Name: "Show.S01E01.mkv", Size: 1024},
123+
}
124+
125+
matchedTorrent := qbt.Torrent{
126+
Hash: matchedHash,
127+
Name: matchedName,
128+
Progress: 1.0,
129+
Category: "tv",
130+
AutoManaged: true,
131+
ContentPath: "/downloads/tv/Show.S01E01/Show.S01E01.mkv",
132+
}
133+
134+
sync := &rootlessSavePathSyncManager{
135+
files: map[string]qbt.TorrentFiles{
136+
matchedHash: candidateFiles,
137+
newHash: sourceFiles,
138+
},
139+
props: map[string]*qbt.TorrentProperties{
140+
matchedHash: {SavePath: "/downloads/tv"},
141+
},
142+
}
143+
144+
service := &Service{
145+
syncManager: sync,
146+
releaseCache: NewReleaseCache(),
147+
stringNormalizer: stringutils.NewDefaultNormalizer(),
148+
automationSettingsLoader: func(context.Context) (*models.CrossSeedAutomationSettings, error) {
149+
return models.DefaultCrossSeedAutomationSettings(), nil
150+
},
151+
}
152+
153+
startPaused := true
154+
req := &CrossSeedRequest{
155+
StartPaused: &startPaused,
156+
}
157+
158+
candidate := CrossSeedCandidate{
159+
InstanceID: instanceID,
160+
InstanceName: "Test",
161+
Torrents: []qbt.Torrent{matchedTorrent},
162+
}
163+
164+
result := service.processCrossSeedCandidate(ctx, candidate, []byte("torrent"), newHash, matchedName, req, service.releaseCache.Parse(matchedName), sourceFiles)
165+
require.True(t, result.Success)
166+
require.Equal(t, "added", result.Status)
167+
168+
require.NotNil(t, sync.addedOptions)
169+
require.Equal(t, "false", sync.addedOptions["autoTMM"])
170+
require.Equal(t, "/downloads/tv/Show.S01E01", sync.addedOptions["savepath"])
171+
require.Equal(t, "Original", sync.addedOptions["contentLayout"])
172+
require.Equal(t, "true", sync.addedOptions["skip_checking"])
173+
}
174+
175+
func TestProcessCrossSeedCandidate_RootlessContentDirOverridesSavePath_MultiFileUsesContentPath(t *testing.T) {
176+
t.Parallel()
177+
178+
ctx := context.Background()
179+
instanceID := 1
180+
matchedHash := "matchedhash"
181+
newHash := "newhash"
182+
matchedName := "Show.S01E01.1080p.WEB-DL-GROUP"
183+
184+
candidateFiles := qbt.TorrentFiles{
185+
{Name: "Show.S01E01.mkv", Size: 1024},
186+
{Name: "Show.S01E01.srt", Size: 128},
187+
}
188+
sourceFiles := qbt.TorrentFiles{
189+
{Name: "Show.S01E01.mkv", Size: 1024},
190+
{Name: "Show.S01E01.srt", Size: 128},
191+
}
192+
193+
matchedTorrent := qbt.Torrent{
194+
Hash: matchedHash,
195+
Name: matchedName,
196+
Progress: 1.0,
197+
Category: "tv",
198+
AutoManaged: true,
199+
ContentPath: "/downloads/tv/Show.S01E01",
200+
}
201+
202+
sync := &rootlessSavePathSyncManager{
203+
files: map[string]qbt.TorrentFiles{
204+
matchedHash: candidateFiles,
205+
newHash: sourceFiles,
206+
},
207+
props: map[string]*qbt.TorrentProperties{
208+
matchedHash: {SavePath: "/downloads/tv"},
209+
},
210+
}
211+
212+
service := &Service{
213+
syncManager: sync,
214+
releaseCache: NewReleaseCache(),
215+
stringNormalizer: stringutils.NewDefaultNormalizer(),
216+
automationSettingsLoader: func(context.Context) (*models.CrossSeedAutomationSettings, error) {
217+
return models.DefaultCrossSeedAutomationSettings(), nil
218+
},
219+
}
220+
221+
startPaused := true
222+
req := &CrossSeedRequest{
223+
StartPaused: &startPaused,
224+
}
225+
226+
candidate := CrossSeedCandidate{
227+
InstanceID: instanceID,
228+
InstanceName: "Test",
229+
Torrents: []qbt.Torrent{matchedTorrent},
230+
}
231+
232+
result := service.processCrossSeedCandidate(ctx, candidate, []byte("torrent"), newHash, matchedName, req, service.releaseCache.Parse(matchedName), sourceFiles)
233+
require.True(t, result.Success)
234+
require.Equal(t, "added", result.Status)
235+
236+
require.NotNil(t, sync.addedOptions)
237+
require.Equal(t, "false", sync.addedOptions["autoTMM"])
238+
require.Equal(t, "/downloads/tv/Show.S01E01", sync.addedOptions["savepath"])
239+
require.Equal(t, "Original", sync.addedOptions["contentLayout"])
240+
require.Equal(t, "true", sync.addedOptions["skip_checking"])
241+
}
242+
243+
func TestProcessCrossSeedCandidate_RootlessContentDirNoopWhenSavePathMatches(t *testing.T) {
244+
t.Parallel()
245+
246+
ctx := context.Background()
247+
instanceID := 1
248+
matchedHash := "matchedhash"
249+
newHash := "newhash"
250+
matchedName := "Show.S01E01.1080p.WEB-DL-GROUP"
251+
252+
candidateFiles := qbt.TorrentFiles{
253+
{Name: "Show.S01E01.mkv", Size: 1024},
254+
}
255+
sourceFiles := qbt.TorrentFiles{
256+
{Name: "Show.S01E01.mkv", Size: 1024},
257+
}
258+
259+
matchedTorrent := qbt.Torrent{
260+
Hash: matchedHash,
261+
Name: matchedName,
262+
Progress: 1.0,
263+
Category: "tv",
264+
AutoManaged: true,
265+
ContentPath: "/downloads/tv/Show.S01E01/Show.S01E01.mkv",
266+
}
267+
268+
sync := &rootlessSavePathSyncManager{
269+
files: map[string]qbt.TorrentFiles{
270+
matchedHash: candidateFiles,
271+
newHash: sourceFiles,
272+
},
273+
props: map[string]*qbt.TorrentProperties{
274+
matchedHash: {SavePath: "/downloads/tv/Show.S01E01"},
275+
},
276+
}
277+
278+
service := &Service{
279+
syncManager: sync,
280+
releaseCache: NewReleaseCache(),
281+
stringNormalizer: stringutils.NewDefaultNormalizer(),
282+
automationSettingsLoader: func(context.Context) (*models.CrossSeedAutomationSettings, error) {
283+
return models.DefaultCrossSeedAutomationSettings(), nil
284+
},
285+
}
286+
287+
startPaused := true
288+
req := &CrossSeedRequest{
289+
StartPaused: &startPaused,
290+
}
291+
292+
candidate := CrossSeedCandidate{
293+
InstanceID: instanceID,
294+
InstanceName: "Test",
295+
Torrents: []qbt.Torrent{matchedTorrent},
296+
}
297+
298+
result := service.processCrossSeedCandidate(ctx, candidate, []byte("torrent"), newHash, matchedName, req, service.releaseCache.Parse(matchedName), sourceFiles)
299+
require.True(t, result.Success)
300+
require.Equal(t, "added", result.Status)
301+
302+
require.NotNil(t, sync.addedOptions)
303+
require.Equal(t, "true", sync.addedOptions["autoTMM"])
304+
_, hasSavePath := sync.addedOptions["savepath"]
305+
require.False(t, hasSavePath)
306+
require.Equal(t, "Original", sync.addedOptions["contentLayout"])
307+
require.Equal(t, "true", sync.addedOptions["skip_checking"])
308+
}

0 commit comments

Comments
 (0)