Skip to content

fix(importer): find qBittorrent-compatible client files under content_path when names omit the root folder (#2878) - #2882

Merged
vavallee merged 1 commit into
mainfrom
fix/2878-qbit-content-path-files
Oct 1, 2026
Merged

vavallee merged 1 commit into
mainfrom
fix/2878-qbit-content-path-files

Conversation

@vavallee

@vavallee vavallee commented Oct 1, 2026

Copy link
Copy Markdown
Owner

Closes #2878

Summary

Automatic import from rdt-client (qBittorrent API) found no files even though Manual Import of the same folder worked.

Bindery builds each file path as save_path + name from /api/v2/torrents/files. Real qBittorrent puts the torrent's root folder in every name of a multi-file torrent (Release/book.m4b), so that join is right. rdt-client lists names relative to content_path instead (book.m4b), so Bindery looked one folder too high (/downloads/bindery/Example Book.m4b) and filterImportableFiles dropped everything.

What changed, in internal/importer/scanner_poll.go:

  • save_path + name stays the primary join, so real qBittorrent is unchanged (this is the Bug Report: Single-file torrent import scans entire download directory #903 / a23fd8d reasoning in the comment on qbittorrentFilesFor, which still holds).
  • If that file is not on this host after the path remap, the resolver tries content_path + name with the same remap, and uses it only if that file exists. It only does this when:
    • content_path is set and is not the same as save_path
    • the remapped content_path is a directory (single-file torrents, where content_path is the file, are left alone)
    • the name does not already start with the content folder's base name. That is qBittorrent's own shape, and joining it onto content_path would double the root folder.
  • If neither path exists, the primary path is returned as before, so the "not a regular file on this host" warning and the retry/blocked flow work the same way they do now.
  • A DEBUG line logs the save path join and the fallback path whenever the fallback is used.
  • Nothing in the fallback is specific to rdt-client. It applies to any client that speaks the qBittorrent API and names files relative to the content folder (Decypharr, TorBox style emulators). Other client types (Transmission, Deluge, rTorrent) pass no content path and keep the existing resolveTorrentFiles behaviour.

Known limit, left on purpose: if an unrelated file with the same name sits loose in the shared save path, the primary join finds it first. Preferring content_path in that case would mean second guessing real qBittorrent, which this change is careful not to do.

How it was verified

New table test TestQbittorrentFilesFor_ContentPathFallback (internal/importer/scanner_qbit_content_path_test.go) uses temp dirs with real files and a fake qBittorrent /torrents/files endpoint. It covers: real qBittorrent multi-file, rdt-client multi-file (with the trailing slash on content_path from the report), single file, a file that is genuinely missing, a missing content folder, the /data/downloads:<local> remap with both name shapes, the no double root guard, and the case where both paths exist and save_path wins.

  • Fails before the fix: with origin/main's scanner_poll.go, the rdt-client and remap subtests fail and report exactly what the issue shows: got .../bindery/Example Book.m4b, want .../bindery/Example Book - Example Series, Book 2 - Example Author/Example Book.m4b, importable files 0.
  • Mutation checks (each mutant passes go vet):
    • fallback disabled: the rdt-client and remap subtests fail
    • no double root guard removed: root-led_name_missing_under_save_path_never_doubles_the_root_folder fails
    • fallback used even when the primary file exists: save_path_join_preferred_when_both_candidates_exist fails
  • go build ./... && go vet ./..., plus GOOS=windows go build ./...
  • go test ./cmd/... ./internal/... exit 0
  • golangci-lint run ./internal/importer/...: 0 issues

🤖 Generated with Claude Code

https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9

…_path when names omit the root folder (#2878)

Real qBittorrent names a multi-file torrent's files relative to save_path,
so each name carries the root folder and save_path + name is the file on
disk. rdt-client, and other clients that emulate the qBittorrent API, name
them relative to content_path instead, so the same join looked one folder
too high, every file was reported missing, and automatic import failed
while Manual Import of the same folder worked.

save_path + name stays the primary join. When that file is not on this
host after the path remap, the resolver tries content_path + name, only
when content_path is a directory distinct from save_path and the name does
not already start with the content folder's base name (qBittorrent's own
shape, where that join would double the root). The candidate is used only
if it exists; otherwise the primary path is kept so the missing file is
still reported as before. Single-file torrents are untouched because their
content_path is the file itself. A DEBUG line names the fallback path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9
Signed-off-by: vavallee <vavallee@protonmail.com>

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clean fix for a real bug with a well-scoped diff and solid test coverage.

Logic — the three guards in resolveTorrentFilesWithContentFallback are correct:

  • clientContentPath != "" keeps Transmission / Deluge / rTorrent paths unchanged (they call resolveTorrentFiles which passes "")
  • !pathOnHost(binderyPath) preserves the save-path join whenever the file is present, so real qBittorrent is unaffected
  • firstPathSegment(name) != root prevents the double-root case; confirmed by the mutation check in the PR description

The new fallback path (filepath.Join(dir, name)) inherits the same filepath.IsAbs / hasDotDotSegment guards already applied to name before it is joined, so the security posture is unchanged.

One FYI (non-blocking): contentDirForFallback (scanner_poll.go:1323) checks the content directory with os.Stat (follows symlinks), while pathOnHost (scanner_poll.go:1337) uses os.Lstat, consistent with importableSourceFile. The inconsistency is benign — a symlink-to-directory enables the fallback path but pathOnHost/filterImportableFiles will still reject a symlink file correctly — but it is slightly surprising. A comment on contentDirForFallback explaining the deliberate Stat vs Lstat split (analogous to the one on importableSourceFile at line 259–265) would help future readers. No change required.

Tests — TestQbittorrentFilesFor_ContentPathFallback covers the real qBittorrent shape, the rdt-client shape, single-file torrents, genuinely missing files, missing content folder, path remap, no-double-root guard, and save-path-wins. The three mutation checks described in the PR body map directly to named subtests. Good.

— 🤖 Bindery triage bot (automated). Reply to correct me; a human will see it.

@codecov

codecov Bot commented Oct 1, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 84.61538% with 6 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
internal/importer/scanner_poll.go 84.61% 3 Missing and 3 partials ⚠️

📢 Thoughts on this report? Let us know!

@vavallee
vavallee merged commit 44af273 into main Oct 1, 2026
40 of 41 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Automatic import uses save_path instead of content_path with rdt-client qBittorrent API

1 participant