torrt 1.2.0__tar.gz → 1.2.1__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.
Files changed (38) hide show
  1. {torrt-1.2.0 → torrt-1.2.1}/.gitignore +1 -0
  2. {torrt-1.2.0 → torrt-1.2.1}/PKG-INFO +2 -3
  3. {torrt-1.2.0 → torrt-1.2.1}/pyproject.toml +4 -1
  4. torrt-1.2.1/src/torrt/__init__.py +3 -0
  5. {torrt-1.2.0 → torrt-1.2.1}/src/torrt/base_rpc.py +28 -1
  6. {torrt-1.2.0 → torrt-1.2.1}/src/torrt/base_tracker.py +84 -9
  7. {torrt-1.2.0 → torrt-1.2.1}/src/torrt/bots/telegram_bot.py +68 -13
  8. torrt-1.2.1/src/torrt/incomplete.py +324 -0
  9. torrt-1.2.1/src/torrt/magnet.py +68 -0
  10. {torrt-1.2.0 → torrt-1.2.1}/src/torrt/main.py +14 -5
  11. {torrt-1.2.0 → torrt-1.2.1}/src/torrt/rpc/deluge.py +32 -10
  12. {torrt-1.2.0 → torrt-1.2.1}/src/torrt/rpc/qbittorrent.py +46 -15
  13. {torrt-1.2.0 → torrt-1.2.1}/src/torrt/rpc/transmission.py +46 -8
  14. {torrt-1.2.0 → torrt-1.2.1}/src/torrt/rpc/utorrent.py +44 -26
  15. {torrt-1.2.0 → torrt-1.2.1}/src/torrt/toolbox.py +138 -67
  16. torrt-1.2.1/src/torrt/trackers/anilibria.py +217 -0
  17. {torrt-1.2.0 → torrt-1.2.1}/src/torrt/trackers/eniahd.py +4 -0
  18. {torrt-1.2.0 → torrt-1.2.1}/src/torrt/trackers/nnmclub.py +4 -0
  19. {torrt-1.2.0 → torrt-1.2.1}/src/torrt/trackers/rutor.py +4 -0
  20. {torrt-1.2.0 → torrt-1.2.1}/src/torrt/trackers/rutracker.py +4 -0
  21. {torrt-1.2.0 → torrt-1.2.1}/src/torrt/utils.py +111 -23
  22. torrt-1.2.0/src/torrt/__init__.py +0 -3
  23. torrt-1.2.0/src/torrt/trackers/anilibria.py +0 -178
  24. {torrt-1.2.0 → torrt-1.2.1}/LICENSE +0 -0
  25. {torrt-1.2.0 → torrt-1.2.1}/README.md +0 -0
  26. {torrt-1.2.0 → torrt-1.2.1}/src/torrt/base_bot.py +0 -0
  27. {torrt-1.2.0 → torrt-1.2.1}/src/torrt/base_notifier.py +0 -0
  28. {torrt-1.2.0 → torrt-1.2.1}/src/torrt/bots/__init__.py +0 -0
  29. {torrt-1.2.0 → torrt-1.2.1}/src/torrt/exceptions.py +0 -0
  30. {torrt-1.2.0 → torrt-1.2.1}/src/torrt/notifiers/__init__.py +0 -0
  31. {torrt-1.2.0 → torrt-1.2.1}/src/torrt/notifiers/mail.py +0 -0
  32. {torrt-1.2.0 → torrt-1.2.1}/src/torrt/notifiers/telegram.py +0 -0
  33. {torrt-1.2.0 → torrt-1.2.1}/src/torrt/rpc/__init__.py +0 -0
  34. {torrt-1.2.0 → torrt-1.2.1}/src/torrt/trackers/__init__.py +0 -0
  35. {torrt-1.2.0 → torrt-1.2.1}/src/torrt/trackers/anidub.py +0 -0
  36. {torrt-1.2.0 → torrt-1.2.1}/src/torrt/trackers/casstudio.py +0 -0
  37. {torrt-1.2.0 → torrt-1.2.1}/src/torrt/trackers/kinozal.py +0 -0
  38. {torrt-1.2.0 → torrt-1.2.1}/src/torrt/trackers/ytsmx.py +0 -0
@@ -11,3 +11,4 @@ docs/_build/
11
11
  docs/build/
12
12
  build/
13
13
  dist/
14
+ *.lock
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: torrt
3
- Version: 1.2.0
3
+ Version: 1.2.1
4
4
  Summary: Automates torrent updates for you.
5
5
  Project-URL: Homepage, https://github.com/idlesign/torrt
