Skip to content
Merged
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
2 changes: 2 additions & 0 deletions changelog.d/2878-qbit-content-path-files.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
### Fixed
- **Automatic import finds files from rdt-client and similar qBittorrent-compatible clients** (#2878): real qBittorrent lists a torrent's files with the release folder in front ("Release/book.m4b"), but rdt-client and other clients that emulate its API list them without it ("book.m4b"). Bindery joined those names onto the save path, looked one folder too high, found nothing, and the import failed even though Manual Import of the same folder worked. When a file is not where the save path says, Bindery now also looks under the torrent's content path, with your path remap applied. Real qBittorrent and single file torrents behave exactly as before. Thanks pmillar80 for the report and the API output that pinned it down.
102 changes: 99 additions & 3 deletions internal/importer/scanner_poll.go
Original file line number Diff line number Diff line change
Expand Up @@ -1226,9 +1226,50 @@ type torrentFile struct {
// downstream code (cleanupMovedSources, alreadyImportedPath) compares clean
// forms consistently.
func (s *Scanner) resolveTorrentFiles(client *models.DownloadClient, clientSavePath string, files []torrentFile) []string {
return s.resolveTorrentFilesWithContentFallback(client, clientSavePath, "", files)
}

// resolveTorrentFilesWithContentFallback is resolveTorrentFiles with a second
// join base, for clients that speak the qBittorrent API but do not shape the
// files list the way qBittorrent does (#2878).
//
// Real qBittorrent names every file of a multi-file torrent relative to the
// save path, so the names carry the torrent's root folder
// ("Release/book.m4b") and save_path + name is the file on disk. rdt-client,
// and any other qBittorrent emulator that copied its shape, names them
// relative to the content folder instead ("book.m4b"), so the same join lands
// one directory too high and every file is reported missing.
//
// save_path + name stays the primary join. Only when that file is not on this
// host does the resolver try clientContentPath + name, and only when:
//
// - clientContentPath is set and differs from the save path (otherwise the
// two joins are the same path);
// - the remapped content path is a directory on this host (for a single
// file torrent content_path is the file itself, and the primary join is
// already right);
// - the name does not already start with the content folder's base name.
// Such a name is qBittorrent's own shape, so joining it onto content_path
// would double the root folder ("Release/Release/book.m4b").
//
// The candidate is used only when it exists; otherwise the primary path is
// returned unchanged so filterImportableFiles reports it as missing, exactly
// as before. Both joins go through the same path remap.
func (s *Scanner) resolveTorrentFilesWithContentFallback(client *models.DownloadClient, clientSavePath, clientContentPath string, files []torrentFile) []string {
if len(files) == 0 || strings.TrimSpace(clientSavePath) == "" {
return nil
}
// The content directory is only stat'ed once, and only after a primary
// join misses, so the common qBittorrent path costs no extra syscalls.
var contentDir, contentRoot string
contentChecked := false
contentBase := func() (string, string) {
if !contentChecked {
contentChecked = true
contentDir, contentRoot = s.contentDirForFallback(client, clientSavePath, clientContentPath)
}
return contentDir, contentRoot
}
out := make([]string, 0, len(files))
for _, f := range files {
name := strings.TrimSpace(f.Name)
Expand All @@ -1252,11 +1293,61 @@ func (s *Scanner) resolveTorrentFiles(client *models.DownloadClient, clientSaveP
if !IsBookFile(binderyPath) {
continue
}
if clientContentPath != "" && !pathOnHost(binderyPath) {
if dir, root := contentBase(); dir != "" && firstPathSegment(name) != root {
alt := filepath.Clean(s.remapDownloadClientPath(client, filepath.Join(dir, name)))
if pathOnHost(alt) {
slog.Debug("import: file missing under the save path, using the content path as the join base",
"client", client.Name, "name", name, "save_path_join", binderyPath, "path", alt)
binderyPath = alt
}
}
}
out = append(out, binderyPath)
}
return out
}

// contentDirForFallback returns the client-side content directory (cleaned)
// and its base name when it is usable as the second join base described on
// resolveTorrentFilesWithContentFallback, or two empty strings when it is not.
func (s *Scanner) contentDirForFallback(client *models.DownloadClient, clientSavePath, clientContentPath string) (string, string) {
dir := strings.TrimSpace(clientContentPath)
if dir == "" {
return "", ""
}
dir = filepath.Clean(dir)
if dir == filepath.Clean(strings.TrimSpace(clientSavePath)) {
return "", ""
}
fi, err := os.Stat(filepath.Clean(s.remapDownloadClientPath(client, dir)))
if err != nil || !fi.IsDir() {
return "", ""
}
root := filepath.Base(dir)
if root == "." || root == string(filepath.Separator) {
return "", ""
}
return dir, root
}

// pathOnHost reports whether anything exists at p, without following a
// symlink. Whether the entry is importable is filterImportableFiles' call.
func pathOnHost(p string) bool {
_, err := os.Lstat(p)
return err == nil
}

// firstPathSegment returns the leading segment of a client-reported relative
// file name, accepting either separator.
func firstPathSegment(name string) string {
name = filepath.ToSlash(name)
if i := strings.Index(name, "/"); i >= 0 {
return name[:i]
}
return name
}

// hasDotDotSegment reports whether p contains a ".." path segment under
// either forward-slash or platform separators. The downloader Files() APIs
// normalise to forward slash already, but checking both is defensive — a
Expand Down Expand Up @@ -1439,12 +1530,17 @@ func (s *Scanner) transmissionFilesFor(ctx context.Context, trans *transmission.
// supplied torrent and returns the absolute Bindery-side book-file paths,
// or nil when the call fails or qBittorrent reported no files yet.
//
// SavePath, not ContentPath, is the join base: qBittorrent's files API
// returns names that include the torrent's display folder (e.g.
// SavePath, not ContentPath, is the primary join base: qBittorrent's files
// API returns names that include the torrent's display folder (e.g.
// "MyBook/file.epub") when the torrent has one, and just the basename for
// single-file torrents. Joining against SavePath reproduces what's on disk
// in both cases. ContentPath is the wrong base for multi-file torrents
// because the file names already include the folder.
//
// ContentPath is passed as a fallback base for qBittorrent-compatible clients
// such as rdt-client, whose files list omits the root folder (#2878). It is
// consulted only for files missing under SavePath; see
// resolveTorrentFilesWithContentFallback for the guards.
func (s *Scanner) qbittorrentFilesFor(ctx context.Context, qb *qbittorrent.Client, client *models.DownloadClient, torrent qbittorrent.Torrent) []string {
files, err := qb.Files(ctx, torrent.Hash)
if err != nil {
Expand All @@ -1461,7 +1557,7 @@ func (s *Scanner) qbittorrentFilesFor(ctx context.Context, qb *qbittorrent.Clien
for _, f := range files {
conv = append(conv, torrentFile{Name: f.Name, Size: f.Size})
}
return s.resolveTorrentFiles(client, torrent.SavePath, conv)
return s.resolveTorrentFilesWithContentFallback(client, torrent.SavePath, torrent.ContentPath, conv)
}

func (s *Scanner) remapDownloadClientPath(client *models.DownloadClient, rawPath string) string {
Expand Down
234 changes: 234 additions & 0 deletions internal/importer/scanner_qbit_content_path_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
package importer

import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"

"github.com/vavallee/bindery/internal/downloader/qbittorrent"
"github.com/vavallee/bindery/internal/models"
)

// TestQbittorrentFilesFor_ContentPathFallback covers #2878. Real qBittorrent
// names a multi-file torrent's files relative to save_path, so the names carry
// the root folder and save_path + name is the file on disk. rdt-client (and
// other qBittorrent-compatible clients that copied its shape) names them
// relative to content_path, so the same join lands one directory too high and
// automatic import found nothing. The resolver keeps save_path + name as the
// primary join and falls back to content_path + name only when the primary
// file is missing.
func TestQbittorrentFilesFor_ContentPathFallback(t *testing.T) {
const release = "Example Book - Example Series, Book 2 - Example Author"

type layout struct {
// clientRoot is the directory the download client reports paths
// under. Empty means "the same as the Bindery side" (no remap).
clientRoot string
saveRel string // save_path relative to the root
contentRel string // content_path relative to the root ("" = unset)
trailing bool // append "/" to content_path, as rdt-client does
files []string
onDisk []string // files created under the Bindery-side root
}

tests := []struct {
name string
layout layout
// want is relative to the Bindery-side root.
want []string
// wantImportable is how many survive filterImportableFiles.
wantImportable int
}{
{
name: "real qBittorrent multi-file: names include the root, save_path join",
layout: layout{
saveRel: "bindery",
contentRel: "bindery/" + release,
files: []string{release + "/Example Book.m4b", release + "/Example Book.jpg"},
onDisk: []string{"bindery/" + release + "/Example Book.m4b", "bindery/" + release + "/Example Book.jpg"},
},
want: []string{"bindery/" + release + "/Example Book.m4b"},
wantImportable: 1,
},
{
name: "rdt-client multi-file: names omit the root, content_path fallback",
layout: layout{
saveRel: "bindery",
contentRel: "bindery/" + release,
trailing: true,
files: []string{"Example Book.jpg", "Example Book.m4b"},
onDisk: []string{"bindery/" + release + "/Example Book.m4b", "bindery/" + release + "/Example Book.jpg"},
},
want: []string{"bindery/" + release + "/Example Book.m4b"},
wantImportable: 1,
},
{
name: "single-file torrent: content_path is the file, save_path join",
layout: layout{
saveRel: "bindery",
contentRel: "bindery/Lone Book.epub",
files: []string{"Lone Book.epub"},
onDisk: []string{"bindery/Lone Book.epub"},
},
want: []string{"bindery/Lone Book.epub"},
wantImportable: 1,
},
{
name: "file genuinely missing: primary path returned and filtered out",
layout: layout{
saveRel: "bindery",
contentRel: "bindery/" + release,
trailing: true,
files: []string{"Example Book.m4b"},
onDisk: []string{"bindery/" + release + "/Example Book.jpg"},
},
want: []string{"bindery/Example Book.m4b"},
wantImportable: 0,
},
{
name: "content folder missing entirely: primary path returned and filtered out",
layout: layout{
saveRel: "bindery",
contentRel: "bindery/" + release,
files: []string{"Example Book.m4b"},
},
want: []string{"bindery/Example Book.m4b"},
wantImportable: 0,
},
{
name: "path remap /data/downloads to the local mount applies to the fallback",
layout: layout{
clientRoot: "/data/downloads",
saveRel: "bindery",
contentRel: "bindery/" + release,
trailing: true,
files: []string{"Example Book.jpg", "Example Book.m4b"},
onDisk: []string{"bindery/" + release + "/Example Book.m4b", "bindery/" + release + "/Example Book.jpg"},
},
want: []string{"bindery/" + release + "/Example Book.m4b"},
wantImportable: 1,
},
{
name: "path remap with real qBittorrent names keeps the save_path join",
layout: layout{
clientRoot: "/data/downloads",
saveRel: "bindery",
contentRel: "bindery/" + release,
files: []string{release + "/Example Book.m4b"},
onDisk: []string{"bindery/" + release + "/Example Book.m4b"},
},
want: []string{"bindery/" + release + "/Example Book.m4b"},
wantImportable: 1,
},
{
// The no-double-root guard: a name already led by the content
// folder is qBittorrent's own shape. Even when that file is gone,
// joining it onto content_path would double the folder and could
// pick up an unrelated file that happens to sit there.
name: "root-led name missing under save_path never doubles the root folder",
layout: layout{
saveRel: "bindery",
contentRel: "bindery/" + release,
files: []string{release + "/Example Book.m4b"},
onDisk: []string{"bindery/" + release + "/" + release + "/Example Book.m4b"},
},
want: []string{"bindery/" + release + "/Example Book.m4b"},
wantImportable: 0,
},
{
// The fallback is a fallback: when save_path + name exists it wins
// even if content_path + name exists too.
name: "save_path join preferred when both candidates exist",
layout: layout{
saveRel: "bindery",
contentRel: "bindery/" + release,
files: []string{"Example Book.m4b"},
onDisk: []string{"bindery/Example Book.m4b", "bindery/" + release + "/Example Book.m4b"},
},
want: []string{"bindery/Example Book.m4b"},
wantImportable: 1,
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
localRoot := t.TempDir()
for _, rel := range tc.layout.onDisk {
p := filepath.Join(localRoot, filepath.FromSlash(rel))
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte("data"), 0o644); err != nil {
t.Fatal(err)
}
}
if err := os.MkdirAll(filepath.Join(localRoot, filepath.FromSlash(tc.layout.saveRel)), 0o755); err != nil {
t.Fatal(err)
}

clientRoot := filepath.ToSlash(localRoot)
client := &models.DownloadClient{Name: "qbit-compat", Type: "qbittorrent"}
if tc.layout.clientRoot != "" {
clientRoot = tc.layout.clientRoot
client.PathRemap = tc.layout.clientRoot + ":" + localRoot
}
savePath := clientRoot + "/" + tc.layout.saveRel
contentPath := ""
if tc.layout.contentRel != "" {
contentPath = clientRoot + "/" + tc.layout.contentRel
if tc.layout.trailing {
contentPath += "/"
}
}

const hash = "28782878287828782878287828782878aaaabbbb"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v2/auth/login":
_, _ = w.Write([]byte("Ok."))
case "/api/v2/torrents/files":
out := make([]map[string]any, 0, len(tc.layout.files))
for i, n := range tc.layout.files {
out = append(out, map[string]any{"index": i, "name": n, "size": 4, "progress": 1})
}
_ = json.NewEncoder(w).Encode(out)
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer srv.Close()

host, port := scannerTestHostPort(t, srv.URL)
qb := qbittorrent.New(host, port, "", "", "", false)
s, _, _, _ := scannerFixture(t, t.TempDir())

torrent := qbittorrent.Torrent{
Hash: hash,
Name: release,
SavePath: savePath,
ContentPath: contentPath,
}
got := s.qbittorrentFilesFor(context.Background(), qb, client, torrent)

want := make([]string, 0, len(tc.want))
for _, rel := range tc.want {
want = append(want, filepath.Join(localRoot, filepath.FromSlash(rel)))
}
if len(got) != len(want) {
t.Fatalf("files: want %v, got %v", want, got)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("file[%d]: want %q, got %q", i, want[i], got[i])
}
}
if n := len(filterImportableFiles(got)); n != tc.wantImportable {
t.Errorf("importable files: want %d, got %d (%v)", tc.wantImportable, n, got)
}
})
}
}
Loading