Skip to content

feat(cli): migrate Deluge, rTorrent and Transmission to qBittorrent with stats#616

Open
zze0s wants to merge 12 commits intodevelopfrom
feature/migrate-from-other-clients
Open

feat(cli): migrate Deluge, rTorrent and Transmission to qBittorrent with stats#616
zze0s wants to merge 12 commits intodevelopfrom
feature/migrate-from-other-clients

Conversation

@zze0s
Copy link
Copy Markdown
Collaborator

@zze0s zze0s commented Nov 20, 2025

Implement client migrator from Deluge, rTorrent and Transmission to qBittorrent with stats.

Summary by CodeRabbit

  • New Features

    • New migrate CLI to import torrents from Deluge, rTorrent, or Transmission into qBittorrent, with per-torrent progress, summary, and dry-run support.
    • CLI flags to specify source/destination, enable dry-run, and skip backups, with input validation and clear errors.
    • Migration preserves paths, file priorities, trackers, timestamps, and generates qBittorrent resume data while copying torrents.
  • Chores

    • Optional archive/backup support for source and qBittorrent data during migration.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Nov 20, 2025

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds a new migrate CLI command and a client migration subsystem to import Deluge, rTorrent, or Transmission state into qBittorrent fastresume format, including optional timestamped backups, dry-run support, filesystem utilities, and bencode-based fastresume creation.

Changes

Cohort / File(s) Summary
CLI Command
cmd/qui/main.go
Adds migrate Cobra subcommand, flags (--source-dir, --qbit-dir, --dry-run, --skip-backup), argument validation, and RunMigrateCommand() wiring.
Migration Core
internal/clientmigrate/migrate.go
New orchestrator types (Options, ClientMigrater, Migrater), New(opts) and Migrate(ctx) with optional pre-backup, archiveDir, MkDirIfNotExists, and CopyFile utilities.
Deluge Importer
internal/clientmigrate/deluge.go
New NewDelugeImporter and DelugeImport.Migrate() plus decodeFastresumeFile; parses Deluge fastresume and .torrent files, maps to qbittorrent.Fastresume, supports dry-run, writes .fastresume and copies .torrent.
rTorrent Importer
internal/clientmigrate/rtorrent.go
New NewRTorrentImporter and RTorrentImport.Migrate() with rtorrent bencode types and helpers; decodes resume/state, converts trackers/priorities/pieces, writes .fastresume and copies .torrent.
Transmission Importer
internal/clientmigrate/transmission.go
New NewTransmissionImporter and TransmissionImport.Migrate() with TransmissionResumeFile types and decodeResumeFile; maps Transmission resume + torrent metadata to qbittorrent.Fastresume, writes .fastresume and copies .torrent.
qBittorrent Fastresume
internal/qbittorrent/fastresume.go
Adds exported Fastresume struct with bencode tags and methods Encode(path), ConvertFilePriority(numFiles), and FillPieces() to initialize priorities/pieces and encode to disk.
Module deps
go.mod
Adds direct dependencies for bencode and archive tooling and multiple transitive archive/compression modules.

Sequence Diagram(s)

sequenceDiagram
    actor User
    participant CLI as CLI Command
    participant Migr as Migrater
    participant Imp as Importer
    participant Backup as Backup Manager
    participant FS as File System

    User->>CLI: migrate <client> --source-dir /src --qbit-dir /dst [--dry-run] [--skip-backup]
    CLI->>Migr: New(opts) / Migrate(ctx)
    Migr->>Backup: perform backup (unless SkipBackup)
    Backup->>FS: archive sourceDir & qbitDir
    Migr->>Imp: select importer (deluge|rtorrent|transmission)
    Imp->>FS: read .torrent and client resume/state files
    Imp->>Imp: decode state, build qbittorrent.Fastresume
    alt Dry-Run
        Imp->>CLI: log intended writes (no file changes)
    else Not Dry-Run
        Imp->>FS: write .fastresume files
        Imp->>FS: copy .torrent files to qbitDir
    end
    Imp->>Migr: report summary
    Migr->>CLI: display results
    CLI->>User: output completion
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Suggested labels

enhancement, backend, torrent

Suggested reviewers

  • s0up4200
  • buroa

Poem

🐰 I hop through folders, bencode in paw,
I map fastresume, copy torrents with awe.
Deluge, rTorrent, Transmission in line,
Dry-run, backups, then migrations shine.
A happy rabbit commit — migration fine!

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 38.46% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically summarizes the main change: adding migration functionality from three torrent clients (Deluge, rTorrent, Transmission) to qBittorrent with statistics. It directly reflects the primary purpose of all file changes in the PR.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feature/migrate-from-other-clients

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +186 to +189
newFastResume.ConvertFilePriority(len(metaInfo.Files))

// fill pieces to set as completed
newFastResume.FillPieces()
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Pieces left empty in rTorrent fastresume

The rTorrent importer calls FillPieces() with NumPieces still at its zero value (no assignment anywhere before lines 186-189), so the generated fastresume contains an empty pieces bitfield. Migrated torrents will be treated as unchecked in qBittorrent, forcing a full recheck or leaving them inactive instead of resuming seeding despite existing data.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

It's assigned on line 129 on the newFastResume struct.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 10

🧹 Nitpick comments (3)
go.mod (1)

23-32: Confirm new archive/bencode dependencies are necessary and run go mod tidy.

The added deps (e.g. github.com/mholt/archives, github.com/zeebo/bencode and the related compression libs) look aligned with the new migration/backup functionality, but they significantly expand the module graph.

Two suggestions:

  • Ensure all of these are actually referenced (high‑level: run go mod tidy and check for removals).
  • Consider whether you really need both archives and separate zip/xz/lz4 stacks, or if some can remain purely transitive to keep the surface smaller.

Also applies to: 49-109

internal/qbittorrent/fastresume.go (2)

88-114: Handle buffered writer flush errors and avoid logging from deep inside library code.

Two small issues in Encode:

  • You ignore the error from bufferedWriter.Flush(), which can hide I/O failures even though Encode reports success.
  • Using the std log package here is inconsistent with the rest of the codebase (which uses zerolog) and mixes concerns; callers are usually better placed to decide how to log.

Consider:

-	bufferedWriter := bufio.NewWriter(file)
-	enc := bencode.NewEncoder(bufferedWriter)
-	if err := enc.Encode(fr); err != nil {
-		log.Printf("encode error: %v", err)
-		return err
-	}
-
-	bufferedWriter.Flush()
-	return nil
+	bufferedWriter := bufio.NewWriter(file)
+	enc := bencode.NewEncoder(bufferedWriter)
+	if err := enc.Encode(fr); err != nil {
+		return fmt.Errorf("encode fastresume: %w", err)
+	}
+
+	if err := bufferedWriter.Flush(); err != nil {
+		return fmt.Errorf("flush fastresume: %w", err)
+	}
+	return nil

(and let the caller log as needed).


116-140: Clarify ConvertFilePriority behavior for single‑file torrents.

ConvertFilePriority builds FilePriority based solely on numFiles. When called with len(metaInfo.Files), single‑file torrents usually have Files == nil, which means numFiles == 0 and FilePriority remains empty.

Please verify how qBittorrent behaves when file_priority is missing for a single‑file torrent. If it expects one entry, you may want:

