diff --git a/README.md b/README.md index 8a09e82..72429f2 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,14 @@ # qBt Migrate ![Python](https://github.com/jslay88/qbt_migrate/actions/workflows/python.yml/badge.svg) -This tool changes the root paths of existing torrents in qBittorrent. +This tool changes the paths of existing torrents in qBittorrent in a bulk fashion. It can also convert slashes when migrating between Windows and Linux/Mac. ![Demo](demo.gif) ## Usage +**ALWAYS** ensure qBittorrent is closed before running `qbt_migrate`. +Either quit through `File` -> `Exit`, task tray icon, or task manager for your system. Install from PyPi using `pip`, or jump to [Examples](#Examples) for Docker @@ -38,6 +40,7 @@ Override `BT_backup` path if needed. Default BT_backup paths: * Windows: `%LOCALAPPDATA%/qBittorrent/BT_backup` * Linux/Mac: `$HOME/.local/share/data/qBittorrent/BT_backup` +* Docker: `/config/qBittorrent/BT_backup` A backup zip archive is automatically created in the `BT_backup` directory. diff --git a/qbt_migrate/__init__.py b/qbt_migrate/__init__.py index 0d071b0..bc7e759 100644 --- a/qbt_migrate/__init__.py +++ b/qbt_migrate/__init__.py @@ -6,6 +6,6 @@ from .methods import convert_slashes, discover_bt_backup_path -__version__ = "2.3.0" + os.getenv("VERSION_TAG", "") +__version__ = "2.3.1" + os.getenv("VERSION_TAG", "") logging.getLogger(__name__).addHandler(logging.NullHandler()) diff --git a/qbt_migrate/classes.py b/qbt_migrate/classes.py index 324a248..f11c135 100644 --- a/qbt_migrate/classes.py +++ b/qbt_migrate/classes.py @@ -159,6 +159,11 @@ def save_path(self) -> Optional[str]: if "save_path" in self._data: return self._data["save_path"] + @property + def qbt_download_path(self) -> Optional[str]: + if "qBt-downloadPath" in self._data: + return self._data["qBt-downloadPath"] + @property def qbt_save_path(self) -> Optional[str]: if "qBt-savePath" in self._data: @@ -177,7 +182,7 @@ def set_save_path( save_file: bool = True, create_backup: bool = True, ): - if key not in ["save_path", "qBt-savePath"]: + if key not in ["save_path", "qBt-savePath", "qBt-downloadPath"]: raise KeyError("When setting a save path, key must be `save_path` or `qBt-savePath`. " f"Received {key}") if create_backup: self.save(self.backup_filename) @@ -192,6 +197,7 @@ def set_save_paths( self, path: str, qbt_path: Optional[str] = None, + qbt_download_path: Optional[str] = None, target_os: Optional[TargetOS] = None, save_file: bool = True, create_backup: bool = True, @@ -204,6 +210,14 @@ def set_save_paths( qbt_path = path if qbt_path is None else qbt_path if qbt_path: self.set_save_path(qbt_path, key="qBt-savePath", target_os=target_os, save_file=False, create_backup=False) + if qbt_download_path: + self.set_save_path( + qbt_download_path, key="qBt-downloadPath", target_os=target_os, save_file=False, create_backup=False + ) + elif self.qbt_download_path: + self.set_save_path( + self.qbt_save_path, key="qBt-downloadPath", target_os=target_os, save_file=False, create_backup=False + ) if self.mapped_files is not None and target_os is not None: self.logger.debug("Converting Slashes for mapped_files...") self._data["mapped_files"] = [convert_slashes(path, target_os) for path in self.mapped_files] @@ -229,6 +243,7 @@ def replace_paths( if regex_path: new_save_path = None new_qbt_save_path = None + new_qbt_download_path = None pattern = re.compile(existing_path) if self.save_path: new_save_path = pattern.sub(new_path, self.save_path) @@ -236,6 +251,8 @@ def replace_paths( new_qbt_save_path = pattern.sub(new_path, self.qbt_save_path) if not self.save_path: new_save_path = new_qbt_save_path + if self.qbt_download_path: + new_qbt_download_path = pattern.sub(new_path, self.qbt_download_path) if self.mapped_files: self._data["mapped_files"] = [pattern.sub(new_path, path) for path in self.mapped_files] else: @@ -243,6 +260,9 @@ def replace_paths( new_qbt_save_path = ( self.qbt_save_path.replace(existing_path, new_path) if self.qbt_save_path is not None else None ) + new_qbt_download_path = ( + self.qbt_download_path.replace(existing_path, new_path) if self.qbt_download_path is not None else None + ) if not self.save_path: new_save_path = new_qbt_save_path if self.mapped_files: @@ -253,6 +273,7 @@ def replace_paths( self.set_save_paths( path=str(new_save_path), qbt_path=new_qbt_save_path, + qbt_download_path=new_qbt_download_path, target_os=target_os, save_file=save_file, create_backup=create_backup, diff --git a/qbt_migrate/cli.py b/qbt_migrate/cli.py index 747c686..7e1d8dc 100644 --- a/qbt_migrate/cli.py +++ b/qbt_migrate/cli.py @@ -1,6 +1,7 @@ import argparse import logging import sys +from pathlib import Path from . import QBTBatchMove, __version__, discover_bt_backup_path from .enums import TargetOS @@ -72,11 +73,11 @@ def main(): return qbm = QBTBatchMove() if args.bt_backup_path is not None: - qbm.bt_backup_path = args.bt_backup_path + qbm.bt_backup_path = Path(args.bt_backup_path.strip()) else: bt_backup_path = input(f"BT_backup Path {qbm.bt_backup_path}: ") if bt_backup_path.strip(): - qbm.bt_backup_path = bt_backup_path + qbm.bt_backup_path = Path(bt_backup_path.strip()) if args.existing_path is None: args.existing_path = input("Existing Path: ") if args.new_path is None: @@ -84,7 +85,11 @@ def main(): # Get Valid Regex Input if args.regex is None: - while (answer := input("Regex Paths with Capture Groups [y/N]: ").lower().strip()) not in ( + while ( + answer := input("Existing and New paths are regex patterns (capture groups recommended)? [y/N]: ") + .lower() + .strip() + ) not in ( "y", "yes", "n", diff --git a/tests/test_classes.py b/tests/test_classes.py index 3b4380d..7d09363 100644 --- a/tests/test_classes.py +++ b/tests/test_classes.py @@ -189,6 +189,8 @@ def test_fastresume_properties(temp_dir): assert fast_resume.save_path == fast_resume._data["save_path"] if "qBt-savePath" in fast_resume._data: assert fast_resume.qbt_save_path == fast_resume._data["qBt-savePath"] + if "qBt-downloadPath" in fast_resume._data: + assert fast_resume.qbt_download_path == fast_resume._data["qBt-downloadPath"] assert ( fast_resume.mapped_files == fast_resume._data["mapped_files"] if "mapped_files" in fast_resume._data @@ -228,8 +230,10 @@ def test_fastresume_set_save_path(monkeypatch): # Explicitly test setting of key fast_resume.set_save_path("/this/is/a/test", save_file=False, create_backup=False) fast_resume.set_save_path("/this/is/a/test", key="qBt-savePath", save_file=False, create_backup=False) + fast_resume.set_save_path("/this/is/a/test", key="qBt-downloadPath", save_file=False, create_backup=False) assert fast_resume.save_path == "/this/is/a/test" assert fast_resume.qbt_save_path == "/this/is/a/test" + assert fast_resume.qbt_download_path == "/this/is/a/test" mock.reset() # Test target_os None @@ -273,10 +277,12 @@ def test_fastresume_set_save_paths(monkeypatch): fast_resume.set_save_paths("/this/is/a/path") assert fast_resume.save_path == "/this/is/a/path" assert fast_resume.qbt_save_path == "/this/is/a/path" + assert fast_resume.qbt_download_path == "/this/is/a/path" # Test with differing qBt-savePath - fast_resume.set_save_paths("/this/is/a/path", "/this/is/a/qbt/path") + fast_resume.set_save_paths("/this/is/a/path", "/this/is/a/qbt/path", "/this/is/a/qbt/download/path") assert fast_resume.save_path == "/this/is/a/path" assert fast_resume.qbt_save_path == "/this/is/a/qbt/path" + assert fast_resume.qbt_download_path == "/this/is/a/qbt/download/path" # Test mapped_files convert slashes (replacing of paths happens with `replace_paths`) fast_resume.set_save_paths("C:/this/is/a/path", target_os=TargetOS.WINDOWS) @@ -290,6 +296,11 @@ def test_fastresume_set_save_paths(monkeypatch): assert fast_resume.save_path == "/this/is/a/path" assert fast_resume.qbt_save_path == "/this/is/a/path" + # Test missing qBt-downloadPath key, stays missing + del fast_resume._data["qBt-downloadPath"] + fast_resume.set_save_paths("/this/is/a/path") + assert fast_resume.qbt_download_path is None + def test_fastresume_save(monkeypatch): fast_resume = FastResume("./tests/test_files/good.fastresume") @@ -317,6 +328,7 @@ def test_fastresume_replace_paths(monkeypatch): fast_resume.replace_paths("/some/test", "/a/new/test", save_file=False, create_backup=False) assert fast_resume.save_path == "/a/new/test/path" assert fast_resume.qbt_save_path == "/a/new/test/path" + assert fast_resume.qbt_download_path == "/a/new/test/path" assert len(fast_resume.mapped_files) > 0 for mapped_file in fast_resume.mapped_files: assert mapped_file.startswith("/a/new/test/path") @@ -327,6 +339,7 @@ def test_fastresume_replace_paths_regex(monkeypatch): fast_resume.replace_paths(r"/some/(\w+)/.*$", r"/\1/regex", True, save_file=False, create_backup=False) assert fast_resume.save_path == "/test/regex" assert fast_resume.qbt_save_path == "/test/regex" + assert fast_resume.qbt_download_path == "/test/regex" assert len(fast_resume.mapped_files) > 0 for mapped_file in fast_resume.mapped_files: assert mapped_file.startswith("/test/regex") diff --git a/tests/test_cli.py b/tests/test_cli.py index cf94685..cd8bbdd 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,3 +1,4 @@ +from pathlib import Path from unittest.mock import patch import pytest @@ -121,7 +122,7 @@ def test_main_with_inputs(monkeypatch): monkeypatch.setattr("qbt_migrate.cli.QBTBatchMove", MockQBTBatchMove) monkeypatch.setattr("sys.argv", ["qbt_migrate"]) main() - assert MockQBTBatchMove.instance.bt_backup_path == "bt-backup-path" + assert MockQBTBatchMove.instance.bt_backup_path == Path("bt-backup-path") assert len(MockQBTBatchMove.run_call[0]) == 6 assert MockQBTBatchMove.run_call[0][0] == "existing-path" assert MockQBTBatchMove.run_call[0][1] == "new-path" @@ -150,7 +151,7 @@ def test_main_with_args(monkeypatch): ], ) main() - assert MockQBTBatchMove.instance.bt_backup_path == "different-bt-backup-path" + assert MockQBTBatchMove.instance.bt_backup_path == Path("different-bt-backup-path") assert len(MockQBTBatchMove.run_call[0]) == 6 assert MockQBTBatchMove.run_call[0][0] == "different-existing-path" assert MockQBTBatchMove.run_call[0][1] == "different-new-path" @@ -166,7 +167,7 @@ def test_main_invalid_input_loops(monkeypatch): mock_user_input = MockUserInput(["not-valid", "yes", "not-valid", "windows"]) monkeypatch.setattr("builtins.input", lambda _: mock_user_input.next()) main() - assert MockQBTBatchMove.instance.bt_backup_path == "backup-path" + assert MockQBTBatchMove.instance.bt_backup_path == Path("backup-path") assert len(MockQBTBatchMove.run_call[0]) == 6 assert MockQBTBatchMove.run_call[0][0] == "e-path" assert MockQBTBatchMove.run_call[0][1] == "n-path" diff --git a/tests/test_files/good.fastresume b/tests/test_files/good.fastresume index ba9929d..31378fe 100644 --- a/tests/test_files/good.fastresume +++ b/tests/test_files/good.fastresume @@ -1 +1 @@ -d11:active_timei90e10:added_timei1644727394e10:allocation6:sparse15:apply_ip_filteri1e12:auto_managedi0e12:banned_peers6:ÌÃdTá13:banned_peers60:14:completed_timei1644727428e11:disable_dhti0e11:disable_lsdi0e11:disable_pexi0e19:download_rate_limiti-1e11:file-format22:libtorrent resume file12:file-versioni1e13:file_priorityli1ee13:finished_timei57e9:httpseedsle9:info-hash20:É«ã>6yY<Ø™'‘ R‚RÍÄNn13:last_downloadi204407e18:last_seen_completei1644727466e11:last_uploadi0e18:libtorrent-version8:1.2.14.012:mapped_filesl29:/some/test/path/mapped_file_129:/some/test/path/mapped_file_2e15:max_connectionsi100e11:max_uploadsi4e12:num_completei1043e14:num_downloadedi16777215e14:num_incompletei10e6:pausedi1e5:peers0:6:peers60:6:pieces0:12:qBt-category0:17:qBt-contentLayout8:Original26:qBt-firstLastPiecePriorityi0e8:qBt-name0:14:qBt-ratioLimiti-2000e12:qBt-savePath15:/some/test/path14:qBt-seedStatusi1e20:qBt-seedingTimeLimiti-2e8:qBt-tagsle9:save_path15:/some/test/path9:seed_modei0e12:seeding_timei57e19:sequential_downloadi0e10:share_modei0e15:stop_when_readyi0e13:super_seedingi0e16:total_downloadedi1287360228e14:total_uploadedi0e8:trackersll35:https://torrent.ubuntu.com/announceel40:https://ipv6.torrent.ubuntu.com/announceee11:upload_modei0e17:upload_rate_limiti-1e8:url-listlee \ No newline at end of file +d11:active_timei90e10:added_timei1644727394e10:allocation6:sparse15:apply_ip_filteri1e12:auto_managedi0e12:banned_peers6:ÌÃdTá13:banned_peers60:14:completed_timei1644727428e11:disable_dhti0e11:disable_lsdi0e11:disable_pexi0e19:download_rate_limiti-1e11:file-format22:libtorrent resume file12:file-versioni1e13:file_priorityli1ee13:finished_timei57e9:httpseedsle9:info-hash20:É«ã>6yY<Ø™'‘ R‚RÍÄNn13:last_downloadi204407e18:last_seen_completei1644727466e11:last_uploadi0e18:libtorrent-version8:1.2.14.012:mapped_filesl29:/some/test/path/mapped_file_129:/some/test/path/mapped_file_2e15:max_connectionsi100e11:max_uploadsi4e12:num_completei1043e14:num_downloadedi16777215e14:num_incompletei10e6:pausedi1e5:peers0:6:peers60:6:pieces0:12:qBt-category0:17:qBt-contentLayout8:Original16:qBt-downloadPath15:/some/test/path26:qBt-firstLastPiecePriorityi0e8:qBt-name0:14:qBt-ratioLimiti-2000e12:qBt-savePath15:/some/test/path14:qBt-seedStatusi1e20:qBt-seedingTimeLimiti-2e8:qBt-tagsle9:save_path15:/some/test/path9:seed_modei0e12:seeding_timei57e19:sequential_downloadi0e10:share_modei0e15:stop_when_readyi0e13:super_seedingi0e16:total_downloadedi1287360228e14:total_uploadedi0e8:trackersll35:https://torrent.ubuntu.com/announceel40:https://ipv6.torrent.ubuntu.com/announceee11:upload_modei0e17:upload_rate_limiti-1e8:url-listlee \ No newline at end of file