CDMClient 1.0.2__tar.gz → 1.0.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: CDMClient
3
- Version: 1.0.2
3
+ Version: 1.0.3
4
4
  Summary: CDMClient
5
5
  Author-email: Aron Radics <radics.aron.jozsef@gmail.com>
6
6
  Project-URL: Homepage, https://github.com/radaron/CDMClient
@@ -100,21 +100,3 @@ To monitor the service logs, use the following command:
100
100
  ```shell
101
101
  journalctl -fu cdm-client
102
102
  ```
103
-
104
- ## Torrent Migration
105
- You can migrate torrents between Transmission and qBittorrent with:
106
- ```shell
107
- cdm-migrate --source-type transmission --target-type qbittorrent \
108
- --source-host 127.0.0.1 --source-port 9091 --source-username user --source-password pass \
109
- --target-host 127.0.0.1 --target-port 8080 --target-username admin --target-password adminadmin
110
- ```
111
-
112
- ### Recommended first run (dry-run)
113
- ```shell
114
- cdm-migrate --source-type transmission --target-type qbittorrent --dry-run
115
- ```
116
-
117
- ### Reverse direction (qBittorrent -> Transmission)
118
- ```shell
119
- cdm-migrate --source-type qbittorrent --target-type transmission
120
- ```
@@ -11,7 +11,6 @@ cdm_client/__init__.py
11
11
  cdm_client/cdm_client.py
12
12
  cdm_client/config.py
13
13
  cdm_client/database_adapter.py
14
- cdm_client/migrate_torrents.py
15
14
  cdm_client/qbittorrent_adapter.py
16
15
  cdm_client/torrent_client_adapter_base.py
17
16
  cdm_client/torrent_client_factory.py
@@ -1,3 +1,2 @@
1
1
  [console_scripts]
2
2
  cdm-client = cdm_client.cdm_client:main
3
- cdm-migrate = cdm_client.migrate_torrents:main
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: CDMClient
3
- Version: 1.0.2
3
+ Version: 1.0.3
4
4
  Summary: CDMClient
5
5
  Author-email: Aron Radics <radics.aron.jozsef@gmail.com>
6
6
  Project-URL: Homepage, https://github.com/radaron/CDMClient
@@ -100,21 +100,3 @@ To monitor the service logs, use the following command:
100
100
  ```shell
101
101
  journalctl -fu cdm-client
102
102
  ```
103
-
104
- ## Torrent Migration
105
- You can migrate torrents between Transmission and qBittorrent with:
106
- ```shell
107
- cdm-migrate --source-type transmission --target-type qbittorrent \
108
- --source-host 127.0.0.1 --source-port 9091 --source-username user --source-password pass \
109
- --target-host 127.0.0.1 --target-port 8080 --target-username admin --target-password adminadmin
110
- ```
111
-
112
- ### Recommended first run (dry-run)
113
- ```shell
114
- cdm-migrate --source-type transmission --target-type qbittorrent --dry-run
115
- ```
116
-
117
- ### Reverse direction (qBittorrent -> Transmission)
118
- ```shell
119
- cdm-migrate --source-type qbittorrent --target-type transmission
120
- ```
@@ -81,21 +81,3 @@ To monitor the service logs, use the following command:
81
81
  ```shell
82
82
  journalctl -fu cdm-client
83
83
  ```
84
-
85
- ## Torrent Migration
86
- You can migrate torrents between Transmission and qBittorrent with:
87
- ```shell
88
- cdm-migrate --source-type transmission --target-type qbittorrent \
89
- --source-host 127.0.0.1 --source-port 9091 --source-username user --source-password pass \
90
- --target-host 127.0.0.1 --target-port 8080 --target-username admin --target-password adminadmin
91
- ```
92
-
93
- ### Recommended first run (dry-run)
94
- ```shell
95
- cdm-migrate --source-type transmission --target-type qbittorrent --dry-run
96
- ```
97
-
98
- ### Reverse direction (qBittorrent -> Transmission)
99
- ```shell
100
- cdm-migrate --source-type qbittorrent --target-type transmission
101
- ```
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "CDMClient"
3
- version = "1.0.2"
3
+ version = "1.0.3"
4
4
  description = "CDMClient"
5
5
  authors = [{name = "Aron Radics", email = "radics.aron.jozsef@gmail.com"}]
6
6
  requires-python = ">=3.9"
@@ -44,4 +44,3 @@ package = true
44
44
 
45
45
  [project.scripts]
46
46
  cdm-client = "cdm_client.cdm_client:main"
47
- cdm-migrate = "cdm_client.migrate_torrents:main"
@@ -1,374 +0,0 @@
1
- import argparse
2
- import logging
3
- from dataclasses import dataclass
4
- from typing import Optional
5
-
6
- from transmission_rpc import Torrent
7
-
8
- from cdm_client.database_adapter import DatabaseAdapter
9
- from cdm_client.qbittorrent_adapter import QBitTorrentAdapter
10
- from cdm_client.torrent_client_adapter_base import TorrentClientAdapterBase
11
- from cdm_client.torrent_client_factory import (
12
- TorrentClientType,
13
- create_torrent_client_adapter,
14
- )
15
- from cdm_client.transmission_adapter import TransmissionAdapter
16
-
17
-
18
- @dataclass
19
- class TorrentMigrationItem:
20
- source_id: int
21
- hash_value: str
22
- name: str
23
- download_dir: str
24
- magnet_link: Optional[str]
25
- payload_bytes: Optional[bytes]
26
-
27
-
28
- @dataclass
29
- class MigrationStats:
30
- migrated: int = 0
31
- skipped_duplicate: int = 0
32
- failed_add: int = 0
33
- failed_lookup: int = 0
34
- failed_db_update: int = 0
35
- source_removed: int = 0
36
-
37
-
38
- def _hash_to_id(torrent_hash: str) -> int:
39
- return int(torrent_hash[:8], 16)
40
-
41
-
42
- def _normalize_hash(torrent_hash: str) -> str:
43
- return torrent_hash.lower()
44
-
45
-
46
- def _build_parser() -> argparse.ArgumentParser:
47
- parser = argparse.ArgumentParser(
48
- description="Migrate torrents between Transmission and qBittorrent."
49
- )
50
- parser.add_argument(
51
- "--source-type",
52
- required=True,
53
- choices=[
54
- TorrentClientType.TRANSMISSION.value,
55
- TorrentClientType.QBITTORRENT.value,
56
- ],
57
- )
58
- parser.add_argument(
59
- "--target-type",
60
- required=True,
61
- choices=[
62
- TorrentClientType.TRANSMISSION.value,
63
- TorrentClientType.QBITTORRENT.value,
64
- ],
65
- )
66
-
67
- parser.add_argument("--source-host")
68
- parser.add_argument("--source-port", type=int)
69
- parser.add_argument("--source-username")
70
- parser.add_argument("--source-password")
71
-
72
- parser.add_argument("--target-host")
73
- parser.add_argument("--target-port", type=int)
74
- parser.add_argument("--target-username")
75
- parser.add_argument("--target-password")
76
-
77
- parser.add_argument("--dry-run", action="store_true")
78
- parser.add_argument("--verbose", action="store_true")
79
- return parser
80
-
81
-
82
- def _default_host() -> str:
83
- return "127.0.0.1"
84
-
85
-
86
- def _default_port(client_type: TorrentClientType) -> int:
87
- if client_type == TorrentClientType.TRANSMISSION:
88
- return 9091
89
- return 8080
90
-
91
-
92
- def _list_source_items(
93
- source_type: TorrentClientType, source_adapter: TorrentClientAdapterBase
94
- ) -> list[TorrentMigrationItem]:
95
- if source_type == TorrentClientType.TRANSMISSION:
96
- if not isinstance(source_adapter, TransmissionAdapter):
97
- raise TypeError("Invalid source adapter for Transmission")
98
- torrents = source_adapter._client.get_torrents()
99
- items: list[TorrentMigrationItem] = []
100
- for torrent in torrents:
101
- if not isinstance(torrent, Torrent):
102
- continue
103
- items.append(
104
- TorrentMigrationItem(
105
- source_id=int(torrent.id),
106
- hash_value=_normalize_hash(torrent.hashString),
107
- name=torrent.name,
108
- download_dir=torrent.download_dir,
109
- magnet_link=torrent.magnet_link,
110
- payload_bytes=None,
111
- )
112
- )
113
- return items
114
-
115
- if not isinstance(source_adapter, QBitTorrentAdapter):
116
- raise TypeError("Invalid source adapter for qBittorrent")
117
- items = []
118
- for torrent in source_adapter._client.torrents_info():
119
- torrent_hash = _normalize_hash(torrent.hash)
120
- payload_bytes: Optional[bytes] = None
121
- try:
122
- payload_bytes = source_adapter._client.torrents_export(
123
- torrent_hash=torrent_hash
124
- )
125
- except Exception:
126
- payload_bytes = None
127
- magnet_link: Optional[str] = None
128
- if hasattr(torrent, "magnet_uri"):
129
- magnet_candidate = getattr(torrent, "magnet_uri")
130
- if isinstance(magnet_candidate, str) and magnet_candidate:
131
- magnet_link = magnet_candidate
132
- items.append(
133
- TorrentMigrationItem(
134
- source_id=_hash_to_id(torrent_hash),
135
- hash_value=torrent_hash,
136
- name=torrent.name,
137
- download_dir=torrent.save_path,
138
- magnet_link=magnet_link,
139
- payload_bytes=payload_bytes,
140
- )
141
- )
142
- return items
143
-
144
-
145
- def _build_target_hash_index(
146
- target_type: TorrentClientType, target_adapter: TorrentClientAdapterBase
147
- ) -> dict[str, int]:
148
- hash_to_id: dict[str, int] = {}
149
- if target_type == TorrentClientType.TRANSMISSION:
150
- if not isinstance(target_adapter, TransmissionAdapter):
151
- raise TypeError("Invalid target adapter for Transmission")
152
- for torrent in target_adapter._client.get_torrents():
153
- if not isinstance(torrent, Torrent):
154
- continue
155
- hash_to_id[_normalize_hash(torrent.hashString)] = int(torrent.id)
156
- return hash_to_id
157
-
158
- if not isinstance(target_adapter, QBitTorrentAdapter):
159
- raise TypeError("Invalid target adapter for qBittorrent")
160
- for torrent in target_adapter._client.torrents_info():
161
- torrent_hash = _normalize_hash(torrent.hash)
162
- hash_to_id[torrent_hash] = _hash_to_id(torrent_hash)
163
- return hash_to_id
164
-
165
-
166
- def _add_to_target(
167
- item: TorrentMigrationItem,
168
- target_type: TorrentClientType,
169
- target_adapter: TorrentClientAdapterBase,
170
- ) -> Optional[int]:
171
- if target_type == TorrentClientType.TRANSMISSION:
172
- if not isinstance(target_adapter, TransmissionAdapter):
173
- raise TypeError("Invalid target adapter for Transmission")
174
- if item.payload_bytes:
175
- created = target_adapter.add_torrent(item.payload_bytes, item.download_dir)
176
- if created is None:
177
- return None
178
- return int(created.id)
179
- if item.magnet_link:
180
- created = target_adapter._client.add_torrent(
181
- item.magnet_link, download_dir=item.download_dir
182
- )
183
- return int(created.id)
184
- return None
185
-
186
- if not isinstance(target_adapter, QBitTorrentAdapter):
187
- raise TypeError("Invalid target adapter for qBittorrent")
188
- if item.payload_bytes:
189
- added = target_adapter.add_torrent(item.payload_bytes, item.download_dir)
190
- if added is None:
191
- return None
192
- elif item.magnet_link:
193
- target_adapter._client.torrents_add(
194
- urls=item.magnet_link, save_path=item.download_dir
195
- )
196
- else:
197
- return None
198
- for torrent in target_adapter._client.torrents_info():
199
- if _normalize_hash(torrent.hash) == item.hash_value:
200
- return _hash_to_id(item.hash_value)
201
- return None
202
-
203
-
204
- def _reconcile_mapping(source_id: int, target_id: int) -> bool:
205
- with DatabaseAdapter() as db_adapter:
206
- tracker_id = db_adapter.get_tracker_id_by_torrent_id(source_id)
207
- logging.debug(
208
- "source_id=%s, target_id=%s, tracker_id=%s",
209
- source_id,
210
- target_id,
211
- tracker_id,
212
- )
213
- if tracker_id is None:
214
- return True
215
- return db_adapter.update_torrent_id(tracker_id, target_id)
216
-
217
-
218
- def _parse_types_or_exit(
219
- source_type_raw: str,
220
- target_type_raw: str,
221
- ) -> tuple[TorrentClientType, TorrentClientType]:
222
- try:
223
- source_type = TorrentClientType(source_type_raw)
224
- target_type = TorrentClientType(target_type_raw)
225
- except ValueError as exc:
226
- raise SystemExit(2) from exc
227
- return source_type, target_type
228
-
229
-
230
- def _validate_args_or_exit(
231
- args: argparse.Namespace,
232
- source_type: TorrentClientType,
233
- target_type: TorrentClientType,
234
- ) -> None:
235
- source_host = args.source_host or _default_host()
236
- target_host = args.target_host or _default_host()
237
- source_port = args.source_port or _default_port(source_type)
238
- target_port = args.target_port or _default_port(target_type)
239
-
240
- if source_type == target_type:
241
- raise SystemExit("Source and target types are identical.")
242
-
243
- same_endpoint = (
244
- source_type == target_type
245
- and source_host == target_host
246
- and source_port == target_port
247
- )
248
- if same_endpoint:
249
- raise SystemExit("Source and target endpoint are identical.")
250
-
251
-
252
- def _print_summary(stats: MigrationStats, failures: list[str], dry_run: bool) -> None:
253
- mode_prefix = "DRY-RUN " if dry_run else ""
254
- print(f"{mode_prefix}summary:")
255
- print(f" migrated: {stats.migrated}")
256
- print(f" skipped_duplicate: {stats.skipped_duplicate}")
257
- print(f" failed_add: {stats.failed_add}")
258
- print(f" failed_lookup: {stats.failed_lookup}")
259
- print(f" failed_db_update: {stats.failed_db_update}")
260
- print(f" source_removed: {stats.source_removed}")
261
- if failures:
262
- print("failed items:")
263
- for failure in failures:
264
- print(f" - {failure}")
265
-
266
-
267
- def run_migration(args: argparse.Namespace) -> int:
268
- source_type, target_type = _parse_types_or_exit(args.source_type, args.target_type)
269
- _validate_args_or_exit(args, source_type, target_type)
270
-
271
- source_host = args.source_host or _default_host()
272
- target_host = args.target_host or _default_host()
273
- source_port = args.source_port or _default_port(source_type)
274
- target_port = args.target_port or _default_port(target_type)
275
-
276
- source_adapter = create_torrent_client_adapter(
277
- client_type=source_type,
278
- host=source_host,
279
- port=source_port,
280
- username=args.source_username,
281
- password=args.source_password,
282
- )
283
- target_adapter = create_torrent_client_adapter(
284
- client_type=target_type,
285
- host=target_host,
286
- port=target_port,
287
- username=args.target_username,
288
- password=args.target_password,
289
- )
290
-
291
- source_items = _list_source_items(source_type, source_adapter)
292
- target_hash_to_id = _build_target_hash_index(target_type, target_adapter)
293
-
294
- stats = MigrationStats()
295
- failures: list[str] = []
296
- had_failure = False
297
-
298
- for item in source_items:
299
- if item.hash_value in target_hash_to_id:
300
- stats.skipped_duplicate += 1
301
- target_id = target_hash_to_id[item.hash_value]
302
- if not args.dry_run:
303
- mapping_updated = _reconcile_mapping(item.source_id, target_id)
304
- if not mapping_updated:
305
- stats.failed_db_update += 1
306
- had_failure = True
307
- failures.append(
308
- f"{item.name} ({item.hash_value}) duplicate mapping update failed"
309
- )
310
- continue
311
-
312
- if item.payload_bytes is None and not item.magnet_link:
313
- stats.failed_add += 1
314
- had_failure = True
315
- failures.append(
316
- f"{item.name} ({item.hash_value}) no transferable payload or magnet"
317
- )
318
- continue
319
-
320
- if args.dry_run:
321
- print(
322
- "DRY-RUN migrate:",
323
- item.name,
324
- item.hash_value,
325
- "->",
326
- target_type.value,
327
- f"path={item.download_dir}",
328
- )
329
- continue
330
-
331
- try:
332
- new_target_id = _add_to_target(item, target_type, target_adapter)
333
- except Exception as exc:
334
- stats.failed_add += 1
335
- had_failure = True
336
- failures.append(f"{item.name} ({item.hash_value}) add failed: {exc}")
337
- continue
338
-
339
- if new_target_id is None:
340
- stats.failed_lookup += 1
341
- had_failure = True
342
- failures.append(
343
- f"{item.name} ({item.hash_value}) added but target id lookup failed"
344
- )
345
- continue
346
-
347
- target_hash_to_id[item.hash_value] = new_target_id
348
- stats.migrated += 1
349
-
350
- if not _reconcile_mapping(item.source_id, new_target_id):
351
- stats.failed_db_update += 1
352
- had_failure = True
353
- failures.append(
354
- f"{item.name} ({item.hash_value}) mapping update failed "
355
- f"{item.source_id}->{new_target_id}"
356
- )
357
- _print_summary(stats, failures, args.dry_run)
358
- return 1 if had_failure else 0
359
-
360
-
361
- def main() -> None:
362
- parser = _build_parser()
363
- args = parser.parse_args()
364
- logging.basicConfig(level=logging.DEBUG if args.verbose else logging.INFO)
365
- try:
366
- exit_code = run_migration(args)
367
- except SystemExit as exc:
368
- if isinstance(exc.code, int):
369
- raise
370
- parser.error(str(exc))
371
- except Exception as exc:
372
- print(f"Migration failed: {exc}")
373
- raise SystemExit(1) from exc
374
- raise SystemExit(exit_code)
File without changes
File without changes