-func (fr *Fastresume) ConvertFilePriority(numFiles int) {
+func (fr *Fastresume) ConvertFilePriority(numFiles int) {
+	if numFiles == 0 {
+		// Single-file torrent: still have one logical file.
+		numFiles = 1
+	}
 	newPrioList := make([]int, numFiles)
 	for i := 0; i < numFiles; i++ {
 		newPrioList[i] = 1
 	}
 	fr.FilePriority = newPrioList
}
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between f5704de and ae8cec0.

⛔ Files ignored due to path filters (1)
  • go.sum is excluded by !**/*.sum
📒 Files selected for processing (6)
  • cmd/qui/main.go (3 hunks)
  • go.mod (5 hunks)
  • internal/clientmigrate/deluge.go (1 hunks)
  • internal/clientmigrate/migrate.go (1 hunks)
  • internal/clientmigrate/rtorrent.go (1 hunks)
  • internal/qbittorrent/fastresume.go (1 hunks)
🧰 Additional context used
🧠 Learnings (2)
📓 Common learnings
Learnt from: Audionut
Repo: autobrr/qui PR: 553
File: internal/services/crossseed/service.go:1045-1082
Timestamp: 2025-11-06T12:11:04.963Z
Learning: The autobrr/qui project uses a custom go-qbittorrent client library (github.com/autobrr/go-qbittorrent) that supports both "paused" and "stopped" parameters when adding torrents via the options map. Both parameters should be set together when controlling torrent start state, as seen in internal/services/crossseed/service.go and throughout the codebase.
📚 Learning: 2025-11-06T12:11:04.963Z
Learnt from: Audionut
Repo: autobrr/qui PR: 553
File: internal/services/crossseed/service.go:1045-1082
Timestamp: 2025-11-06T12:11:04.963Z
Learning: The autobrr/qui project uses a custom go-qbittorrent client library (github.com/autobrr/go-qbittorrent) that supports both "paused" and "stopped" parameters when adding torrents via the options map. Both parameters should be set together when controlling torrent start state, as seen in internal/services/crossseed/service.go and throughout the codebase.

Applied to files:

  • internal/clientmigrate/rtorrent.go
🧬 Code graph analysis (4)
internal/clientmigrate/deluge.go (2)
internal/clientmigrate/migrate.go (4)
  • Options (16-22)
  • ClientMigrater (24-26)
  • MkDirIfNotExists (154-168)
  • CopyFile (175-212)
internal/qbittorrent/fastresume.go (1)
  • Fastresume (14-86)
internal/clientmigrate/rtorrent.go (2)
internal/clientmigrate/migrate.go (3)
  • Options (16-22)
  • ClientMigrater (24-26)
  • CopyFile (175-212)
internal/qbittorrent/fastresume.go (1)
  • Fastresume (14-86)
internal/clientmigrate/migrate.go (2)
internal/clientmigrate/rtorrent.go (1)
  • NewRTorrentImporter (23-25)
internal/clientmigrate/deluge.go (1)
  • NewDelugeImporter (19-21)
cmd/qui/main.go (1)
internal/clientmigrate/migrate.go (2)
  • New (32-34)
  • Options (16-22)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Run tests
🔇 Additional comments (7)
internal/clientmigrate/rtorrent.go (7)

1-30: LGTM!

The package structure, imports, and type definitions are well organized. The constructor properly returns the ClientMigrater interface.


35-56: LGTM!

Directory validation and file discovery logic is robust with appropriate error handling and early returns.


220-235: LGTM!

The tracker conversion logic correctly filters out DHT pseudo-trackers and disabled trackers, producing the qBittorrent-compatible format.


238-244: LGTM!

Filename extraction and normalization logic is correct.


246-272: LGTM!

The decode helper functions follow a standard pattern for reading and unmarshaling bencode files.


275-286: LGTM with a note on error handling.

The function silently returns 0 for invalid input, which is appropriate for a migration utility where some data corruption is tolerable and shouldn't halt processing.


288-302: LGTM!

The type definitions correctly map to RTorrent's bencode format with appropriate struct tags.

Comment thread cmd/qui/main.go
Comment thread internal/clientmigrate/deluge.go
Comment thread internal/clientmigrate/migrate.go
Comment thread internal/clientmigrate/rtorrent.go
Comment thread internal/clientmigrate/rtorrent.go
Comment thread internal/clientmigrate/rtorrent.go
Comment thread internal/clientmigrate/rtorrent.go Outdated
Comment on lines +167 to +174
// Fix savepath for torrents with subfolder
// directory contains the whole torrent path, which gives error in qBit.
// remove file.sourceDirInfo.name from full path in id.rtorrent directory
newPath := strings.ReplaceAll(rtFile.Directory, metaInfo.Name, "")

newFastResume.Path = newPath
newFastResume.SavePath = newPath
newFastResume.QbtSavePath = newPath
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.

⚠️ Potential issue | 🟠 Major

Path manipulation is fragile and may break in edge cases.

Using strings.ReplaceAll to remove the torrent name from the directory path can fail if:

  1. The torrent name appears multiple times in the path
  2. The name is a substring of a parent directory

Consider using strings.TrimSuffix or filepath.Dir for more robust path handling.

Apply this diff:

-			// Fix savepath for torrents with subfolder
-			// directory contains the whole torrent path, which gives error in qBit.
-			// remove file.sourceDirInfo.name from full path in id.rtorrent directory
-			newPath := strings.ReplaceAll(rtFile.Directory, metaInfo.Name, "")
+			// Fix savepath for torrents with subfolder
+			// directory contains the whole torrent path, which gives error in qBit.
+			// remove the torrent name from the end of the path
+			newPath := strings.TrimSuffix(rtFile.Directory, string(filepath.Separator)+metaInfo.Name)
+			if newPath == rtFile.Directory {
+				// If no suffix was removed, try without separator (edge case)
+				newPath = strings.TrimSuffix(rtFile.Directory, metaInfo.Name)
+			}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Fix savepath for torrents with subfolder
// directory contains the whole torrent path, which gives error in qBit.
// remove file.sourceDirInfo.name from full path in id.rtorrent directory
newPath := strings.ReplaceAll(rtFile.Directory, metaInfo.Name, "")
newFastResume.Path = newPath
newFastResume.SavePath = newPath
newFastResume.QbtSavePath = newPath
// Fix savepath for torrents with subfolder
// directory contains the whole torrent path, which gives error in qBit.
// remove the torrent name from the end of the path
newPath := strings.TrimSuffix(rtFile.Directory, string(filepath.Separator)+metaInfo.Name)
if newPath == rtFile.Directory {
// If no suffix was removed, try without separator (edge case)
newPath = strings.TrimSuffix(rtFile.Directory, metaInfo.Name)
}
newFastResume.Path = newPath
newFastResume.SavePath = newPath
newFastResume.QbtSavePath = newPath
🤖 Prompt for AI Agents
internal/clientmigrate/rtorrent.go around lines 167 to 174: current code uses
strings.ReplaceAll(rtFile.Directory, metaInfo.Name, "") to strip the torrent
name from the directory which is fragile; instead compute the intended parent
directory robustly (e.g., if rtFile.Directory ends with the torrent name, use
strings.TrimSuffix to remove that trailing segment, or use filepath.Dir on a
constructed path to get the parent) and assign that result to
newFastResume.Path, newFastResume.SavePath and newFastResume.QbtSavePath; ensure
you preserve leading/trailing separators correctly (use filepath.Clean and
filepath.Separator aware functions) and only remove a single trailing occurrence
so names embedded in parent folders are not altered.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (6)
internal/clientmigrate/migrate.go (1)

57-96: Backup directory must be created before writing archives.

The qbt_backup directory is never created before calling os.Create() on lines 80 and 112, causing backups to fail with ENOENT.

Apply this diff:

 	// Backup data before running
 	if !skipBackup {
 		log.Info().Msg("prepare to backup torrent data before import..")
 
 		timeStamp := time.Now().Format("20060102150405")
+
+		backupDir := "qbt_backup"
+		if err := MkDirIfNotExists(backupDir); err != nil {
+			return errors.Wrap(err, "could not create backup directory")
+		}
 
-		sourceBackupArchive := filepath.Join("qbt_backup", source+"_backup_"+timeStamp+".tar.gz")
-		qbitBackupArchive := filepath.Join("qbt_backup", "qBittorrent_backup_"+timeStamp+".tar.gz")
+		sourceBackupArchive := filepath.Join(backupDir, source+"_backup_"+timeStamp+".tar.gz")
+		qbitBackupArchive := filepath.Join(backupDir, "qBittorrent_backup_"+timeStamp+".tar.gz")

Consider also anchoring backupDir to a more predictable location (e.g., near qbitDir or the app's data directory) rather than the current working directory.

internal/clientmigrate/rtorrent.go (5)

76-79: Dry-run log message is misleading.

The message "successfully imported" incorrectly implies that an import occurred, when dry-run mode performs no actual writes.

Apply this diff:

 		if i.opts.DryRun {
-			log.Info().Msgf("dry-run: (%d/%d) successfully imported: %s", positionNum, totalJobs, torrentID)
+			log.Info().Msgf("dry-run: (%d/%d) would import: %s", positionNum, totalJobs, torrentID)
 			continue
 		}

217-219: Active time calculation is fundamentally incorrect.

getActiveTime treats RTorrent's seedingtime as a start timestamp and calculates duration from then until now using time.Since(). This incorrectly assumes continuous activity, overestimating if the torrent was paused or stopped. qBittorrent's active_time and seeding_time fields expect cumulative seconds of actual activity, not a calculated duration from a start time.

If seedingtime is already a cumulative duration in seconds, use it directly:

-func getActiveTime(t string) int64 {
-	return int64(time.Since(time.Unix(strToIntClean(t), 0)).Seconds())
-}
+func getActiveTime(t string) int64 {
+	// RTorrent stores cumulative seconds directly
+	return strToIntClean(t)
+}

If seedingtime is truly a timestamp and you cannot reconstruct cumulative time, default to 0 rather than using an incorrect calculation.


121-121: FinishedTime must use seconds, not minutes.

qBittorrent (via libtorrent) expects finished_time as cumulative seconds, not minutes. Using .Minutes() creates a value off by a factor of 60.

Apply this diff:

-			FinishedTime:              int64(time.Since(time.Unix(rtFile.TimestampFinished, 0)).Minutes()),
+			FinishedTime:              int64(time.Since(time.Unix(rtFile.TimestampFinished, 0)).Seconds()),

169-176: Path manipulation using ReplaceAll is fragile.

Using strings.ReplaceAll to remove the torrent name from the directory path can fail if:

  1. The torrent name appears multiple times in the path
  2. The name is a substring of a parent directory

Apply this diff:

 			// Fix savepath for torrents with subfolder
 			// directory contains the whole torrent path, which gives error in qBit.
-			// remove file.sourceDirInfo.name from full path in id.rtorrent directory
-			newPath := strings.ReplaceAll(rtFile.Directory, metaInfo.Name, "")
+			// remove the torrent name from the end of the path
+			newPath := strings.TrimSuffix(rtFile.Directory, string(filepath.Separator)+metaInfo.Name)
+			if newPath == rtFile.Directory {
+				// If no suffix was removed, try without separator (edge case)
+				newPath = strings.TrimSuffix(rtFile.Directory, metaInfo.Name)
+			}

197-206: File write errors halt entire migration unnecessarily.

If encoding the fastresume file or copying the torrent file fails for a single torrent, the entire migration stops, preventing other valid torrents from being processed.

Apply this diff to log and continue instead:

 		// copy torrent file
 		fastResumeOutFile := filepath.Join(i.opts.QbitDir, torrentID+".fastresume")
 		if err = newFastResume.Encode(fastResumeOutFile); err != nil {
 			log.Error().Err(err).Msgf("Could not create qBittorrent fastresume file %s error: %q", fastResumeOutFile, err)
-			return err
+			continue
 		}
 
 		if err = CopyFile(match, torrentOutFile); err != nil {
 			log.Error().Err(err).Msgf("Could copy qBittorrent torrent file %s error %q", torrentOutFile, err)
-			return err
+			continue
 		}
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between ae8cec0 and 90f20c8.

📒 Files selected for processing (4)
  • cmd/qui/main.go (3 hunks)
  • internal/clientmigrate/deluge.go (1 hunks)
  • internal/clientmigrate/migrate.go (1 hunks)
  • internal/clientmigrate/rtorrent.go (1 hunks)
🧰 Additional context used
🧠 Learnings (2)
📓 Common learnings
Learnt from: Audionut
Repo: autobrr/qui PR: 553
File: internal/services/crossseed/service.go:1045-1082
Timestamp: 2025-11-06T12:11:04.963Z
Learning: The autobrr/qui project uses a custom go-qbittorrent client library (github.com/autobrr/go-qbittorrent) that supports both "paused" and "stopped" parameters when adding torrents via the options map. Both parameters should be set together when controlling torrent start state, as seen in internal/services/crossseed/service.go and throughout the codebase.
📚 Learning: 2025-11-06T12:11:04.963Z
Learnt from: Audionut
Repo: autobrr/qui PR: 553
File: internal/services/crossseed/service.go:1045-1082
Timestamp: 2025-11-06T12:11:04.963Z
Learning: The autobrr/qui project uses a custom go-qbittorrent client library (github.com/autobrr/go-qbittorrent) that supports both "paused" and "stopped" parameters when adding torrents via the options map. Both parameters should be set together when controlling torrent start state, as seen in internal/services/crossseed/service.go and throughout the codebase.

Applied to files:

  • internal/clientmigrate/rtorrent.go
🧬 Code graph analysis (4)
cmd/qui/main.go (1)
internal/clientmigrate/migrate.go (2)
  • New (32-34)
  • Options (16-22)
internal/clientmigrate/migrate.go (2)
internal/clientmigrate/rtorrent.go (1)
  • NewRTorrentImporter (23-25)
internal/clientmigrate/deluge.go (1)
  • NewDelugeImporter (19-21)
internal/clientmigrate/deluge.go (2)
internal/clientmigrate/migrate.go (3)
  • Options (16-22)
  • MkDirIfNotExists (154-168)
  • CopyFile (175-212)
internal/qbittorrent/fastresume.go (1)
  • Fastresume (14-86)
internal/clientmigrate/rtorrent.go (2)
internal/clientmigrate/migrate.go (3)
  • Options (16-22)
  • ClientMigrater (24-26)
  • CopyFile (175-212)
internal/qbittorrent/fastresume.go (1)
  • Fastresume (14-86)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Run tests

Comment thread internal/clientmigrate/deluge.go
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (1)
internal/clientmigrate/migrate.go (1)

56-130: Backup directory "qbt_backup" must be created before use.

The backup logic hardcodes "qbt_backup" at lines 62-63 but never creates this directory. When os.Create() is called at lines 79 and 111, it will fail with ENOENT if qbt_backup doesn't already exist, causing migrations to fail by default.

🔎 Proposed fix
 	// Backup data before running
 	if !skipBackup {
 		log.Info().Msg("prepare to backup torrent data before import..")
 
 		timeStamp := time.Now().Format("20060102150405")
+
+		backupDir := "qbt_backup"
+		if err := MkDirIfNotExists(backupDir); err != nil {
+			return errors.Wrap(err, "could not create backup directory")
+		}
 
-		sourceBackupArchive := filepath.Join("qbt_backup", source+"_backup_"+timeStamp+".tar.gz")
-		qbitBackupArchive := filepath.Join("qbt_backup", "qBittorrent_backup_"+timeStamp+".tar.gz")
+		sourceBackupArchive := filepath.Join(backupDir, source+"_backup_"+timeStamp+".tar.gz")
+		qbitBackupArchive := filepath.Join(backupDir, "qBittorrent_backup_"+timeStamp+".tar.gz")

Additionally, consider anchoring backupDir to a predictable location (e.g., near qbitDir or the app's data directory) rather than the current working directory, especially when run as a service.

🧹 Nitpick comments (3)
internal/clientmigrate/transmission.go (3)

65-65: Use Info or Debug level for expected skip behavior.

Logging at Error level when a torrent already exists is misleading—this is expected behavior during incremental migrations or re-runs, not an error condition.

🔎 Suggested change
-		if _, err = os.Stat(torrentOutFile); err == nil {
-			log.Error().Err(err).Msgf("(%d/%d) %s Torrent already exists, skipping", positionNum, totalJobs, torrentOutFile)
+		if _, err = os.Stat(torrentOutFile); err == nil {
+			log.Info().Msgf("(%d/%d) %s Torrent already exists, skipping", positionNum, totalJobs, torrentOutFile)
 			continue
 		}

Note: Also remove .Err(err) since err is nil in this branch.


94-94: Remove or demote debug log statement.

This verbose log statement (logging the entire resume file structure) should either be removed or changed to Debug level to avoid cluttering production logs.

🔎 Suggested change
-		log.Info().Msgf("resume file, %v", resumeFile)
+		log.Debug().Msgf("resume file, %v", resumeFile)

Or remove it entirely if it was only needed during development.


161-165: Path manipulation with strings.ReplaceAll is fragile and may fail on edge cases.

Using strings.ReplaceAll(resumeFile.Destination, metaInfo.Name, "") to extract the base path can produce incorrect results if metaInfo.Name appears multiple times in the path or not at all. A safer approach is to use filepath.Dir() or strings.TrimSuffix() after verifying the torrent name is the last component.

🔎 Safer alternative
-		// Fix savepath for torrents with subfolder
-		// directory contains the whole torrent path, which gives error in qBit.
-		// remove file.sourceDirInfo.name from full path directory
-		newPath := strings.ReplaceAll(resumeFile.Destination, metaInfo.Name, "")
+		// Fix savepath for torrents with subfolder: strip the torrent name if it's the last component
+		newPath := resumeFile.Destination
+		if strings.HasSuffix(newPath, metaInfo.Name) {
+			newPath = strings.TrimSuffix(newPath, metaInfo.Name)
+			newPath = filepath.Clean(newPath)
+		}

 		newFastResume.Path = newPath

This avoids unintended replacements and ensures the path is well-formed.

📜 Review details

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 70c5a2e and a5e265f.

📒 Files selected for processing (4)
  • cmd/qui/main.go
  • go.mod
  • internal/clientmigrate/migrate.go
  • internal/clientmigrate/transmission.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • go.mod
🧰 Additional context used
🧠 Learnings (3)
📚 Learning: 2025-12-28T18:44:10.496Z
Learnt from: s0up4200
Repo: autobrr/qui PR: 876
File: internal/logstream/hub_test.go:188-192
Timestamp: 2025-12-28T18:44:10.496Z
Learning: In Go 1.25 (Aug 2025), use wg.Go(func()) to spawn a goroutine and automate the Add/Done lifecycle. Replace manual patterns like wg.Add(1); go func(){ defer wg.Done(); ... }() with wg.Go(func(){ ... }). Ensure the codebase builds with Go 1.25+ and apply this in relevant Go files (e.g., internal/logstream/hub_test.go). If targeting older Go versions, maintain the existing pattern.

Applied to files:

  • cmd/qui/main.go
  • internal/clientmigrate/migrate.go
  • internal/clientmigrate/transmission.go
📚 Learning: 2025-11-25T22:46:03.762Z
Learnt from: s0up4200
Repo: autobrr/qui PR: 632
File: internal/backups/service.go:1401-1404
Timestamp: 2025-11-25T22:46:03.762Z
Learning: In qui's backup service (internal/backups/service.go), background torrent downloads initiated during manifest import intentionally use a fire-and-forget pattern with the shared service context (s.ctx). Per-run cancellation is not needed, as orphaned downloads completing after run deletion are considered harmless and acceptable. This design prioritizes simplicity over per-run lifecycle management for background downloads.

Applied to files:

  • internal/clientmigrate/migrate.go
  • internal/clientmigrate/transmission.go
📚 Learning: 2025-11-06T12:11:04.963Z
Learnt from: Audionut
Repo: autobrr/qui PR: 553
File: internal/services/crossseed/service.go:1045-1082
Timestamp: 2025-11-06T12:11:04.963Z
Learning: The autobrr/qui project uses a custom go-qbittorrent client library (github.com/autobrr/go-qbittorrent) that supports both "paused" and "stopped" parameters when adding torrents via the options map. Both parameters should be set together when controlling torrent start state, as seen in internal/services/crossseed/service.go and throughout the codebase.

Applied to files:

  • internal/clientmigrate/transmission.go
🧬 Code graph analysis (2)
cmd/qui/main.go (1)
internal/clientmigrate/migrate.go (2)
  • New (32-45)
  • Options (15-21)
internal/clientmigrate/migrate.go (3)
internal/clientmigrate/deluge.go (1)
  • NewDelugeImporter (19-21)
internal/clientmigrate/rtorrent.go (1)
  • NewRTorrentImporter (23-25)
internal/clientmigrate/transmission.go (1)
  • NewTransmissionImporter (21-23)
🔇 Additional comments (4)
internal/clientmigrate/transmission.go (1)

177-177: Verify that ConvertFilePriority cannot fail or handle its potential errors.

Line 177 calls newFastResume.ConvertFilePriority(len(metaInfo.Files)) without checking for errors. If this method can fail (e.g., due to invalid input), the migration could produce corrupted fastresume data.

Please confirm whether ConvertFilePriority can return an error or panic. If so, add appropriate error handling:

if err := newFastResume.ConvertFilePriority(len(metaInfo.Files)); err != nil {
    log.Error().Err(err).Msgf("Could not convert file priority for %s", torrentID)
    continue
}

If it's infallible, consider documenting that in the method or adding a comment here.

cmd/qui/main.go (1)

400-454: LGTM! The migrate command is well-structured.

The command properly:

  • Validates the source argument against allowed values
  • Marks required flags (source-dir, qbit-dir)
  • Passes SkipBackup to Options (fixing the previous review issue)
  • Wraps errors with context

The implementation is clean and follows the project's CLI patterns.

internal/clientmigrate/migrate.go (2)

152-167: LGTM! MkDirIfNotExists is well-implemented.

The utility correctly checks for directory existence, creates it with appropriate permissions if missing, and handles errors properly.


169-211: LGTM! CopyFile follows best practices for safe file copying.

The implementation properly:

  • Handles deferred cleanup with error capture
  • Syncs data to disk before closing
  • Preserves source file mode

This is a robust file copy utility.

Comment thread internal/clientmigrate/transmission.go Outdated
@netlify
Copy link
Copy Markdown

netlify bot commented Jan 8, 2026

Deploy Preview for getqui canceled.

Name Link
🔨 Latest commit b6da30d
🔍 Latest deploy log https://app.netlify.com/projects/getqui/deploys/695fc14c042da500088eb464

@zze0s zze0s changed the base branch from main to develop January 8, 2026 15:12
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 5

🤖 Fix all issues with AI agents
In @internal/clientmigrate/rtorrent.go:
- Line 121: The FinishedTime computation uses Minutes() but should use Seconds()
for consistency; change the expression in the struct field FinishedTime from
int64(time.Since(time.Unix(rtFile.TimestampFinished, 0)).Minutes()) to use
.Seconds() instead (i.e., int64(time.Since(time.Unix(rtFile.TimestampFinished,
0)).Seconds())), ensuring the FinishedTime field and its source
rtFile.TimestampFinished match the seconds-based units used elsewhere like
getActiveTime()/transmission.go.
- Around line 216-219: The getActiveTime function uses
time.Since(time.Unix(...)) which is correct but obscures intent; change it to
explicitly compute elapsed seconds by parsing the input via strToIntClean(t)
into startTime, return 0 if startTime==0, otherwise return time.Now().Unix() -
startTime so the code clearly converts the rtorrent UNIX seeding timestamp into
elapsed seconds; update the getActiveTime function to use startTime and
time.Now().Unix() and keep using strToIntClean as the parser.

In @internal/clientmigrate/transmission.go:
- Around line 185-193: The qBittorrent importer currently aborts the whole
migration on fastresume or torrent copy errors by returning after
newFastResume.Encode(...) and CopyFile(...); change this to match the rtorrent
importer by logging the error (retain log.Error().Err(err).Msgf(...) with
contextual file names fastResumeOutFile and torrentOutFile) and then continue
processing the next torrent instead of returning; ensure the loop around these
calls (the function handling each match/torrent) does not exit on these errors
and optionally track a non-fatal error counter or aggregated error for final
reporting rather than immediate return.
🧹 Nitpick comments (8)
internal/clientmigrate/migrate.go (3)

47-82: Context parameter is unused.

The ctx parameter is not used within the method, nor is it passed to m.imp.Migrate(). Consider either:

  1. Updating the ClientMigrater interface to accept context: Migrate(context.Context) error
  2. Using the context to check for cancellation before/during the migration
  3. Removing the unused parameter if cancellation support isn't needed
♻️ Option 1: Update interface to accept context

Update the interface definition:

 type ClientMigrater interface {
-	Migrate() error
+	Migrate(context.Context) error
 }

Then pass context to the importer:

-	if err := m.imp.Migrate(); err != nil {
+	if err := m.imp.Migrate(ctx); err != nil {
 		return errors.Wrapf(err, "could not import from %s", source)
 	}

And update all implementers (RTorrentImport, DelugeImport, TransmissionImport) to accept and use the context.


119-147: Consider cleaning up partial archives on error.

If format.Archive fails, the partially written archive file remains on disk. Consider removing the incomplete archive to avoid confusion.

♻️ Proposed enhancement
 func (m Migrater) archiveDir(ctx context.Context, dir, archiveName string) error {
 	// map files on disk to their paths in the archive using default settings (second arg)
 	files, err := archives.FilesFromDisk(ctx, nil, map[string]string{
 		dir: "",
 	})
 	if err != nil {
 		return err
 	}
 
 	// create the output file we'll write to
 	out, err := os.Create(archiveName)
 	if err != nil {
 		return err
 	}
 	defer out.Close()
 
 	format := archives.CompressedArchive{
 		Compression: archives.Gz{},
 		Archival:    archives.Tar{},
 	}
 
 	// create the archive
 	err = format.Archive(ctx, out, files)
 	if err != nil {
+		os.Remove(archiveName) // Clean up partial archive
 		return errors.Wrapf(err, "could not create backup archive: %s", out.Name())
 	}
 
 	return nil
 }

149-164: Consider more restrictive directory permissions.

The function uses os.ModePerm (0777), which creates world-writable directories. Backup directories typically don't need to be world-accessible.

🔒 Proposed change
 func MkDirIfNotExists(dir string) error {
 	if _, err := os.Stat(dir); err != nil {
 		if os.IsNotExist(err) {
-			if err := os.MkdirAll(dir, os.ModePerm); err != nil {
+			if err := os.MkdirAll(dir, 0750); err != nil {
 				return err
 			}
 
 			return nil
 		}
 
 		return err
 	}
 
 	return nil
 }
internal/clientmigrate/rtorrent.go (3)

70-74: Use Info level for expected skip condition.

Skipping an already-imported torrent is expected behavior, not an error condition.

📝 Proposed fix
 		// If file already exists, skip
 		if _, err = os.Stat(torrentOutFile); err == nil {
-			log.Error().Err(err).Msgf("(%d/%d) %s Torrent already exists, skipping", positionNum, totalJobs, torrentOutFile)
+			log.Info().Msgf("(%d/%d) %s Torrent already exists, skipping", positionNum, totalJobs, torrentOutFile)
 			continue
 		}

169-176: Path manipulation could be more robust.

Using strings.ReplaceAll might replace multiple occurrences of the torrent name if it appears in parent directories. Consider using path manipulation functions for more predictable behavior.

🛠️ Proposed improvement
 			// Fix savepath for torrents with subfolder
 			// directory contains the whole torrent path, which gives error in qBit.
 			// remove file.sourceDirInfo.name from full path in id.rtorrent directory
-			newPath := strings.ReplaceAll(rtFile.Directory, metaInfo.Name, "")
+			// Use TrimSuffix to remove only the trailing torrent name
+			newPath := strings.TrimSuffix(rtFile.Directory, string(filepath.Separator) + metaInfo.Name)
+			if newPath == rtFile.Directory {
+				// If nothing was trimmed, the directory might not end with the torrent name
+				// Fall back to the directory as-is
+				newPath = rtFile.Directory
+			}

Alternatively, verify that the directory ends with the torrent name before trimming to avoid unexpected behavior.


277-288: Consider logging parse errors.

The function silently returns 0 on parse errors, which could hide data corruption. Consider logging a warning when parsing fails to aid troubleshooting.

📝 Proposed enhancement
 func strToIntClean(line string) int64 {
 	if line == "" {
 		return 0
 	}
 
 	s := strings.TrimSuffix(line, "\n")
 	i, err := strconv.ParseInt(s, 10, 64)
 	if err != nil {
+		log.Warn().Str("value", s).Msg("failed to parse integer from rtorrent data")
 		return 0
 	}
 	return i
 }
internal/clientmigrate/transmission.go (2)

25-26: Document Transmission directory structure assumption.

The code assumes Transmission stores torrents in a /torrents subdirectory under the source directory. This might not hold for all Transmission configurations. Consider documenting this requirement in the command help or validating the directory structure.


156-163: Path manipulation could be more robust.

Same concern as in rtorrent.go: using strings.ReplaceAll to remove the torrent name from the path could cause issues if the name appears multiple times. Consider using strings.TrimSuffix or path manipulation functions.

🛠️ Proposed improvement
 			// Fix savepath for torrents with subfolder
 			// directory contains the whole torrent path, which gives error in qBit.
 			// remove file.sourceDirInfo.name from full path directory
-			newPath := strings.ReplaceAll(resumeFile.Destination, metaInfo.Name, "")
+			newPath := strings.TrimSuffix(resumeFile.Destination, string(filepath.Separator) + metaInfo.Name)
+			if newPath == resumeFile.Destination {
+				newPath = resumeFile.Destination
+			}
📜 Review details

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between a5e265f and 25c1be7.

⛔ Files ignored due to path filters (1)
  • go.sum is excluded by !**/*.sum
📒 Files selected for processing (6)
  • cmd/qui/main.go
  • go.mod
  • internal/clientmigrate/deluge.go
  • internal/clientmigrate/migrate.go
  • internal/clientmigrate/rtorrent.go
  • internal/clientmigrate/transmission.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • internal/clientmigrate/deluge.go
🧰 Additional context used
🧠 Learnings (7)
📚 Learning: 2025-12-28T18:44:10.496Z
Learnt from: s0up4200
Repo: autobrr/qui PR: 876
File: internal/logstream/hub_test.go:188-192
Timestamp: 2025-12-28T18:44:10.496Z
Learning: In Go 1.25 (Aug 2025), use wg.Go(func()) to spawn a goroutine and automate the Add/Done lifecycle. Replace manual patterns like wg.Add(1); go func(){ defer wg.Done(); ... }() with wg.Go(func(){ ... }). Ensure the codebase builds with Go 1.25+ and apply this in relevant Go files (e.g., internal/logstream/hub_test.go). If targeting older Go versions, maintain the existing pattern.

Applied to files:

  • cmd/qui/main.go
  • internal/clientmigrate/migrate.go
  • internal/clientmigrate/transmission.go
  • internal/clientmigrate/rtorrent.go
📚 Learning: 2025-11-25T22:46:03.762Z
Learnt from: s0up4200
Repo: autobrr/qui PR: 632
File: internal/backups/service.go:1401-1404
Timestamp: 2025-11-25T22:46:03.762Z
Learning: In qui's backup service (internal/backups/service.go), background torrent downloads initiated during manifest import intentionally use a fire-and-forget pattern with the shared service context (s.ctx). Per-run cancellation is not needed, as orphaned downloads completing after run deletion are considered harmless and acceptable. This design prioritizes simplicity over per-run lifecycle management for background downloads.

Applied to files:

  • internal/clientmigrate/migrate.go
  • internal/clientmigrate/transmission.go
  • internal/clientmigrate/rtorrent.go
📚 Learning: 2025-11-21T21:11:50.633Z
Learnt from: s0up4200
Repo: autobrr/qui PR: 625
File: internal/qbittorrent/sync_manager.go:1112-1123
Timestamp: 2025-11-21T21:11:50.633Z
Learning: In internal/qbittorrent/sync_manager.go, the GetCachedFilesBatch interface documentation (around line 39-40) should specify "uppercase hex" instead of "lowercase hex" to match the actual normalization practice used throughout the codebase (e.g., normalizeHash in internal/services/crossseed/service.go uses strings.ToUpper, and hash filtering uses uppercase).

Applied to files:

  • internal/clientmigrate/transmission.go
📚 Learning: 2025-11-28T20:32:30.126Z
Learnt from: s0up4200
Repo: autobrr/qui PR: 641
File: internal/services/crossseed/service.go:209-212
Timestamp: 2025-11-28T20:32:30.126Z
Learning: Repo: autobrr/qui PR: 641
File: internal/services/crossseed/service.go
Learning: The cross-seed recheck-resume worker intentionally runs for the process lifetime and keys pending entries by hash only. This is acceptable under the current constraint that background seeded-search runs operate on a single instance at a time; graceful shutdown and instanceID|hash keying are deferred by design.

Applied to files:

  • internal/clientmigrate/transmission.go
  • internal/clientmigrate/rtorrent.go
📚 Learning: 2025-11-06T12:11:04.963Z
Learnt from: Audionut
Repo: autobrr/qui PR: 553
File: internal/services/crossseed/service.go:1045-1082
Timestamp: 2025-11-06T12:11:04.963Z
Learning: The autobrr/qui project uses a custom go-qbittorrent client library (github.com/autobrr/go-qbittorrent) that supports both "paused" and "stopped" parameters when adding torrents via the options map. Both parameters should be set together when controlling torrent start state, as seen in internal/services/crossseed/service.go and throughout the codebase.

Applied to files:

  • internal/clientmigrate/transmission.go
  • internal/clientmigrate/rtorrent.go
📚 Learning: 2025-12-11T08:40:01.329Z
Learnt from: s0up4200
Repo: autobrr/qui PR: 746
File: internal/services/reannounce/service.go:480-481
Timestamp: 2025-12-11T08:40:01.329Z
Learning: In autobrr/qui's internal/services/reannounce/service.go, the hasHealthyTracker, getProblematicTrackers, and getHealthyTrackers functions intentionally match qbrr's lenient tracker health logic (skip unregistered trackers and check if any other tracker is healthy) rather than go-qbittorrent's strict isTrackerStatusOK logic (which treats unregistered as an immediate failure). For multi-tracker torrents, if one tracker is working, reannouncing won't help. The duplication of the health check logic across these three functions is acceptable as it's a simple one-liner, and extracting it would add unnecessary complexity.

Applied to files:

  • internal/clientmigrate/rtorrent.go
📚 Learning: 2025-11-28T22:21:20.730Z
Learnt from: s0up4200
Repo: autobrr/qui PR: 641
File: internal/services/crossseed/service.go:2415-2457
Timestamp: 2025-11-28T22:21:20.730Z
Learning: Repo: autobrr/qui PR: 641
File: internal/services/crossseed/service.go
Learning: The determineSavePath function intentionally includes a contentLayout string parameter for future/content-layout branching and API consistency. Its presence is by design even if unused in the current body; do not flag as an issue in reviews.

Applied to files:

  • internal/clientmigrate/rtorrent.go
🧬 Code graph analysis (4)
cmd/qui/main.go (1)
internal/clientmigrate/migrate.go (2)
  • New (32-45)
  • Options (15-21)
internal/clientmigrate/migrate.go (3)
internal/clientmigrate/deluge.go (1)
  • NewDelugeImporter (19-21)
internal/clientmigrate/rtorrent.go (1)
  • NewRTorrentImporter (23-25)
internal/clientmigrate/transmission.go (1)
  • NewTransmissionImporter (21-23)
internal/clientmigrate/transmission.go (2)
internal/clientmigrate/migrate.go (3)
  • Options (15-21)
  • ClientMigrater (23-25)
  • CopyFile (171-208)
internal/qbittorrent/fastresume.go (1)
  • Fastresume (14-86)
internal/clientmigrate/rtorrent.go (2)
internal/clientmigrate/migrate.go (3)
  • Options (15-21)
  • ClientMigrater (23-25)
  • CopyFile (171-208)
internal/qbittorrent/fastresume.go (1)
  • Fastresume (14-86)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Run tests
🔇 Additional comments (13)
cmd/qui/main.go (3)

402-419: LGTM! Well-structured command definition.

The command properly validates the source client argument against a whitelist and provides helpful examples for each supported client.


421-434: LGTM! Flags are well-defined.

The required flags are properly marked, and the help text clearly describes each option.


436-453: LGTM! Clean error handling and integration.

The implementation correctly constructs options from flags, instantiates the migrator, and wraps errors with context. The command context is passed to Migrate for potential cancellation support.

internal/clientmigrate/migrate.go (2)

84-117: LGTM! Backup logic is well-structured.

The backup properly creates timestamped archives of both source and destination directories before migration. The context is correctly passed to archiveDir for cancellation support during archive creation.


166-208: LGTM! Robust file copy implementation.

The function properly handles errors, syncs data to disk, and preserves file permissions. The deferred close with error capture ensures cleanup happens even on failure.

internal/clientmigrate/rtorrent.go (4)

32-56: LGTM! Thorough source directory validation.

The validation properly checks for directory existence, type, and available torrent files, with clear error messages for each failure case.


81-106: LGTM! Proper error handling with graceful degradation.

Errors are logged and the loop continues, ensuring that one corrupted torrent doesn't abort the entire migration.


185-214: LGTM! Proper finalization and output.

The fastresume encoding and file copying are correctly handled with appropriate error logging and progress tracking.


222-237: LGTM! Tracker conversion handles DHT exclusion properly.

The function correctly filters out DHT and disabled trackers, converting to qBittorrent's tier-based tracker format.

internal/clientmigrate/transmission.go (3)

63-72: LGTM! Correct log level for expected skip.

Unlike the rtorrent importer, this correctly uses log.Info() when skipping existing torrents.


203-215: LGTM! Standard bencode decode pattern.

The function properly reads and decodes the Transmission resume file with appropriate error propagation.


217-266: LGTM! Comprehensive Transmission resume file structure.

The types properly map Transmission's resume file format with correct bencode tags.

go.mod (1)

26-26: Packages are current and secure.

Both packages are at their latest versions and free from known vulnerabilities:

  • github.com/mholt/archives v0.1.5 is the latest version (released Oct 2025) with no CVEs
  • github.com/zeebo/bencode v1.0.0 is the current version with no CVEs; the library is stable and maintenance-focused (last update Aug 2018), which is appropriate for a mature encoding implementation

Comment thread internal/clientmigrate/migrate.go
Comment thread internal/clientmigrate/rtorrent.go Outdated
Comment thread internal/clientmigrate/rtorrent.go Outdated
FileFormat: "libtorrent resume file",
FileVersion: 1,
FilePriority: []int{},
FinishedTime: int64(time.Since(time.Unix(resumeFile.DoneDate, 0)).Seconds()),
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.

⚠️ Potential issue | 🟡 Minor

Inconsistent FinishedTime calculation across importers.

This importer uses .Seconds() on line 108, while rtorrent.go uses .Minutes() on line 121. Both should use the same unit for consistency. Verify which is correct according to qBittorrent's expected format.

Based on learnings, this appears to be a discrepancy that should be reconciled for consistency.

Comment thread internal/clientmigrate/transmission.go
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In @internal/clientmigrate/rtorrent.go:
- Around line 203-206: The error log message in the CopyFile error branch uses a
typo "Could copy" instead of "Could not copy"; update the Msgf call inside the
error handling after CopyFile(match, torrentOutFile) so the message reads "Could
not copy qBittorrent torrent file %s error %q" (retain the use of
log.Error().Err(err).Msgf, and keep the same arguments match/torrentOutFile and
err).
- Around line 70-74: The log call inside the os.Stat success branch is
incorrect: when os.Stat(torrentOutFile) returns nil, err is nil, so remove the
erroneous .Err(err) and demote the severity from log.Error to log.Warn to
reflect an expected skip; update the logging statement that references
torrentOutFile, positionNum, and totalJobs (the block around the os.Stat check
in internal/clientmigrate/rtorrent.go) to use log.Warn().Msgf(...) without
attaching Err(err).
🧹 Nitpick comments (4)
internal/clientmigrate/migrate.go (3)

