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
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ Application Options:
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)
--without-labels Do not export/import labels
Expand All @@ -86,6 +88,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 +138,15 @@ 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
```
19 changes: 16 additions & 3 deletions bt2qbt.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,22 @@ 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.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")
fmt.Println("Press Enter to start")
Expand Down
31 changes: 20 additions & 11 deletions internal/options/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,17 @@ 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"`
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)"`
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 {
Expand Down Expand Up @@ -95,8 +97,15 @@ 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 _, 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
32 changes: 23 additions & 9 deletions internal/options/options_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,20 +56,24 @@ func TestOptionsArgs(t *testing.T) {
args: []string{
"--source", "/dir",
"--destination", "/dir",
"--destination-private", "/dir_private",
"--categories", "/dir/q.json",
"--replace", "dir1,dir2", "-r", "dir3,dir4",
"--sep", "/",
"--search", "/dir5", "-t", "/dir6/",
"--stats",
"--without-tags"},
mustFail: false,
expected: &Opts{
BitDir: "/dir",
QBitDir: "/dir",
Categories: "/dir/q.json",
Replaces: []string{"dir1,dir2", "dir3,dir4"},
PathSeparator: "/",
SearchPaths: []string{"/dir5", "/dir6/"},
WithoutTags: true,
BitDir: "/dir",
QBitDir: "/dir",
PrivateQBitDir: "/dir_private",
Categories: "/dir/q.json",
Replaces: []string{"dir1,dir2", "dir3,dir4"},
PathSeparator: "/",
SearchPaths: []string{"/dir5", "/dir6/"},
Stats: true,
WithoutTags: true,
},
},
}
Expand Down Expand Up @@ -209,7 +213,17 @@ func TestOptionsChecks(t *testing.T) {
mustFail: false,
},
{
name: "003 Must fail do not exists folders or files test",
name: "003 Check exists private destination",
opts: &Opts{
BitDir: "../../test/data",
QBitDir: "../../test/data",
PrivateQBitDir: "../../test/data",
SearchPaths: []string{},
},
mustFail: false,
},
{
name: "004 Must fail do not exists folders or files test",
opts: &Opts{
BitDir: "/dir",
QBitDir: "/dir",
Expand All @@ -220,7 +234,7 @@ func TestOptionsChecks(t *testing.T) {
mustFail: true,
},
{
name: "004 Must fail do not exists qbitdir test",
name: "005 Must fail do not exists qbitdir test",
opts: &Opts{
BitDir: "../../test/data",
QBitDir: "/dir",
Expand Down
7 changes: 6 additions & 1 deletion internal/transfer/chans.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
package transfer

type Channels struct {
ComChannel chan string
ComChannel chan ImportResult
ErrChannel chan string
BoundedChannel chan bool
}

type ImportResult struct {
Message string
IsPrivate bool
}
22 changes: 22 additions & 0 deletions internal/transfer/private_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package transfer

import (
"testing"

"github.com/rumanzo/bt2qbt/pkg/torrentStructures"
)

func TestTransferStructure_IsPrivate(t *testing.T) {
transferStructure := CreateEmptyNewTransferStructure()
transferStructure.TorrentFile = &torrentStructures.Torrent{
Info: &torrentStructures.TorrentInfo{Private: 1},
}
if !transferStructure.IsPrivate() {
t.Fatalf("expected private torrent")
}

transferStructure.TorrentFile.Info.Private = 0
if transferStructure.IsPrivate() {
t.Fatalf("expected non-private torrent")
}
}
33 changes: 25 additions & 8 deletions internal/transfer/resumeHandle.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,21 +65,28 @@ func HandleResumeItem(key string, transferStruct *TransferStructure, chans *Chan
transferStruct.HandleStructures()

newBaseName := transferStruct.GetHash()
if err = helpers.EncodeTorrentFile(filepath.Join(transferStruct.Opts.QBitDir, newBaseName+".fastresume"), transferStruct.Fastresume); err != nil {
chans.ErrChannel <- fmt.Sprintf("Can't create qBittorrent fastresume file %v. With error: %v", filepath.Join(transferStruct.Opts.QBitDir, newBaseName+".fastresume"), err)
destDir := transferStruct.Opts.QBitDir
if transferStruct.IsPrivate() && transferStruct.Opts.PrivateQBitDir != "" {
destDir = transferStruct.Opts.PrivateQBitDir
}
if err = helpers.EncodeTorrentFile(filepath.Join(destDir, newBaseName+".fastresume"), transferStruct.Fastresume); err != nil {
chans.ErrChannel <- fmt.Sprintf("Can't create qBittorrent fastresume file %v. With error: %v", filepath.Join(destDir, newBaseName+".fastresume"), err)
return err
}
if err = helpers.CopyFile(transferStruct.TorrentFilePath, filepath.Join(transferStruct.Opts.QBitDir, newBaseName+".torrent")); err != nil {
chans.ErrChannel <- fmt.Sprintf("Can't create qBittorrent torrent file %v", filepath.Join(transferStruct.Opts.QBitDir, newBaseName+".torrent"))
if err = helpers.CopyFile(transferStruct.TorrentFilePath, filepath.Join(destDir, newBaseName+".torrent")); err != nil {
chans.ErrChannel <- fmt.Sprintf("Can't create qBittorrent torrent file %v", filepath.Join(destDir, newBaseName+".torrent"))
return err
}
chans.ComChannel <- fmt.Sprintf("Sucessfully imported %v", key)
chans.ComChannel <- ImportResult{
Message: fmt.Sprintf("Sucessfully imported %v", key),
IsPrivate: transferStruct.IsPrivate(),
}
return nil
}

func HandleResumeItems(opts *options.Opts, resumeItems map[string]*utorrentStructs.ResumeItem) {
totalJobs := len(resumeItems)
chans := Channels{ComChannel: make(chan string, totalJobs),
chans := Channels{ComChannel: make(chan ImportResult, totalJobs),
ErrChannel: make(chan string, totalJobs),
BoundedChannel: make(chan bool, runtime.GOMAXPROCS(0)*2)}
numJob := 1
Expand Down Expand Up @@ -114,14 +121,23 @@ func HandleResumeItems(opts *options.Opts, resumeItems map[string]*utorrentStruc
close(chans.ComChannel)
close(chans.ErrChannel)
}()
for message := range chans.ComChannel {
fmt.Printf("%v/%v %v \n", numJob, totalJobs, message)
var publicCount int
var privateCount int
var failedCount int
for result := range chans.ComChannel {
fmt.Printf("%v/%v %v \n", numJob, totalJobs, result.Message)
if result.IsPrivate {
privateCount++
} else {
publicCount++
}
numJob++
}
var wasErrors bool
for message := range chans.ErrChannel {
fmt.Printf("%v/%v %v \n", numJob, totalJobs, message)
wasErrors = true
failedCount++
numJob++
}
if opts.WithoutTags == false {
Expand All @@ -130,6 +146,7 @@ func HandleResumeItems(opts *options.Opts, resumeItems map[string]*utorrentStruc
fmt.Printf("Can't handle labels with error:\n%v\n", err)
}
}
fmt.Printf("Summary: public %v, private %v, failed %v of %v total\n", publicCount, privateCount, failedCount, totalJobs)
fmt.Println()
log.Println("Ended")
if wasErrors {
Expand Down
49 changes: 49 additions & 0 deletions internal/transfer/stats.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package transfer

import (
"strings"

"github.com/rumanzo/bt2qbt/internal/options"
"github.com/rumanzo/bt2qbt/pkg/helpers"
"github.com/rumanzo/bt2qbt/pkg/torrentStructures"
"github.com/rumanzo/bt2qbt/pkg/utorrentStructs"
)

type StatsResult struct {
Total int
Public int
Private int
Failed int
}

func CollectStats(opts *options.Opts, resumeItems map[string]*utorrentStructs.ResumeItem) StatsResult {
result := StatsResult{Total: len(resumeItems)}
for key := range resumeItems {
normalizedKey := helpers.HandleCesu8(key)
if strings.HasPrefix(normalizedKey, "magnet:?") {
result.Failed++
continue
}

transferStructure := CreateEmptyNewTransferStructure()
transferStructure.Opts = opts
HandleTorrentFilePath(&transferStructure, normalizedKey)
if err := FindTorrentFile(&transferStructure); err != nil {
result.Failed++
continue
}

torrent := &torrentStructures.Torrent{}
if err := helpers.DecodeTorrentFile(transferStructure.TorrentFilePath, torrent); err != nil {
result.Failed++
continue
}

if torrent.Info != nil && torrent.Info.Private != 0 {
result.Private++
} else {
result.Public++
}
}
return result
}
65 changes: 65 additions & 0 deletions internal/transfer/stats_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package transfer

import (
"os"
"path/filepath"
"testing"

"github.com/rumanzo/bt2qbt/internal/options"
"github.com/rumanzo/bt2qbt/pkg/torrentStructures"
"github.com/rumanzo/bt2qbt/pkg/utorrentStructs"
"github.com/zeebo/bencode"
)

func writeTorrentFile(t *testing.T, path string, private uint8) {
t.Helper()
torrent := &torrentStructures.Torrent{
Info: &torrentStructures.TorrentInfo{
Name: "test",
PieceLength: 1,
Pieces: []byte{0},
Private: private,
},
}
data, err := bencode.EncodeBytes(torrent)
if err != nil {
t.Fatalf("encode torrent: %v", err)
}
if err := os.WriteFile(path, data, 0o666); err != nil {
t.Fatalf("write torrent: %v", err)
}
}

func TestCollectStats(t *testing.T) {
bitDir := t.TempDir()
searchDir := t.TempDir()

writeTorrentFile(t, filepath.Join(bitDir, "private.torrent"), 1)
writeTorrentFile(t, filepath.Join(searchDir, "public.torrent"), 0)

opts := &options.Opts{
BitDir: bitDir,
SearchPaths: []string{searchDir},
}
resumeItems := map[string]*utorrentStructs.ResumeItem{
"private.torrent": {},
"public.torrent": {},
"missing.torrent": {},
"magnet:?xt=urn:btih:deadbeef": {},
"magnet:?xt=urn:btih:deadbeef&dn": {},
}

stats := CollectStats(opts, resumeItems)
if stats.Total != 5 {
t.Fatalf("unexpected total: %v", stats.Total)
}
if stats.Private != 1 {
t.Fatalf("unexpected private count: %v", stats.Private)
}
if stats.Public != 1 {
t.Fatalf("unexpected public count: %v", stats.Public)
}
if stats.Failed != 3 {
t.Fatalf("unexpected failed count: %v", stats.Failed)
}
}
Loading