6
6
  Project-URL: Documentation, https://torrt.readthedocs.io/
@@ -13,7 +13,6 @@ Classifier: License :: OSI Approved :: BSD License
13
13
  Classifier: Operating System :: OS Independent
14
14
  Classifier: Programming Language :: Python
15
15
  Classifier: Programming Language :: Python :: 3
16
- Classifier: Programming Language :: Python :: 3.10
17
16
  Classifier: Programming Language :: Python :: 3.11
18
17
  Classifier: Programming Language :: Python :: 3.12
19
18
  Classifier: Programming Language :: Python :: 3.13
@@ -15,7 +15,6 @@ classifiers = [
15
15
  "Operating System :: OS Independent",
16
16
  "Programming Language :: Python",
17
17
  "Programming Language :: Python :: 3",
18
- "Programming Language :: Python :: 3.10",
19
18
  "Programming Language :: Python :: 3.11",
20
19
  "Programming Language :: Python :: 3.12",
21
20
  "Programming Language :: Python :: 3.13",
@@ -75,6 +74,10 @@ packages = ["src/torrt"]
75
74
  packages = ["src/"]
76
75
 
77
76
  [tool.pytest.ini_options]
77
+ addopts = "-m 'not live_trackers'"
78
+ markers = [
79
+ "live_trackers: explicit checks against real torrent tracker websites",
80
+ ]
78
81
  testpaths = [
79
82
  "tests",
80
83
  ]
@@ -0,0 +1,3 @@
1
+
2
+ VERSION = '1.2.1'
3
+ """Application version string."""
@@ -1,8 +1,22 @@
1
- from typing import Any, ClassVar
1
+ from typing import Any, ClassVar, NamedTuple
2
2
 
3
+ from .exceptions import TorrtRPCException
3
4
  from .utils import HttpClient, RPCClassesRegistry, RPCObjectsRegistry, TorrentData, WithSettings
4
5
 
5
6
 
7
+ class TorrentFile(NamedTuple):
8
+ """Identify a file in the client's current torrent metadata."""
9
+
10
+ index: int
11
+ name: str
12
+
13
+ class TorrentStatus(NamedTuple):
14
+ """Describe a client's torrent without requiring a local torrent file."""
15
+
16
+ hash: str
17
+ metadata_ready: bool
18
+ files: tuple[TorrentFile, ...] = ()
19
+
6
20
  class BaseRPC(WithSettings):
7
21
  """Base RPC class. All RPC classes should inherit from this."""
8
22
 
@@ -65,6 +79,19 @@ class BaseRPC(WithSettings):
65
79
  """
66
80
  raise NotImplementedError
67
81
 
82
+ def method_get_torrent_status(self, hash_str: str) -> TorrentStatus | None:
83
+ """Return metadata status, or None only when absence is confirmed.
84
+
85
+ Failed status queries must raise TorrtRPCException rather than report absence.
86
+ """
87
+ raise TorrtRPCException("Magnet readiness checks are not supported by this RPC")
88
+
89
+ def method_apply_file_exclusions(self, status: TorrentStatus, excluded_names: list[str]) -> None:
90
+ """Apply saved file exclusions using the supplied torrent metadata."""
91
+
92
+ if excluded_names:
93
+ raise TorrtRPCException("File exclusions are not supported by this RPC")
94
+
68
95
  def method_remove_torrent(self, hash_str: str, *, with_data: bool = False) -> Any: # pragma: nocover
69
96
  """Removes torrent from torrent client using RPC.
70
97
 
@@ -7,6 +7,7 @@ from typing import ClassVar
7
7
  from urllib.parse import parse_qs, urljoin, urlparse
8
8
 
9
9
  from .exceptions import TorrtTrackerException
10
+ from .magnet import parse_magnet
10
11
  from .utils import (
11
12
  BeautifulSoup,
12
13
  HttpClient,
@@ -110,7 +111,7 @@ class BaseTracker(WithSettings):
110
111
  silence_exceptions=True,
111
112
  )
112
113
 
113
- if response and response.url.startswith(mirror_url):
114
+ if response and response.ok and response.url.startswith(mirror_url):
114
115
  mirror_picked = mirror_domain
115
116
  break
116
117
 
@@ -141,9 +142,31 @@ class BaseTracker(WithSettings):
141
142
  :param string: String, describing torrent. E.g. URL from torrent comment.
142
143
 
143
144
  """
145
+
146
+ if not isinstance(string, str) or not string:
147
+ return False
148
+
149
+ try:
150
+ parsed_url = urlparse(string)
151
+ hostname = parsed_url.hostname
152
+
153
+ if (
154
+ parsed_url.scheme not in ("http", "https")
155
+ or not hostname
156
+ or parsed_url.username is not None
157
+ or parsed_url.port == 0
158
+ ):
159
+ # maybe forgery
160
+ return False
161
+
162
+ except ValueError:
163
+ return False
164
+
144
165
  for domain in chain([cls.alias], cls.mirrors):
145
- if domain in string:
166
+
167
+ if domain and hostname in (domain.lower(), f"www.{domain.lower()}"):
146
168
  return True
169
+
147
170
  return False
148
171
 
149
172
  @classmethod
@@ -280,16 +303,31 @@ class BaseTracker(WithSettings):
280
303
  """This should implement a configuration test, e.g. make test login and report success."""
281
304
  return True
282
305
 
283
- def get_torrent(self, url: str, *, last_updated: datetime | None = None) -> TorrentData | None:
284
- """This method should be implemented in torrent tracker handler class
285
- and must return .torrent file contents.
306
+ def get_torrent(
307
+ self, url: str, *, last_updated: datetime | None = None, prefer_magnet: bool = False,
308
+ ) -> TorrentData | None:
309
+ """Return torrent file contents or a preferred magnet extracted by this tracker.
286
310
 
287
311
  :param url: URL to download torrent file from
288
312
  :param last_updated: torrent last updated datetime
313
+ :param prefer_magnet: use a valid magnet instead of downloading a torrent file
289
314
 
290
315
  """
291
316
  raise NotImplementedError # pragma: nocover
292
317
 
318
+ def get_magnet_link(self, url: str) -> str:
319
+ """Return a magnet only when the tracker explicitly implements extraction."""
320
+ return ""
321
+
322
+ def _find_magnet_link(self, url: str) -> str:
323
+ """Find a magnet href on a supported tracker page using the page cache."""
324
+
325
+ if not self.can_handle(url):
326
+ return ""
327
+
328
+ page = self.get_torrent_page(url)
329
+ return self.find_links(url, page, definite=r"(?i)^magnet:\?") or ""
330
+
293
331
  def extract_page_data(self) -> PageData:
294
332
  data = PageData(
295
333
  title=self.extract_page_title(),
@@ -364,14 +402,32 @@ class GenericTracker(BaseTracker):
364
402
  """
365
403
  return url.split('=')[1]
366
404
 
367
- def get_torrent(self, url: str, *, last_updated: datetime | None = None) -> TorrentData | None:
368
- """This is the main method which returns torrent file contents
369
- of file located at URL.
405
+ def get_torrent(
406
+ self, url: str, *, last_updated: datetime | None = None, prefer_magnet: bool = False,
407
+ ) -> TorrentData | None:
408
+ """Get torrent data from a tracker page, preferring a magnet when requested.
370
409
 
371
410
  :param url: URL to find and get torrent from
372
411
  :param last_updated: torrent last updated datetime
412
+ :param prefer_magnet: use a valid magnet instead of downloading a torrent file
373
413
 
374
414
  """
415
+
416
+ if not self.can_handle(url):
417
+ self.log_warning("Unsupported tracker page URL")
418
+ return None
419
+
420
+ if prefer_magnet and (magnet := self.get_magnet_link(url)):
421
+
422
+ try:
423
+ parse_magnet(magnet)
424
+
425
+ except ValueError:
426
+ self.log_warning("Invalid magnet link on tracker page; using torrent file instead")
427
+
428
+ else:
429
+ return TorrentData(url=url, magnet=magnet, page=self.extract_page_data())
430
+
375
431
  download_link = self.get_download_link(url)
376
432
 
377
433
  if not download_link:
@@ -382,7 +438,9 @@ class GenericTracker(BaseTracker):
382
438
 
383
439
  self.log_debug(f'Torrent download link found: {download_link}')
384
440
 
385
- if last_updated and last_updated >= page_data.date_updated:
441
+ date_updated = page_data.date_updated
442
+
443
+ if last_updated and date_updated and last_updated >= date_updated:
386
444
  self.log_debug('Skipped as up to date')
387
445
  return None
388
446
  else:
@@ -501,9 +559,24 @@ class GenericPrivateTracker(GenericPublicTracker):
501
559
  def test_configuration(self) -> bool:
502
560
  return self.login(self.alias)
503
561
 
562
+ def _find_magnet_link(self, url: str) -> str:
563
+ """Find a visible magnet, signing in once if the page requires a session."""
564
+ magnet = super()._find_magnet_link(url)
565
+
566
+ if not magnet and not self.logged_in and self.login(self.extract_domain(url)):
567
+ magnet = super()._find_magnet_link(url)
568
+
569
+ return magnet
570
+
504
571
  def login(self, domain: str) -> bool:
505
572
  """Implements tracker login procedure. Returns success bool."""
506
573
 
574
+ domain_url = f"https://{domain}/"
575
+
576
+ if not self.can_handle(domain_url) or urlparse(domain_url).netloc != domain:
577
+ self.log_warning("Unsupported tracker login domain")
578
+ return False
579
+
507
580
  login_url = self.login_url % {'domain': domain}
508
581
 
509
582
  self.log_debug(f'Trying to login at {login_url} ...')
@@ -549,6 +622,8 @@ class GenericPrivateTracker(GenericPublicTracker):
549
622
  self.query_string = parsed_qs[self.auth_qs_param_name][0]
550
623
 
551
624
  self.cookies = response.cookies
625
+ self._torrent_page = None
626
+ self._torrent_page_url = ""
552
627
 
553
628
  # Save auth info to config.
554
629
  self.save_settings()
@@ -27,14 +27,18 @@ class TelegramBot(BaseBot):
27
27
  alias: str = 'telegram'
28
28
  url: str = 'https://api.telegram.org/bot'
29
29
 
30
- def __init__(self, token: str, *, allowed_users: str = ''):
30
+ def __init__(self, token: str, *, allowed_users: str = "") -> None:
31
31
  """
32
32
  :param token: str Telegram's bot token
33
- :param allowed_users: comma-joined list of users allowed to add new torrents.
33
+ :param allowed_users: comma-joined list of users allowed to manage torrents.
34
34
 
35
35
  """
36
36
  self.token = token
37
37
  self.allowed_users = allowed_users or ''
38
+ self._allowed_usernames = frozenset(
39
+ name.strip().removeprefix("@").lower()
40
+ for name in self.allowed_users.split(",") if name.strip()
41
+ )
38
42
 
39
43
  if not telegram:
40
44
  raise BotRegistrationFailed('You have not installed python-telegram-bot library.')
@@ -44,10 +48,19 @@ class TelegramBot(BaseBot):
44
48
  self.dispatcher = self.updater.dispatcher
45
49
 
46
50
  if self.allowed_users:
47
- self.handler_kwargs = {'filters': Filters.user(username=self.allowed_users.split(','))}
51
+ self.handler_kwargs = {"filters": Filters.user(username=list(self._allowed_usernames))}
48
52
 
49
53
  super().__init__()
50
54
 
55
+ def is_allowed_user(self, update: "Update") -> bool:
56
+ """Check the actual sender, including users clicking shared group buttons."""
57
+
58
+ if not self.allowed_users:
59
+ return True
60
+
61
+ user = update.effective_user
62
+ return bool(user and user.username and user.username.lower() in self._allowed_usernames)
63
+
51
64
  def test_configuration(self) -> bool:
52
65
  return telegram and bool(self.updater)
53
66
 
@@ -91,9 +104,21 @@ class TelegramBot(BaseBot):
91
104
  add_handler(CommandHandler('remove', self.command_remove_torrents, **kwargs))
92
105
  add_handler(CommandHandler('help', self.command_help, **kwargs))
93
106
 
94
- def handle_callbacks(self, update: 'Update', context: 'CallbackContext'):
107
+ def handle_callbacks(self, update: "Update", context: "CallbackContext") -> None:
95
108
  """Handler to process all callbacks from buttons"""
96
109
 
110
+ query = update.callback_query
111
+
112
+ if not self.is_allowed_user(update):
113
+
114
+ if query:
115
+ query.answer("Access denied.", show_alert=True)
116
+
117
+ return
118
+
119
+ if not query or not isinstance(query.data, str):
120
+ return
121
+
97
122
  handlers = {
98
123
  'add_torrent': self.handle_ask_url,
99
124
  'list_torrents': self.command_list_torrents,
@@ -107,7 +132,10 @@ class TelegramBot(BaseBot):
107
132
  elif update.callback_query.data.startswith('hash:'):
108
133
  self.handle_remove_torrents(update, context)
109
134
 
110
- def handle_ask_url(self, update: 'Update', context: 'CallbackContext'):
135
+ def handle_ask_url(self, update: "Update", context: "CallbackContext") -> int:
136
+
137
+ if not self.is_allowed_user(update):
138
+ return ConversationHandler.END
111
139
 
112
140
  message = update.callback_query.message
113
141
  message.reply_text(
@@ -116,7 +144,10 @@ class TelegramBot(BaseBot):
116
144
  )
117
145
  return self.URL
118
146
 
119
- def handle_process_url(self, update: 'Update', context: 'CallbackContext'):
147
+ def handle_process_url(self, update: "Update", context: "CallbackContext") -> int:
148
+
149
+ if not self.is_allowed_user(update):
150
+ return ConversationHandler.END
120
151
 
121
152
  torrent_url = update.message.text
122
153
  torrent_data = get_torrent_from_url(torrent_url)
@@ -158,7 +189,10 @@ class TelegramBot(BaseBot):
158
189
 
159
190
  return self.PATH
160
191
 
161
- def handle_ask_download_path(self, update: 'Update', context: 'CallbackContext'):
192
+ def handle_ask_download_path(self, update: "Update", context: "CallbackContext") -> int:
193
+
194
+ if not self.is_allowed_user(update):
195
+ return ConversationHandler.END
162
196
 
163
197
  torrent_url = context.user_data.get('url')
164
198
 
@@ -204,7 +238,7 @@ class TelegramBot(BaseBot):
204
238
 
205
239
  return ConversationHandler.END
206
240
 
207
- def handle_remove_torrents(self, update: 'Update', context: 'CallbackContext'):
241
+ def handle_remove_torrents(self, update: "Update", context: "CallbackContext") -> None:
208
242
  """
209
243
  Handler for torrent remove action.
210
244
  data is colon-joined string which is contains:
@@ -213,6 +247,10 @@ class TelegramBot(BaseBot):
213
247
  3. 0|1 - optional attribute (with_data) responsible for data removal from RPC
214
248
  For example 'hash:1234567890' or 'hash:1234567890:0'
215
249
  """
250
+
251
+ if not self.is_allowed_user(update):
252
+ return
253
+
216
254
  message = update.callback_query.message
217
255
  data = update.callback_query.data
218
256
  split_data = data.split(':')[1:]
@@ -256,9 +294,12 @@ class TelegramBot(BaseBot):
256
294
 
257
295
  return self.URL
258
296
 
259
- def command_add_torrent(self, update: 'Update', context: 'CallbackContext'):
297
+ def command_add_torrent(self, update: "Update", context: "CallbackContext") -> None:
260
298
  """Stand-alone handler to add torrent"""
261
299
 
300
+ if not self.is_allowed_user(update):
301
+ return
302
+
262
303
  torrent_url = update.message.text.removeprefix('/add ')
263
304
 
264
305
  if not torrent_url:
@@ -286,9 +327,17 @@ class TelegramBot(BaseBot):
286
327
  else:
287
328
  context.bot.send_message(chat_id, text='Unable to register the torrent.')
288
329
 
289
- def command_list_torrents(self, update: 'Update', context: 'CallbackContext'):
330
+ def command_list_torrents(self, update: "Update", context: "CallbackContext") -> None:
290
331
  """Command to list all monitored torrents"""
291
332
 
333
+ if not self.is_allowed_user(update):
334
+ return
335
+
336
+ message = update.effective_message
337
+
338
+ if message is None:
339
+ return
340
+
292
341
  torrents = []
293
342
 
294
343
  for idx, torrent in enumerate(get_registered_torrents().values(), 1):
@@ -298,16 +347,23 @@ class TelegramBot(BaseBot):
298
347
  else:
299
348
  torrents.append(f"{idx}. {torrent['name']}")
300
349
 
301
- message = update.callback_query.message
302
350
  if torrents:
303
351
  message.reply_text('\n\n'.join(torrents))
304
352
 
305
353
  else:
306
354
  message.reply_text('No torrents yet.')
307
355
 
308
- def command_remove_torrents(self, update: 'Update', context: 'CallbackContext'):
356
+ def command_remove_torrents(self, update: "Update", context: "CallbackContext") -> None:
309
357
  """Command to remove torrent"""
310
358
 
359
+ if not self.is_allowed_user(update):
360
+ return
361
+
362
+ message = update.effective_message
363
+
364
+ if message is None:
365
+ return
366
+
311
367
  buttons = [
312
368
  [
313
369
  InlineKeyboardButton(
@@ -318,7 +374,6 @@ class TelegramBot(BaseBot):
318
374
  for torrent in get_registered_torrents().values()
319
375
  ]
320
376
 
321
- message = update.callback_query.message
322
377
  if not buttons:
323
378
  message.reply_text('No torrents yet.')
324
379
  return