23-24: Consider adding context parameter to the interface.

The Migrate(ctx context.Context) method on line 49 receives a context but cannot pass it to m.imp.Migrate() on line 75 because the ClientMigrater interface doesn't accept context. This prevents proper cancellation and timeout support during potentially long-running migration operations.

♻️ Proposed fix to add context support
 type ClientMigrater interface {
-	Migrate() error
+	Migrate(ctx context.Context) error
 }

Note: This will require updating all implementations (DelugeImporter, RTorrentImporter, TransmissionImporter) to accept the context parameter.


42-43: Consider returning an error instead of calling log.Fatal().

Using log.Fatal() terminates the entire program, which may not be desirable in library code. While acceptable for CLI usage, returning an error would provide better composability and allow callers to handle unsupported sources gracefully.

♻️ Alternative approach
-func New(opts Options) Migrater {
+func New(opts Options) (Migrater, error) {
 	m := Migrater{opts: opts}
 
 	switch m.opts.Source {
 	case "deluge":
 		m.imp = NewDelugeImporter(m.opts)
 	case "rtorrent":
 		m.imp = NewRTorrentImporter(m.opts)
 	case "transmission":
 		m.imp = NewTransmissionImporter(m.opts)
 	default:
-		log.Fatal().Str("source", m.opts.Source).Msg("unsupported source client")
+		return m, errors.Errorf("unsupported source client: %s", m.opts.Source)
 	}
 
-	return m
+	return m, nil
 }

152-166: Consider using more restrictive permissions for backup directory.

Line 155 uses os.ModePerm (0777), which grants read, write, and execute permissions to all users. For security-sensitive backup directories containing torrent data, consider using more restrictive permissions like 0755 or 0700.

♻️ Proposed fix
 func MkDirIfNotExists(dir string) error {
 	if _, err := os.Stat(dir); err != nil {
 		if os.IsNotExist(err) {
-			if err := os.MkdirAll(dir, os.ModePerm); err != nil {
+			if err := os.MkdirAll(dir, 0755); err != nil {
 				return err
 			}
 
 			return nil
 		}
internal/clientmigrate/rtorrent.go (1)

169-176: Path manipulation using string replacement is fragile.

Line 172 uses strings.ReplaceAll to remove the torrent name from the directory path. This approach is fragile and could fail if the torrent name appears multiple times in the path or contains special characters.

♻️ More robust alternative using filepath operations
 			// Fix savepath for torrents with subfolder
 			// directory contains the whole torrent path, which gives error in qBit.
 			// remove file.sourceDirInfo.name from full path in id.rtorrent directory
-			newPath := strings.ReplaceAll(rtFile.Directory, metaInfo.Name, "")
+			// If the directory ends with the torrent name, remove it
+			newPath := rtFile.Directory
+			if strings.HasSuffix(rtFile.Directory, string(filepath.Separator)+metaInfo.Name) {
+				newPath = filepath.Dir(rtFile.Directory)
+			}
 
 			newFastResume.Path = newPath
 			newFastResume.SavePath = newPath
 			newFastResume.QbtSavePath = newPath

This approach is safer as it only removes the name if it's the final path component.

📜 Review details

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 25c1be7 and bf9e53c.

📒 Files selected for processing (3)
  • internal/clientmigrate/migrate.go
  • internal/clientmigrate/rtorrent.go
  • internal/clientmigrate/transmission.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • internal/clientmigrate/transmission.go
🧰 Additional context used
🧠 Learnings (6)
📚 Learning: 2025-11-25T22:46:03.762Z
Learnt from: s0up4200
Repo: autobrr/qui PR: 632
File: internal/backups/service.go:1401-1404
Timestamp: 2025-11-25T22:46:03.762Z
Learning: In qui's backup service (internal/backups/service.go), background torrent downloads initiated during manifest import intentionally use a fire-and-forget pattern with the shared service context (s.ctx). Per-run cancellation is not needed, as orphaned downloads completing after run deletion are considered harmless and acceptable. This design prioritizes simplicity over per-run lifecycle management for background downloads.

Applied to files:

  • internal/clientmigrate/migrate.go
  • internal/clientmigrate/rtorrent.go
📚 Learning: 2025-12-28T18:44:10.496Z
Learnt from: s0up4200
Repo: autobrr/qui PR: 876
File: internal/logstream/hub_test.go:188-192
Timestamp: 2025-12-28T18:44:10.496Z
Learning: In Go 1.25 (Aug 2025), use wg.Go(func()) to spawn a goroutine and automate the Add/Done lifecycle. Replace manual patterns like wg.Add(1); go func(){ defer wg.Done(); ... }() with wg.Go(func(){ ... }). Ensure the codebase builds with Go 1.25+ and apply this in relevant Go files (e.g., internal/logstream/hub_test.go). If targeting older Go versions, maintain the existing pattern.

Applied to files:

  • internal/clientmigrate/migrate.go
  • internal/clientmigrate/rtorrent.go
📚 Learning: 2025-11-06T12:11:04.963Z
Learnt from: Audionut
Repo: autobrr/qui PR: 553
File: internal/services/crossseed/service.go:1045-1082
Timestamp: 2025-11-06T12:11:04.963Z
Learning: The autobrr/qui project uses a custom go-qbittorrent client library (github.com/autobrr/go-qbittorrent) that supports both "paused" and "stopped" parameters when adding torrents via the options map. Both parameters should be set together when controlling torrent start state, as seen in internal/services/crossseed/service.go and throughout the codebase.

Applied to files:

  • internal/clientmigrate/rtorrent.go
📚 Learning: 2025-11-28T20:32:30.126Z
Learnt from: s0up4200
Repo: autobrr/qui PR: 641
File: internal/services/crossseed/service.go:209-212
Timestamp: 2025-11-28T20:32:30.126Z
Learning: Repo: autobrr/qui PR: 641
File: internal/services/crossseed/service.go
Learning: The cross-seed recheck-resume worker intentionally runs for the process lifetime and keys pending entries by hash only. This is acceptable under the current constraint that background seeded-search runs operate on a single instance at a time; graceful shutdown and instanceID|hash keying are deferred by design.

Applied to files:

  • internal/clientmigrate/rtorrent.go
📚 Learning: 2025-12-11T08:40:01.329Z
Learnt from: s0up4200
Repo: autobrr/qui PR: 746
File: internal/services/reannounce/service.go:480-481
Timestamp: 2025-12-11T08:40:01.329Z
Learning: In autobrr/qui's internal/services/reannounce/service.go, the hasHealthyTracker, getProblematicTrackers, and getHealthyTrackers functions intentionally match qbrr's lenient tracker health logic (skip unregistered trackers and check if any other tracker is healthy) rather than go-qbittorrent's strict isTrackerStatusOK logic (which treats unregistered as an immediate failure). For multi-tracker torrents, if one tracker is working, reannouncing won't help. The duplication of the health check logic across these three functions is acceptable as it's a simple one-liner, and extracting it would add unnecessary complexity.

Applied to files:

  • internal/clientmigrate/rtorrent.go
📚 Learning: 2025-11-28T22:21:20.730Z
Learnt from: s0up4200
Repo: autobrr/qui PR: 641
File: internal/services/crossseed/service.go:2415-2457
Timestamp: 2025-11-28T22:21:20.730Z
Learning: Repo: autobrr/qui PR: 641
File: internal/services/crossseed/service.go
Learning: The determineSavePath function intentionally includes a contentLayout string parameter for future/content-layout branching and API consistency. Its presence is by design even if unused in the current body; do not flag as an issue in reviews.

Applied to files:

  • internal/clientmigrate/rtorrent.go
🧬 Code graph analysis (2)
internal/clientmigrate/migrate.go (3)
internal/clientmigrate/deluge.go (1)
  • NewDelugeImporter (19-21)
internal/clientmigrate/rtorrent.go (1)
  • NewRTorrentImporter (23-25)
internal/clientmigrate/transmission.go (1)
  • NewTransmissionImporter (21-23)
internal/clientmigrate/rtorrent.go (2)
internal/clientmigrate/migrate.go (3)
  • Options (15-21)
  • ClientMigrater (23-25)
  • CopyFile (173-210)
internal/qbittorrent/fastresume.go (1)
  • Fastresume (14-86)
🔇 Additional comments (4)
internal/clientmigrate/migrate.go (1)

173-210: LGTM! Robust file copy implementation.

The CopyFile implementation follows best practices:

  • Properly closes file handles with defer
  • Syncs data to disk before closing
  • Preserves source file permissions
  • Handles errors correctly with named return values
internal/clientmigrate/rtorrent.go (3)

217-223: LGTM! Correct time calculation.

The getActiveTime function correctly converts rTorrent's seeding start timestamp to elapsed seconds, with proper handling of the zero case.


226-241: LGTM! Proper tracker conversion.

The convertTrackers function correctly:

  • Skips DHT pseudo-trackers
  • Filters for enabled trackers only
  • Converts to qBittorrent's nested array format

281-292: LGTM! Defensive string-to-int conversion.

The strToIntClean function handles edge cases appropriately by returning 0 for empty strings and parse errors, which is suitable for timestamp fields that may be unset.

Comment thread internal/clientmigrate/rtorrent.go
Comment thread internal/clientmigrate/rtorrent.go
@zze0s zze0s changed the title feat(cli): migrate Deluge and rTorrent to qBittorrent with stats feat(cli): migrate Deluge, rTorrent and Transmission to qBittorrent with stats Jan 8, 2026
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 12

🧹 Nitpick comments (3)
internal/clientmigrate/deluge.go (1)

170-181: Replace interface{} with any per static analysis and coding guidelines.

The linter flags interface{} as replaceable by any (Go 1.18+). Also, the coding guideline says to avoid map[string]interface{} and use proper structs. Here the map is used because keys are dynamic torrent IDs — that's reasonable, but at minimum use any for the modernize lint fix.

♻️ Proposed fix
-func decodeFastresumeFile(path string) (map[string]interface{}, error) {
+func decodeFastresumeFile(path string) (map[string]any, error) {
 	dat, err := os.ReadFile(path)
 	if err != nil {
 		return nil, err
 	}
 
-	var fastresumeFile map[string]interface{}
+	var fastresumeFile map[string]any
 	if err := bencode.DecodeBytes(dat, &fastresumeFile); err != nil {
 		return nil, err
 	}

As per coding guidelines: **/*.go: Avoid map[string]interface{} — use proper structs.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/clientmigrate/deluge.go` around lines 170 - 181, The function
decodeFastresumeFile uses map[string]interface{} which the linter wants
modernized; change all occurrences in the function signature, local variable
declaration, and return types from map[string]interface{} to map[string]any
(e.g., update the return type of decodeFastresumeFile, the fastresumeFile
variable declaration, and any downstream uses expecting that type) so the code
compiles and satisfies the "use any" lint rule while preserving the existing
dynamic-key behavior.
go.mod (2)

56-56: Consider the dependency footprint and supply chain implications.

The addition of github.com/mholt/archives introduces 13 new indirect dependencies, primarily for compression and archive format support. While this is expected for a multi-format archive library, it significantly expands the dependency tree and supply chain surface area.

Consider whether all archive format support is necessary for the migration feature, or if a more focused library with fewer transitive dependencies could suffice. If the broad format support is required, ensure you have:

  • Dependency scanning in CI/CD pipelines
  • Regular dependency updates via Dependabot or Renovate
  • SBOM generation for supply chain visibility

Also applies to: 61-63, 66-66, 76-76, 80-80, 84-85, 91-91, 94-94, 101-101, 111-111

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@go.mod` at line 56, The new dependency github.com/mholt/archives (and the
indirect github.com/STARRY-S/zip entry) increases transitive dependencies;
decide whether full multi-format support is required and if not replace it with
a narrower library or vendor only needed codecs, otherwise add CI/ops controls:
add dependency scanning to pipelines, enable Dependabot/Renovate for go.mod, and
generate an SBOM during builds. Locate the go.mod entries referencing
github.com/mholt/archives and github.com/STARRY-S/zip and either swap to a
smaller alternative or add CI config (dependency scanner) and
Dependabot/renovate config plus an SBOM step (e.g., syft) to your build
workflow.

66-66: Note the use of pseudo-versioned indirect dependencies with inactive upstream libraries.

Two indirect dependencies rely on pseudo-versioned commits rather than tagged releases:

  • github.com/dsnet/compress – last commit Sep 4, 2023 (dormant ~2.5 years)
  • go4.org – commits only in Feb 2023 and Jan 2026; no commits throughout 2024–2025

While github.com/mholt/archives (the direct parent dependency controlling these transitive versions) is actively maintained with recent activity through Oct 2025, the dormancy of these underlying libraries may limit future updates or security patches if vulnerabilities are discovered. Monitor these transitive dependencies for any security issues.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@go.mod` at line 66, The go.mod contains pseudo-versioned indirect modules
(github.com/dsnet/compress and go4.org) which are potentially dormant; update
go.mod to pin supported/tagged releases or explicitly require safer versions
and/or add replace directives to known-good forks if official tags are
unavailable, and try bumping the direct parent module
(github.com/mholt/archives) to a newer release so its transitive constraints
refresh; locate references to github.com/dsnet/compress, go4.org, and
github.com/mholt/archives in go.mod and replace or require explicit versions (or
add replace rules) and run go mod tidy to verify the graph and ensure
builds/tests pass.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@cmd/qui/main.go`:
- Around line 436-437: The calls to command.MarkFlagRequired("source-dir") and
command.MarkFlagRequired("qbit-dir") ignore returned errors causing a linter
failure; either handle the error or explicitly acknowledge it. Fix by capturing
each returned error (e := command.MarkFlagRequired(...)) and then use a
must-pattern (panic/log.Fatalf on non-nil) or assign to the blank identifier (_
= command.MarkFlagRequired(...)) to show the return value is intentionally
ignored; reference the MarkFlagRequired calls on the command variable to locate
and update the two lines.

In `@go.mod`:
- Line 26: The dependency list includes the unmaintained module
github.com/zeebo/bencode v1.0.0; review BitTorrent fastresume handling in your
code (places that import or call github.com/zeebo/bencode) and either (a)
replace it with a maintained bencode library (update go.mod to the new module
and adjust code to the new API), or (b) add a short justification comment and
documentation that confirms zeebo/bencode is acceptable for our use case; ensure
all imports, tests, and any call sites referencing github.com/zeebo/bencode are
updated to the chosen alternative or annotated with the decision.

In `@internal/clientmigrate/deluge.go`:
- Around line 152-159: The log lines in the qBittorrent migration block attach
the error twice by calling log.Error().Err(err) and also interpolating err with
%q in Msgf; update the two calls that reference fastResume.Encode
(fastResumeOutFile) and CopyFile (torrentOutFile) to remove the duplicated error
text — either keep .Err(err) and change Msgf to a plain descriptive string (e.g.
"Could not create qBittorrent fastresume file %s") or drop .Err(err) and include
the error only in the formatted message; adjust the Msgf calls and/or the
.Err(err) usage consistently so each log entry contains the error exactly once.
- Around line 88-92: The log call in the type-assertion block (where value is
checked and assigned to strValue) incorrectly uses the "%s" verb for the
interface{} value; change the formatting in the log.Error().Msgf call to use
"%v" (or "%#v" if you want Go-syntax) so non-string types render safely and
predictably when logging the variable named value in the deluge.go
type-assertion block.

In `@internal/clientmigrate/rtorrent.go`:
- Line 160: Update the inline comment formatting in
internal/clientmigrate/rtorrent.go: change the malformed comment "//if
file.Info.Files != nil {" to use a space after the slashes ("// if
file.Info.Files != nil {") so it satisfies the linter; locate the comment near
the handling of file.Info.Files in the function that processes rtorrent file
info and update only the comment text.
- Around line 197-206: The log calls for fastresume and torrent copy duplicate
the error output by using .Err(err) and also formatting err into the message;
update the two logging lines that follow newFastResume.Encode(fastResumeOutFile)
and CopyFile(match, torrentOutFile) to remove the error interpolation from Msgf
(use a static descriptive message that references fastResumeOutFile or
torrentOutFile) and rely on log.Error().Err(err).Msg(...) to include the error;
ensure you keep the existing context (fastResumeOutFile, torrentOutFile, match)
in the message but do not include %q or the err variable inside the format
string.
- Line 5: Replace use of path.Ext with filepath.Ext to ensure OS-native path
handling: remove the "path" import from the imports block and update the call
site that currently uses path.Ext (the rTorrent migration code where file
extension is computed) to call filepath.Ext instead; keep the existing filepath
import and update any references to the old package name.
- Around line 32-47: The RTorrentImport.Migrate method validates the source
directory but never ensures the qBittorrent output directory exists, causing
writes of .fastresume files to fail; after the sourceDirInfo.IsDir() check in
RTorrentImport.Migrate, call MkDirIfNotExists(i.opts.QbitDir) (same helper used
by the Deluge importer) and handle/return any error from that call so the
QbitDir is created before writing files.

In `@internal/clientmigrate/transmission.go`:
- Line 26: Replace the OS-unsafe string concatenation that builds torrentsDir
from i.opts.SourceDir (torrentsDir := i.opts.SourceDir + "/torrents") with a
call to filepath.Join to construct the path portably; import "path/filepath" if
not already imported and update the assignment to use
filepath.Join(i.opts.SourceDir, "torrents") in the transmission.go code where
torrentsDir is set.
- Line 147: Update the inline comment to follow linter style by adding a space
after the comment slashes: change the comment "//if file.Info.Files != nil {" to
"// if file.Info.Files != nil {" wherever it appears (search for the commented
check referencing file.Info.Files in transmission.go) so the linter no longer
flags `//if` formatting.
- Around line 184-193: The log lines in the block that calls
newFastResume.Encode and CopyFile double-report the error by using .Err(err) and
also formatting the error into the Msgf string; update the two error logs to use
one approach consistently: either keep .Err(err) and remove the %q/%v error
interpolation from the Msgf message, or remove .Err(err) and include the error
string in Msgf. Locate the calls to newFastResume.Encode(...) and CopyFile(...)
and modify the corresponding log.Error().Err(err).Msgf(...) calls so the error
is reported only once (prefer keeping .Err(err) and simplifying the Msgf text to
omit the error formatting).
- Around line 25-49: The TransmissionImport.Migrate function fails to ensure the
qBittorrent output directory exists; add a call to
MkDirIfNotExists(i.opts.QbitDir) (the same helper used by the Deluge importer)
after validating torrentsDir and before processing/writing torrents so writes
won't fail when QbitDir is missing; handle and return any error from
MkDirIfNotExists wrapped/descriptive (use errors.Wrapf) to match existing error
handling.

---

Duplicate comments:
In `@internal/clientmigrate/rtorrent.go`:
- Around line 76-79: The dry-run branch currently logs "successfully imported"
which incorrectly implies an actual import; update the log inside the
i.opts.DryRun branch (the call to log.Info().Msgf that uses positionNum,
totalJobs, torrentID) to clearly indicate this is a simulation (e.g. "dry-run:
would import" or "dry-run: simulated import") so it does not claim a real import
occurred; ensure you only change the log message text and keep the same
variables and formatting.
- Around line 169-176: The current logic uses
strings.ReplaceAll(rtFile.Directory, metaInfo.Name, "") which is fragile;
instead detect and remove the trailing torrent folder only if it exactly matches
the final path segment: use filepath.Clean(rtFile.Directory) and check if
filepath.Base(cleanDir) == metaInfo.Name, then set newPath =
strings.TrimSuffix(cleanDir, string(os.PathSeparator)+metaInfo.Name) (or use
filepath.Dir(cleanDir) to get parent) so newFastResume.Path,
newFastResume.SavePath and newFastResume.QbtSavePath get the parent directory
only when the basename equals metaInfo.Name; otherwise leave rtFile.Directory
unchanged.

In `@internal/clientmigrate/transmission.go`:
- Around line 156-163: The code uses strings.ReplaceAll to remove metaInfo.Name
from resumeFile.Destination which can wrongly remove occurrences elsewhere in
the path; replace this with a path-aware operation: use path/filepath to Clean
resumeFile.Destination, check if it ends with the torrent folder component (e.g.
use filepath.Base(resumeFile.Destination) == metaInfo.Name or
strings.HasSuffix(resumeFile.Destination,
string(os.PathSeparator)+metaInfo.Name)), and if so remove only that trailing
segment (e.g. newPath = filepath.Dir(resumeFile.Destination)); otherwise leave
the directory untouched; then assign newPath to newFastResume.Path,
newFastResume.SavePath and newFastResume.QbtSavePath so you only strip the final
folder safely.

---

Nitpick comments:
In `@go.mod`:
- Line 56: The new dependency github.com/mholt/archives (and the indirect
github.com/STARRY-S/zip entry) increases transitive dependencies; decide whether
full multi-format support is required and if not replace it with a narrower
library or vendor only needed codecs, otherwise add CI/ops controls: add
dependency scanning to pipelines, enable Dependabot/Renovate for go.mod, and
generate an SBOM during builds. Locate the go.mod entries referencing
github.com/mholt/archives and github.com/STARRY-S/zip and either swap to a
smaller alternative or add CI config (dependency scanner) and
Dependabot/renovate config plus an SBOM step (e.g., syft) to your build
workflow.
- Line 66: The go.mod contains pseudo-versioned indirect modules
(github.com/dsnet/compress and go4.org) which are potentially dormant; update
go.mod to pin supported/tagged releases or explicitly require safer versions
and/or add replace directives to known-good forks if official tags are
unavailable, and try bumping the direct parent module
(github.com/mholt/archives) to a newer release so its transitive constraints
refresh; locate references to github.com/dsnet/compress, go4.org, and
github.com/mholt/archives in go.mod and replace or require explicit versions (or
add replace rules) and run go mod tidy to verify the graph and ensure
builds/tests pass.

In `@internal/clientmigrate/deluge.go`:
- Around line 170-181: The function decodeFastresumeFile uses
map[string]interface{} which the linter wants modernized; change all occurrences
in the function signature, local variable declaration, and return types from
map[string]interface{} to map[string]any (e.g., update the return type of
decodeFastresumeFile, the fastresumeFile variable declaration, and any
downstream uses expecting that type) so the code compiles and satisfies the "use
any" lint rule while preserving the existing dynamic-key behavior.

Comment thread cmd/qui/main.go
Comment on lines +436 to +437
command.MarkFlagRequired("source-dir")
command.MarkFlagRequired("qbit-dir")
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.

⚠️ Potential issue | 🟡 Minor

Unchecked return values from MarkFlagRequired (linter failure).

The static analysis check fails because the error returns from MarkFlagRequired are discarded. While these only fail on programmer error (mismatched flag name), the CI pipeline will reject this.

🐛 Proposed fix — use must-pattern or assign to underscore explicitly
-	command.MarkFlagRequired("source-dir")
-	command.MarkFlagRequired("qbit-dir")
+	_ = command.MarkFlagRequired("source-dir")
+	_ = command.MarkFlagRequired("qbit-dir")
🧰 Tools
🪛 GitHub Check: backend

[failure] 437-437:
Error return value of command.MarkFlagRequired is not checked (errcheck)


[failure] 436-436:
Error return value of command.MarkFlagRequired is not checked (errcheck)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmd/qui/main.go` around lines 436 - 437, The calls to
command.MarkFlagRequired("source-dir") and command.MarkFlagRequired("qbit-dir")
ignore returned errors causing a linter failure; either handle the error or
explicitly acknowledge it. Fix by capturing each returned error (e :=
command.MarkFlagRequired(...)) and then use a must-pattern (panic/log.Fatalf on
non-nil) or assign to the blank identifier (_ = command.MarkFlagRequired(...))
to show the return value is intentionally ignored; reference the
MarkFlagRequired calls on the command variable to locate and update the two
lines.

Comment thread go.mod
github.com/klauspost/compress v1.18.3
github.com/lithammer/fuzzysearch v1.1.8
github.com/mat/besticon/v3 v3.21.0
github.com/mholt/archives v0.1.5
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.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's confirm the exact lines in go.mod
head -40 go.mod | tail -20

Repository: autobrr/qui

Length of output: 728


🌐 Web query:

github.com/mholt/archives latest release version security

💡 Result:

  • Latest release (github.com/mholt/archives): v0.1.5 (released Oct 13; GitHub “Latest” tag). [1]

    • pkg.go.dev also lists v0.1.5 as the latest tagged version (published Oct 9, 2025). [2]
  • Security status (repo-level):

    • No published GitHub Security Advisories for mholt/archives. [3]
    • No SECURITY.md policy is set in the repo. [4]

Sources: [1] [2] [3] [4]


🌐 Web query:

github.com/zeebo/bencode latest release version

💡 Result:

The latest tagged version of github.com/zeebo/bencode is v1.0.0 (tag date: Aug 19, 2018). [1]


⚠️ zeebo/bencode v1.0.0 is unmaintained—last release was August 2018.

While github.com/mholt/archives v0.1.5 is the current latest version and has no known security advisories, github.com/zeebo/bencode v1.0.0 is over 7 years old with no recent updates. For BitTorrent fastresume file handling, consider evaluating whether this library is still adequate or if a more actively maintained alternative exists. If this library is confirmed to be stable and suitable for your use case, document the decision; otherwise, investigate modern bencode implementations.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@go.mod` at line 26, The dependency list includes the unmaintained module
github.com/zeebo/bencode v1.0.0; review BitTorrent fastresume handling in your
code (places that import or call github.com/zeebo/bencode) and either (a)
replace it with a maintained bencode library (update go.mod to the new module
and adjust code to the new API), or (b) add a short justification comment and
documentation that confirms zeebo/bencode is acceptable for our use case; ensure
all imports, tests, and any call sites referencing github.com/zeebo/bencode are
updated to the chosen alternative or annotated with the decision.

Comment on lines +88 to +92
strValue, ok := value.(string)
if !ok {
log.Error().Msgf("Could not convert value %s to string", value)
continue
}
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.

⚠️ Potential issue | 🟡 Minor

Use %v instead of %s when formatting an any value.

Line 90 uses %s to format value which is interface{}/any. The %s verb may not produce useful output for non-string types; %v is safer.

🐛 Proposed fix
-			log.Error().Msgf("Could not convert value %s to string", value)
+			log.Error().Msgf("Could not convert value %v to string", value)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/clientmigrate/deluge.go` around lines 88 - 92, The log call in the
type-assertion block (where value is checked and assigned to strValue)
incorrectly uses the "%s" verb for the interface{} value; change the formatting
in the log.Error().Msgf call to use "%v" (or "%#v" if you want Go-syntax) so
non-string types render safely and predictably when logging the variable named
value in the deluge.go type-assertion block.

Comment on lines +152 to +159
if err = fastResume.Encode(fastResumeOutFile); err != nil {
log.Error().Err(err).Msgf("Could not create qBittorrent fastresume file %s error: %q", fastResumeOutFile, err)
continue
}

if err = CopyFile(fastResume.TorrentFilePath, torrentOutFile); err != nil {
log.Error().Err(err).Msgf("Could not copy qBittorrent torrent file %s error %q", torrentOutFile, err)
continue
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.

⚠️ Potential issue | 🟡 Minor

Error is logged twice — via .Err(err) and %q format verb.

Both lines 153 and 158 attach the error with .Err(err) and interpolate it again with %q in the message format string. This produces duplicate error text in the log output.

🐛 Proposed fix
 		if err = fastResume.Encode(fastResumeOutFile); err != nil {
-			log.Error().Err(err).Msgf("Could not create qBittorrent fastresume file %s error: %q", fastResumeOutFile, err)
+			log.Error().Err(err).Msgf("Could not create qBittorrent fastresume file %s", fastResumeOutFile)
 			continue
 		}
 
 		if err = CopyFile(fastResume.TorrentFilePath, torrentOutFile); err != nil {
-			log.Error().Err(err).Msgf("Could not copy qBittorrent torrent file %s error %q", torrentOutFile, err)
+			log.Error().Err(err).Msgf("Could not copy qBittorrent torrent file %s", torrentOutFile)
 			continue
 		}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/clientmigrate/deluge.go` around lines 152 - 159, The log lines in
the qBittorrent migration block attach the error twice by calling
log.Error().Err(err) and also interpolating err with %q in Msgf; update the two
calls that reference fastResume.Encode (fastResumeOutFile) and CopyFile
(torrentOutFile) to remove the duplicated error text — either keep .Err(err) and
change Msgf to a plain descriptive string (e.g. "Could not create qBittorrent
fastresume file %s") or drop .Err(err) and include the error only in the
formatted message; adjust the Msgf calls and/or the .Err(err) usage consistently
so each log entry contains the error exactly once.


import (
"os"
"path"
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.

⚠️ Potential issue | 🟡 Minor

Use filepath.Ext instead of path.Ext for OS-correct path handling.

The path package is for slash-separated (URL/POSIX) paths. On Windows, path.Ext may not correctly handle backslash-separated paths. Since filepath is already imported, use filepath.Ext on line 246 and drop the path import.

🐛 Proposed fix
 import (
 	"os"
-	"path"
 	"path/filepath"
 	"strconv"

And on line 246:

-	trimmed := strings.TrimSuffix(fileName, path.Ext(fileName))
+	trimmed := strings.TrimSuffix(fileName, filepath.Ext(fileName))
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
"path"
import (
"os"
"path/filepath"
"strconv"
)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/clientmigrate/rtorrent.go` at line 5, Replace use of path.Ext with
filepath.Ext to ensure OS-native path handling: remove the "path" import from
the imports block and update the call site that currently uses path.Ext (the
rTorrent migration code where file extension is computed) to call filepath.Ext
instead; keep the existing filepath import and update any references to the old
package name.

Comment on lines +197 to +206
fastResumeOutFile := filepath.Join(i.opts.QbitDir, torrentID+".fastresume")
if err = newFastResume.Encode(fastResumeOutFile); err != nil {
log.Error().Err(err).Msgf("Could not create qBittorrent fastresume file %s error: %q", fastResumeOutFile, err)
continue
}

if err = CopyFile(match, torrentOutFile); err != nil {
log.Error().Err(err).Msgf("Could not copy qBittorrent torrent file %s error %q", torrentOutFile, err)
continue
}
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.

⚠️ Potential issue | 🟡 Minor

Error is logged twice — via .Err(err) and %q format verb.

Same pattern as in deluge.go. Both lines 199 and 204 attach the error with .Err(err) and interpolate it again in the message string.

🐛 Proposed fix
 		if err = newFastResume.Encode(fastResumeOutFile); err != nil {
-			log.Error().Err(err).Msgf("Could not create qBittorrent fastresume file %s error: %q", fastResumeOutFile, err)
+			log.Error().Err(err).Msgf("Could not create qBittorrent fastresume file %s", fastResumeOutFile)
 			continue
 		}
 
 		if err = CopyFile(match, torrentOutFile); err != nil {
-			log.Error().Err(err).Msgf("Could not copy qBittorrent torrent file %s error %q", torrentOutFile, err)
+			log.Error().Err(err).Msgf("Could not copy qBittorrent torrent file %s", torrentOutFile)
 			continue
 		}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/clientmigrate/rtorrent.go` around lines 197 - 206, The log calls for
fastresume and torrent copy duplicate the error output by using .Err(err) and
also formatting err into the message; update the two logging lines that follow
newFastResume.Encode(fastResumeOutFile) and CopyFile(match, torrentOutFile) to
remove the error interpolation from Msgf (use a static descriptive message that
references fastResumeOutFile or torrentOutFile) and rely on
log.Error().Err(err).Msg(...) to include the error; ensure you keep the existing
context (fastResumeOutFile, torrentOutFile, match) in the message but do not
include %q or the err variable inside the format string.

Comment on lines +25 to +49
func (i *TransmissionImport) Migrate() error {
torrentsDir := i.opts.SourceDir + "/torrents"

sourceDirInfo, err := os.Stat(torrentsDir)
if err != nil {
if os.IsNotExist(err) {
return errors.Errorf("source directory does not exist: %s", torrentsDir)
}

return errors.Wrapf(err, "source directory error: %s", torrentsDir)
}

if !sourceDirInfo.IsDir() {
return errors.Errorf("source is a file, not a directory: %s", torrentsDir)
}

matches, err := filepath.Glob(filepath.Join(torrentsDir, "*.torrent"))
if err != nil {
return errors.Wrapf(err, "glob error: %s", torrentsDir)
}

if len(matches) == 0 {
log.Info().Msgf("Found 0 files to process in: %s", torrentsDir)
return nil
}
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.

⚠️ Potential issue | 🟠 Major

Missing MkDirIfNotExists for the qBittorrent output directory.

Same issue as in the RTorrent importer — the Deluge importer creates QbitDir before writing, but the Transmission importer does not. Writes will fail if the directory doesn't exist.

🐛 Proposed fix — add after directory validation
 	if !sourceDirInfo.IsDir() {
 		return errors.Errorf("source is a file, not a directory: %s", torrentsDir)
 	}
+
+	if !i.opts.DryRun {
+		if err := MkDirIfNotExists(i.opts.QbitDir); err != nil {
+			return errors.Wrapf(err, "qbit directory error: %s", i.opts.QbitDir)
+		}
+	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func (i *TransmissionImport) Migrate() error {
torrentsDir := i.opts.SourceDir + "/torrents"
sourceDirInfo, err := os.Stat(torrentsDir)
if err != nil {
if os.IsNotExist(err) {
return errors.Errorf("source directory does not exist: %s", torrentsDir)
}
return errors.Wrapf(err, "source directory error: %s", torrentsDir)
}
if !sourceDirInfo.IsDir() {
return errors.Errorf("source is a file, not a directory: %s", torrentsDir)
}
matches, err := filepath.Glob(filepath.Join(torrentsDir, "*.torrent"))
if err != nil {
return errors.Wrapf(err, "glob error: %s", torrentsDir)
}
if len(matches) == 0 {
log.Info().Msgf("Found 0 files to process in: %s", torrentsDir)
return nil
}
func (i *TransmissionImport) Migrate() error {
torrentsDir := i.opts.SourceDir + "/torrents"
sourceDirInfo, err := os.Stat(torrentsDir)
if err != nil {
if os.IsNotExist(err) {
return errors.Errorf("source directory does not exist: %s", torrentsDir)
}
return errors.Wrapf(err, "source directory error: %s", torrentsDir)
}
if !sourceDirInfo.IsDir() {
return errors.Errorf("source is a file, not a directory: %s", torrentsDir)
}
if !i.opts.DryRun {
if err := MkDirIfNotExists(i.opts.QbitDir); err != nil {
return errors.Wrapf(err, "qbit directory error: %s", i.opts.QbitDir)
}
}
matches, err := filepath.Glob(filepath.Join(torrentsDir, "*.torrent"))
if err != nil {
return errors.Wrapf(err, "glob error: %s", torrentsDir)
}
if len(matches) == 0 {
log.Info().Msgf("Found 0 files to process in: %s", torrentsDir)
return nil
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/clientmigrate/transmission.go` around lines 25 - 49, The
TransmissionImport.Migrate function fails to ensure the qBittorrent output
directory exists; add a call to MkDirIfNotExists(i.opts.QbitDir) (the same
helper used by the Deluge importer) after validating torrentsDir and before
processing/writing torrents so writes won't fail when QbitDir is missing; handle
and return any error from MkDirIfNotExists wrapped/descriptive (use
errors.Wrapf) to match existing error handling.

}

func (i *TransmissionImport) Migrate() error {
torrentsDir := i.opts.SourceDir + "/torrents"
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.

⚠️ Potential issue | 🟡 Minor

Use filepath.Join instead of string concatenation for path construction.

i.opts.SourceDir + "/torrents" uses a hard-coded forward slash, which isn't OS-portable. Use filepath.Join for consistent behavior.

🐛 Proposed fix
-	torrentsDir := i.opts.SourceDir + "/torrents"
+	torrentsDir := filepath.Join(i.opts.SourceDir, "torrents")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
torrentsDir := i.opts.SourceDir + "/torrents"
torrentsDir := filepath.Join(i.opts.SourceDir, "torrents")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/clientmigrate/transmission.go` at line 26, Replace the OS-unsafe
string concatenation that builds torrentsDir from i.opts.SourceDir (torrentsDir
:= i.opts.SourceDir + "/torrents") with a call to filepath.Join to construct the
path portably; import "path/filepath" if not already imported and update the
assignment to use filepath.Join(i.opts.SourceDir, "torrents") in the
transmission.go code where torrentsDir is set.

//Path: resumeFile.Destination,
}

//if file.Info.Files != nil {
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.

⚠️ Potential issue | 🟡 Minor

Fix comment formatting per linter.

Static analysis flags: //if should be // if.

-		//if file.Info.Files != nil {
+		// if file.Info.Files != nil {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
//if file.Info.Files != nil {
// if file.Info.Files != nil {
🧰 Tools
🪛 GitHub Check: backend

[failure] 147-147:
commentFormatting: put a space between // and comment text (gocritic)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/clientmigrate/transmission.go` at line 147, Update the inline
comment to follow linter style by adding a space after the comment slashes:
change the comment "//if file.Info.Files != nil {" to "// if file.Info.Files !=
nil {" wherever it appears (search for the commented check referencing
file.Info.Files in transmission.go) so the linter no longer flags `//if`
formatting.

Comment on lines +184 to +193
fastResumeOutFile := filepath.Join(i.opts.QbitDir, torrentID+".fastresume")
if err = newFastResume.Encode(fastResumeOutFile); err != nil {
log.Error().Err(err).Msgf("Could not create qBittorrent fastresume file %s error: %q", fastResumeOutFile, err)
continue
}

if err = CopyFile(match, torrentOutFile); err != nil {
log.Error().Err(err).Msgf("Could not copy qBittorrent torrent file %s error %q", torrentOutFile, err)
continue
}
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.

⚠️ Potential issue | 🟡 Minor

Error is logged twice — via .Err(err) and %q format verb.

Same double-logging pattern as in the other importers.

🐛 Proposed fix
 		if err = newFastResume.Encode(fastResumeOutFile); err != nil {
-			log.Error().Err(err).Msgf("Could not create qBittorrent fastresume file %s error: %q", fastResumeOutFile, err)
+			log.Error().Err(err).Msgf("Could not create qBittorrent fastresume file %s", fastResumeOutFile)
 			continue
 		}
 
 		if err = CopyFile(match, torrentOutFile); err != nil {
-			log.Error().Err(err).Msgf("Could not copy qBittorrent torrent file %s error %q", torrentOutFile, err)
+			log.Error().Err(err).Msgf("Could not copy qBittorrent torrent file %s", torrentOutFile)
 			continue
 		}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
fastResumeOutFile := filepath.Join(i.opts.QbitDir, torrentID+".fastresume")
if err = newFastResume.Encode(fastResumeOutFile); err != nil {
log.Error().Err(err).Msgf("Could not create qBittorrent fastresume file %s error: %q", fastResumeOutFile, err)
continue
}
if err = CopyFile(match, torrentOutFile); err != nil {
log.Error().Err(err).Msgf("Could not copy qBittorrent torrent file %s error %q", torrentOutFile, err)
continue
}
fastResumeOutFile := filepath.Join(i.opts.QbitDir, torrentID+".fastresume")
if err = newFastResume.Encode(fastResumeOutFile); err != nil {
log.Error().Err(err).Msgf("Could not create qBittorrent fastresume file %s", fastResumeOutFile)
continue
}
if err = CopyFile(match, torrentOutFile); err != nil {
log.Error().Err(err).Msgf("Could not copy qBittorrent torrent file %s", torrentOutFile)
continue
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/clientmigrate/transmission.go` around lines 184 - 193, The log lines
in the block that calls newFastResume.Encode and CopyFile double-report the
error by using .Err(err) and also formatting the error into the Msgf string;
update the two error logs to use one approach consistently: either keep
.Err(err) and remove the %q/%v error interpolation from the Msgf message, or
remove .Err(err) and include the error string in Msgf. Locate the calls to
newFastResume.Encode(...) and CopyFile(...) and modify the corresponding
log.Error().Err(err).Msgf(...) calls so the error is reported only once (prefer
keeping .Err(err) and simplifying the Msgf text to omit the error formatting).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant