Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
.idea
.vscode
bt2qbt_v*
vendors
vendors
/deluge-2.2.0
/.gocache
59 changes: 56 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

# bt2qbt

bt2qbt is cli tool for export from uTorrent\Bittorrent into qBittorrent (convert)
bt2qbt is cli tool for export from uTorrent\Bittorrent into qBittorrent or Deluge (convert)
> [!IMPORTANT]
> Actual version tested with uTorrent 3.5.5 (build 46206) and qBittorrent 4.4.2. It should work with older version utorrent and newer version of qBittorrent, but it isn't tested.

Expand All @@ -13,6 +13,12 @@ bt2qbt is cli tool for export from uTorrent\Bittorrent into qBittorrent (convert
> [!IMPORTANT]
> For new qBittorrent 5.X+ check that it use fastresume files before you migrate. Preferences -> Advanced -> Resume data storage type -> Fastresume files
>
> [!IMPORTANT]
> For Deluge (client = deluge), the tool writes to `torrents.state`, `torrents.fastresume`, and the `state/` folder. Make sure Deluge is closed and those files are backed up before migration.
>
> [!NOTE]
> Deluge migration requires Python in PATH to update `torrents.state`. The tool tries `python3`, `python`, and `py -3`. You can also set `BT2QBT_PYTHON` to a full path.

- [bt2qbt](#bt2qbt)
- [Feature](#user-content-feature)
- [Help](#user-content-help)
Expand Down Expand Up @@ -49,12 +55,15 @@ Feature:

> [!NOTE]
> \*\*\* Partially downloaded torrents will be visible as 100% completed, but in fact you will need to do a recheck (right click on torrent -> Force recheck). Without recheck torrents not will be valid. This is due to the fact that conversion of .dat files in which parts of objects are stored is not implemented.
>
> [!NOTE]
> Deluge Label plugin supports a single label per torrent. For `--client=deluge` the tool uses the uTorrent label if present, otherwise the first tag.

> [!IMPORTANT]
> Before using `bt2qbt`, do not forget to **make backup** from:
> - bittorrent\utorrent data,
> - qbittorrent folder, and
> - config %APPDATA%/Roaming/qBittorrent/qBittorrent.ini.
> - qbittorrent folder or deluge state folder, and
> - config %APPDATA%/Roaming/qBittorrent/qBittorrent.ini (qBittorrent) or %APPDATA%/deluge/label.conf (Deluge Label plugin).
> Close both programs before making a copy!

> [!IMPORTANT]
Expand All @@ -70,12 +79,19 @@ Usage:
bt2qbt_v1.99_amd64.exe [OPTIONS]

Application Options:
--client= Target client: qbt (default) or deluge
-s, --source= Source directory that contains resume.dat and torrents files (default:
C:\Users\rumanzo\AppData\Roaming\uTorrent)
-d, --destination= Destination directory BT_backup (as default) (default:
C:\Users\rumanzo\AppData\Local\qBittorrent\BT_backup)
--destination-private=
Destination directory BT_backup for private torrents (optional)
-c, --categories= Path to qBittorrent categories.json file (for write tags) (default:
C:\Users\rumanzo\AppData\Roaming\qBittorrent\categories.json)
--deluge-config= Deluge config directory (default depends on OS)
--deluge-state= Deluge state directory (contains torrents.state/fastresume and .torrent files)
--deluge-labels= Path to Deluge label.conf file (Label plugin)
--deluge-privacy= Deluge export filter: all (default), public, or private
--without-labels Do not export/import labels
--without-tags Do not export/import tags
-t, --search= Additional search path for torrents files
Expand All @@ -86,6 +102,7 @@ Application Options:

--sep= Default path separator that will use in all paths. You may need use this flag if you migrating
from windows to linux in some cases (default: \)
--stats Show public/private/failed counts only (no conversion)
-v, --version Show version

```
Expand Down Expand Up @@ -135,3 +152,39 @@ Started

Press Enter to exit
```

- Split private torrents into a separate destination folder

```
.\bt2qbt.exe -s C:\Users\user\AppData\Roaming\BitTorrent\ -d C:\Users\user\AppData\Local\qBittorrent\BT_backup\ --destination-private D:\qBittorrent\BT_backup_private\
```

- Show stats only (no conversion)

```
.\bt2qbt.exe -s C:\Users\user\AppData\Roaming\BitTorrent\ --stats
```

- Convert to Deluge (default config dir)

```
.\bt2qbt.exe --client deluge
```

- Convert to Deluge with custom config dir

```
.\bt2qbt.exe --client deluge --deluge-config C:\Users\user\AppData\Roaming\deluge
```

- Convert only public torrents to Deluge

```
.\bt2qbt.exe --client deluge --deluge-privacy public
```

- Convert only private torrents to a separate Deluge config

```
.\bt2qbt.exe --client deluge --deluge-privacy private --deluge-config C:\Users\user\AppData\Roaming\deluge_private
```
33 changes: 29 additions & 4 deletions bt2qbt.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,36 @@ func main() {
os.Exit(1)
}

color.Green("It will be performed processing from directory %v to directory %v\n", opts.BitDir, opts.QBitDir)
color.HiRed("Check that the qBittorrent is turned off and the directory %v and %v is backed up.\n",
opts.QBitDir, opts.Categories)
if opts.Stats {
stats := transfer.CollectStats(opts, resumeItems)
fmt.Printf("Stats: public %v, private %v, failed %v of %v total\n", stats.Public, stats.Private, stats.Failed, stats.Total)
return
}

if opts.Client == "deluge" {
color.Green("It will be performed processing from directory %v to Deluge state directory %v\n",
opts.BitDir, opts.DelugeStateDir)
color.HiRed("Check that Deluge is turned off and the directory %v is backed up.\n",
opts.DelugeStateDir)
if !opts.WithoutLabels || !opts.WithoutTags {
color.HiRed("Check that the label config %v is backed up.\n", opts.DelugeLabels)
}
} else if opts.PrivateQBitDir != "" {
color.Green("It will be performed processing from directory %v to directory %v (public) and %v (private)\n",
opts.BitDir, opts.QBitDir, opts.PrivateQBitDir)
color.HiRed("Check that the qBittorrent is turned off and the directory %v, %v and %v is backed up.\n",
opts.QBitDir, opts.PrivateQBitDir, opts.Categories)
} else {
color.Green("It will be performed processing from directory %v to directory %v\n", opts.BitDir, opts.QBitDir)
color.HiRed("Check that the qBittorrent is turned off and the directory %v and %v is backed up.\n",
opts.QBitDir, opts.Categories)
}
color.HiRed("Check that you previously disable option \"Append .!ut/.!bt to incomplete files\" in preferences of uTorrent/Bittorrent \n")
color.HiRed("Close uTorrent/Bittorrent and qBittorrent previously\n\n")
if opts.Client == "deluge" {
color.HiRed("Close uTorrent/Bittorrent and Deluge previously\n\n")
} else {
color.HiRed("Close uTorrent/Bittorrent and qBittorrent previously\n\n")
}
fmt.Println("Press Enter to start")
fmt.Scanln()
log.Println("Started")
Expand Down
104 changes: 86 additions & 18 deletions internal/options/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,24 +14,34 @@ import (
)

type Opts struct {
BitDir string `short:"s" long:"source" description:"Source directory that contains resume.dat and torrents files"`
QBitDir string `short:"d" long:"destination" description:"Destination directory BT_backup (as default)"`
Categories string `short:"c" long:"categories" description:"Path to qBittorrent categories.json file (for write tags)"`
WithoutLabels bool `long:"without-labels" description:"Do not export/import labels"`
WithoutTags bool `long:"without-tags" description:"Do not export/import tags"`
SearchPaths []string `short:"t" long:"search" description:"Additional search path for torrents files\n Example: --search='/mnt/olddisk/savedtorrents' --search='/mnt/olddisk/workstorrents'"`
Replaces []string `short:"r" long:"replace" description:"Replace save paths. Important: you have to use single slashes in paths\n Delimiter for from/to is comma - ,\n Example: -r \"D:/films,/home/user/films\" -r \"D:/music,/home/user/music\"\n"`
PathSeparator string `long:"sep" description:"Default path separator that will use in all paths. You may need use this flag if you migrating from windows to linux in some cases"`
Version bool `short:"v" long:"version" description:"Show version"`
Client string `long:"client" description:"Target client: qbt (default) or deluge"`
BitDir string `short:"s" long:"source" description:"Source directory that contains resume.dat and torrents files"`
QBitDir string `short:"d" long:"destination" description:"Destination directory BT_backup (as default)"`
PrivateQBitDir string `long:"destination-private" description:"Destination directory BT_backup for private torrents (optional)"`
Categories string `short:"c" long:"categories" description:"Path to qBittorrent categories.json file (for write tags)"`
DelugeConfig string `long:"deluge-config" description:"Deluge config directory (default depends on OS)"`
DelugeStateDir string `long:"deluge-state" description:"Deluge state directory (contains torrents.state/fastresume and .torrent files)"`
DelugeLabels string `long:"deluge-labels" description:"Path to Deluge label.conf file (Label plugin)"`
DelugePrivacy string `long:"deluge-privacy" description:"Deluge export filter: all (default), public, or private"`
WithoutLabels bool `long:"without-labels" description:"Do not export/import labels"`
WithoutTags bool `long:"without-tags" description:"Do not export/import tags"`
SearchPaths []string `short:"t" long:"search" description:"Additional search path for torrents files\n Example: --search='/mnt/olddisk/savedtorrents' --search='/mnt/olddisk/workstorrents'"`
Replaces []string `short:"r" long:"replace" description:"Replace save paths. Important: you have to use single slashes in paths\n Delimiter for from/to is comma - ,\n Example: -r \"D:/films,/home/user/films\" -r \"D:/music,/home/user/music\"\n"`
PathSeparator string `long:"sep" description:"Default path separator that will use in all paths. You may need use this flag if you migrating from windows to linux in some cases"`
Stats bool `long:"stats" description:"Show public/private/failed counts only (no conversion)"`
Version bool `short:"v" long:"version" description:"Show version"`
}

func PrepareOpts() *Opts {
opts := &Opts{PathSeparator: string(os.PathSeparator)}
opts.Client = "qbt"
opts.DelugePrivacy = "all"
switch OS := runtime.GOOS; OS {
case "windows":
opts.BitDir = filepath.Join(os.Getenv("APPDATA"), "uTorrent")
opts.Categories = filepath.Join(os.Getenv("APPDATA"), "qBittorrent", "categories.json")
opts.QBitDir = filepath.Join(os.Getenv("LOCALAPPDATA"), "qBittorrent", "BT_backup")
opts.DelugeConfig = filepath.Join(os.Getenv("APPDATA"), "deluge")
case "linux":
usr, err := user.Current()
if err != nil {
Expand All @@ -40,6 +50,11 @@ func PrepareOpts() *Opts {
opts.BitDir = "/mnt/uTorrent/"
opts.Categories = filepath.Join(usr.HomeDir, ".config", "qBittorrent", "categories.json")
opts.QBitDir = filepath.Join(usr.HomeDir, ".local", "share", "data", "qBittorrent", "BT_backup")
if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" {
opts.DelugeConfig = filepath.Join(xdg, "deluge")
} else {
opts.DelugeConfig = filepath.Join(usr.HomeDir, ".config", "deluge")
}
case "darwin":
usr, err := user.Current()
if err != nil {
Expand All @@ -48,6 +63,11 @@ func PrepareOpts() *Opts {
opts.BitDir = filepath.Join(usr.HomeDir, "Library", "Application Support", "uTorrent")
opts.Categories = filepath.Join(usr.HomeDir, ".config", "qBittorrent", "categories.json")
opts.QBitDir = filepath.Join(usr.HomeDir, "Library", "Application Support", "QBittorrent", "BT_backup")
if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" {
opts.DelugeConfig = filepath.Join(xdg, "deluge")
} else {
opts.DelugeConfig = filepath.Join(usr.HomeDir, ".config", "deluge")
}
}
return opts
}
Expand All @@ -67,21 +87,56 @@ func ParseOpts(opts *Opts) *Opts {

// HandleOpts used for enrichment opts after first creation
func HandleOpts(opts *Opts) {
opts.Client = strings.ToLower(strings.TrimSpace(opts.Client))
if opts.Client == "" {
opts.Client = "qbt"
}
if opts.Client == "qbittorrent" {
opts.Client = "qbt"
}
opts.DelugePrivacy = strings.ToLower(strings.TrimSpace(opts.DelugePrivacy))
if opts.DelugePrivacy == "" {
opts.DelugePrivacy = "all"
}
opts.SearchPaths = append(opts.SearchPaths, opts.BitDir)

qbtDir := fileHelpers.Normalize(opts.QBitDir, `/`)
if strings.Contains(qbtDir, `profile/qBittorrent/data/BT_backup`) {
qbtRootDir, _ := strings.CutSuffix(qbtDir, `data/BT_backup`)
if opts.Client == "qbt" {
qbtDir := fileHelpers.Normalize(opts.QBitDir, `/`)
if strings.Contains(qbtDir, `profile/qBittorrent/data/BT_backup`) {
qbtRootDir, _ := strings.CutSuffix(qbtDir, `data/BT_backup`)

// check that user not define categories
refOpts := PrepareOpts()
if refOpts.Categories == opts.Categories {
opts.Categories = fileHelpers.Join([]string{qbtRootDir, `config/categories.json`}, opts.PathSeparator)
// check that user not define categories
refOpts := PrepareOpts()
if refOpts.Categories == opts.Categories {
opts.Categories = fileHelpers.Join([]string{qbtRootDir, `config/categories.json`}, opts.PathSeparator)
}
}
}

if opts.DelugeConfig != "" {
if opts.DelugeStateDir == "" {
opts.DelugeStateDir = filepath.Join(opts.DelugeConfig, "state")
}
if opts.DelugeLabels == "" {
opts.DelugeLabels = filepath.Join(opts.DelugeConfig, "label.conf")
}
}
}

func OptsCheck(opts *Opts) error {
if opts.Client == "" {
opts.Client = "qbt"
}
if opts.Client != "qbt" && opts.Client != "deluge" {
return fmt.Errorf("unknown client: %v (use qbt or deluge)", opts.Client)
}
if opts.Client == "deluge" {
switch opts.DelugePrivacy {
case "all", "public", "private":
default:
return fmt.Errorf("unknown deluge privacy: %v (use all, public, or private)", opts.DelugePrivacy)
}
}
if len(opts.Replaces) != 0 {
for _, str := range opts.Replaces {
patterns := strings.Split(str, ",")
Expand All @@ -95,8 +150,21 @@ func OptsCheck(opts *Opts) error {
return fmt.Errorf("can't find uTorrent\\Bittorrent folder")
}

if _, err := os.Stat(opts.QBitDir); os.IsNotExist(err) {
return fmt.Errorf("can't find qBittorrent folder")
if !opts.Stats {
if opts.Client == "deluge" {
if _, err := os.Stat(opts.DelugeStateDir); os.IsNotExist(err) {
return fmt.Errorf("can't find Deluge state folder")
}
} else {
if _, err := os.Stat(opts.QBitDir); os.IsNotExist(err) {
return fmt.Errorf("can't find qBittorrent folder")
}
if opts.PrivateQBitDir != "" {
if _, err := os.Stat(opts.PrivateQBitDir); os.IsNotExist(err) {
return fmt.Errorf("can't find qBittorrent folder for private torrents")
}
}
}
}

if runtime.GOOS == "linux" {
Expand Down
Loading