From 6f66f35d5b2798c27e5fd13a19fff4fed0f9b2b9 Mon Sep 17 00:00:00 2001 From: vavallee Date: Wed, 30 Sep 2026 23:45:30 -0300 Subject: [PATCH] fix(importer): find qBittorrent-compatible client files under content_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 Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9 Signed-off-by: vavallee --- changelog.d/2878-qbit-content-path-files.md | 2 + internal/importer/scanner_poll.go | 102 +++++++- .../scanner_qbit_content_path_test.go | 234 ++++++++++++++++++ 3 files changed, 335 insertions(+), 3 deletions(-) create mode 100644 changelog.d/2878-qbit-content-path-files.md create mode 100644 internal/importer/scanner_qbit_content_path_test.go diff --git a/changelog.d/2878-qbit-content-path-files.md b/changelog.d/2878-qbit-content-path-files.md new file mode 100644 index 000000000..0d24c98e1 --- /dev/null +++ b/changelog.d/2878-qbit-content-path-files.md @@ -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. diff --git a/internal/importer/scanner_poll.go b/internal/importer/scanner_poll.go index d3d014054..1f0ff0133 100644 --- a/internal/importer/scanner_poll.go +++ b/internal/importer/scanner_poll.go @@ -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) @@ -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 @@ -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 { @@ -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 { diff --git a/internal/importer/scanner_qbit_content_path_test.go b/internal/importer/scanner_qbit_content_path_test.go new file mode 100644 index 000000000..39ba518ee --- /dev/null +++ b/internal/importer/scanner_qbit_content_path_test.go @@ -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) + } + }) + } +}