quasarr 2.4.8__py3-none-any.whl → 2.4.10__py3-none-any.whl

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.

Potentially problematic release.


This version of quasarr might be problematic. Click here for more details.

Files changed (76) hide show
  1. quasarr/__init__.py +134 -70
  2. quasarr/api/__init__.py +40 -31
  3. quasarr/api/arr/__init__.py +116 -108
  4. quasarr/api/captcha/__init__.py +262 -137
  5. quasarr/api/config/__init__.py +76 -46
  6. quasarr/api/packages/__init__.py +138 -102
  7. quasarr/api/sponsors_helper/__init__.py +29 -16
  8. quasarr/api/statistics/__init__.py +19 -19
  9. quasarr/downloads/__init__.py +165 -72
  10. quasarr/downloads/linkcrypters/al.py +35 -18
  11. quasarr/downloads/linkcrypters/filecrypt.py +107 -52
  12. quasarr/downloads/linkcrypters/hide.py +5 -6
  13. quasarr/downloads/packages/__init__.py +342 -177
  14. quasarr/downloads/sources/al.py +191 -100
  15. quasarr/downloads/sources/by.py +31 -13
  16. quasarr/downloads/sources/dd.py +27 -14
  17. quasarr/downloads/sources/dj.py +1 -3
  18. quasarr/downloads/sources/dl.py +126 -71
  19. quasarr/downloads/sources/dt.py +11 -5
  20. quasarr/downloads/sources/dw.py +28 -14
  21. quasarr/downloads/sources/he.py +32 -24
  22. quasarr/downloads/sources/mb.py +19 -9
  23. quasarr/downloads/sources/nk.py +14 -10
  24. quasarr/downloads/sources/nx.py +8 -18
  25. quasarr/downloads/sources/sf.py +45 -20
  26. quasarr/downloads/sources/sj.py +1 -3
  27. quasarr/downloads/sources/sl.py +9 -5
  28. quasarr/downloads/sources/wd.py +32 -12
  29. quasarr/downloads/sources/wx.py +35 -21
  30. quasarr/providers/auth.py +42 -37
  31. quasarr/providers/cloudflare.py +28 -30
  32. quasarr/providers/hostname_issues.py +2 -1
  33. quasarr/providers/html_images.py +2 -2
  34. quasarr/providers/html_templates.py +22 -14
  35. quasarr/providers/imdb_metadata.py +149 -80
  36. quasarr/providers/jd_cache.py +131 -39
  37. quasarr/providers/log.py +1 -1
  38. quasarr/providers/myjd_api.py +260 -196
  39. quasarr/providers/notifications.py +53 -41
  40. quasarr/providers/obfuscated.py +9 -4
  41. quasarr/providers/sessions/al.py +71 -55
  42. quasarr/providers/sessions/dd.py +21 -14
  43. quasarr/providers/sessions/dl.py +30 -19
  44. quasarr/providers/sessions/nx.py +23 -14
  45. quasarr/providers/shared_state.py +292 -141
  46. quasarr/providers/statistics.py +75 -43
  47. quasarr/providers/utils.py +33 -27
  48. quasarr/providers/version.py +45 -14
  49. quasarr/providers/web_server.py +10 -5
  50. quasarr/search/__init__.py +30 -18
  51. quasarr/search/sources/al.py +124 -73
  52. quasarr/search/sources/by.py +110 -59
  53. quasarr/search/sources/dd.py +57 -35
  54. quasarr/search/sources/dj.py +69 -48
  55. quasarr/search/sources/dl.py +159 -100
  56. quasarr/search/sources/dt.py +110 -74
  57. quasarr/search/sources/dw.py +121 -61
  58. quasarr/search/sources/fx.py +108 -62
  59. quasarr/search/sources/he.py +78 -49
  60. quasarr/search/sources/mb.py +96 -48
  61. quasarr/search/sources/nk.py +80 -50
  62. quasarr/search/sources/nx.py +91 -62
  63. quasarr/search/sources/sf.py +171 -106
  64. quasarr/search/sources/sj.py +69 -48
  65. quasarr/search/sources/sl.py +115 -71
  66. quasarr/search/sources/wd.py +67 -44
  67. quasarr/search/sources/wx.py +188 -123
  68. quasarr/storage/config.py +65 -52
  69. quasarr/storage/setup.py +238 -140
  70. quasarr/storage/sqlite_database.py +10 -4
  71. {quasarr-2.4.8.dist-info → quasarr-2.4.10.dist-info}/METADATA +4 -3
  72. quasarr-2.4.10.dist-info/RECORD +81 -0
  73. quasarr-2.4.8.dist-info/RECORD +0 -81
  74. {quasarr-2.4.8.dist-info → quasarr-2.4.10.dist-info}/WHEEL +0 -0
  75. {quasarr-2.4.8.dist-info → quasarr-2.4.10.dist-info}/entry_points.txt +0 -0
  76. {quasarr-2.4.8.dist-info → quasarr-2.4.10.dist-info}/licenses/LICENSE +0 -0
@@ -7,14 +7,19 @@ import os
7
7
  import re
8
8
  import time
9
9
  import traceback
10
- from datetime import datetime, timedelta, date
11
- from urllib import parse
12
-
13
10
  import unicodedata
11
+ from datetime import date, datetime, timedelta
12
+ from urllib import parse
14
13
 
15
14
  import quasarr
16
- from quasarr.providers.log import info, debug
17
- from quasarr.providers.myjd_api import Myjdapi, TokenExpiredException, RequestTimeoutException, MYJDException, Jddevice
15
+ from quasarr.providers.log import debug, info
16
+ from quasarr.providers.myjd_api import (
17
+ Jddevice,
18
+ Myjdapi,
19
+ MYJDException,
20
+ RequestTimeoutException,
21
+ TokenExpiredException,
22
+ )
18
23
  from quasarr.storage.config import Config
19
24
  from quasarr.storage.sqlite_database import DataBase
20
25
 
@@ -22,9 +27,13 @@ values = {}
22
27
  lock = None
23
28
 
24
29
  # regex to detect season/episode tags for series filtering during search
25
- SEASON_EP_REGEX = re.compile(r"(?i)(?:S\d{1,3}(?:E\d{1,3}(?:-\d{1,3})?)?|S\d{1,3}-\d{1,3})")
30
+ SEASON_EP_REGEX = re.compile(
31
+ r"(?i)(?:S\d{1,3}(?:E\d{1,3}(?:-\d{1,3})?)?|S\d{1,3}-\d{1,3})"
32
+ )
26
33
  # regex to filter out season/episode tags for movies
27
- MOVIE_REGEX = re.compile(r"^(?!.*(?:S\d{1,3}(?:E\d{1,3}(?:-\d{1,3})?)?|S\d{1,3}-\d{1,3})).*$", re.IGNORECASE)
34
+ MOVIE_REGEX = re.compile(
35
+ r"^(?!.*(?:S\d{1,3}(?:E\d{1,3}(?:-\d{1,3})?)?|S\d{1,3}-\d{1,3})).*$", re.IGNORECASE
36
+ )
28
37
  # List of known file hosters that should not be used as search/feed sites
29
38
  SHARE_HOSTERS = {
30
39
  "rapidgator",
@@ -74,18 +83,18 @@ def set_files(config_path):
74
83
 
75
84
  def generate_api_key():
76
85
  api_key = os.urandom(32).hex()
77
- Config('API').save("key", api_key)
86
+ Config("API").save("key", api_key)
78
87
  info(f'API Key replaced with: "{api_key}!"')
79
88
  return api_key
80
89
 
81
90
 
82
91
  def extract_valid_hostname(url, shorthand):
83
92
  try:
84
- if '://' not in url:
85
- url = 'http://' + url
93
+ if "://" not in url:
94
+ url = "http://" + url
86
95
  result = parse.urlparse(url)
87
96
  domain = result.netloc
88
- parts = domain.split('.')
97
+ parts = domain.split(".")
89
98
 
90
99
  if domain.startswith(".") or domain.endswith(".") or "." not in domain[1:-1]:
91
100
  message = f'Error: "{domain}" must contain a "." somewhere in the middle – you need to provide a full domain name!'
@@ -122,13 +131,17 @@ def connect_to_jd(jd, user, password, device_name):
122
131
  info("Error connecting to JDownloader: " + str(e).strip())
123
132
  return False
124
133
  if not device or not isinstance(device, (type, Jddevice)):
125
- info(f'Device "{device_name}" not found. Available devices may differ or be offline.')
134
+ info(
135
+ f'Device "{device_name}" not found. Available devices may differ or be offline.'
136
+ )
126
137
  return False
127
138
  else:
128
139
  device.downloadcontroller.get_current_state() # request forces direct_connection info update
129
140
  connection_info = device.check_direct_connection()
130
141
  if connection_info["status"]:
131
- info(f'Direct connection to JDownloader established: "{connection_info['ip']}"')
142
+ info(
143
+ f'Direct connection to JDownloader established: "{connection_info["ip"]}"'
144
+ )
132
145
  else:
133
146
  info("Could not establish direct connection to JDownloader.")
134
147
  update("device", device)
@@ -137,42 +150,50 @@ def connect_to_jd(jd, user, password, device_name):
137
150
 
138
151
  def set_device(user, password, device):
139
152
  jd = Myjdapi()
140
- jd.set_app_key('Quasarr')
153
+ jd.set_app_key("Quasarr")
141
154
  return connect_to_jd(jd, user, password, device)
142
155
 
143
156
 
144
157
  def set_device_from_config():
145
- config = Config('JDownloader')
146
- user = str(config.get('user'))
147
- password = str(config.get('password'))
148
- device = str(config.get('device'))
158
+ config = Config("JDownloader")
159
+ user = str(config.get("user"))
160
+ password = str(config.get("password"))
161
+ device = str(config.get("device"))
149
162
 
150
163
  update("device", device)
151
164
 
152
165
  if user and password and device:
153
166
  jd = Myjdapi()
154
- jd.set_app_key('Quasarr')
167
+ jd.set_app_key("Quasarr")
155
168
  return connect_to_jd(jd, user, password, device)
156
169
  return False
157
170
 
158
171
 
159
172
  def check_device(device):
160
173
  try:
161
- valid = isinstance(device,
162
- (type, Jddevice)) and device.downloadcontroller.get_current_state()
163
- except (AttributeError, KeyError, TokenExpiredException, RequestTimeoutException, MYJDException):
174
+ valid = (
175
+ isinstance(device, (type, Jddevice))
176
+ and device.downloadcontroller.get_current_state()
177
+ )
178
+ except (
179
+ AttributeError,
180
+ KeyError,
181
+ TokenExpiredException,
182
+ RequestTimeoutException,
183
+ MYJDException,
184
+ ):
164
185
  valid = False
165
186
  return valid
166
187
 
167
188
 
168
189
  def connect_device():
169
- config = Config('JDownloader')
170
- user = str(config.get('user'))
171
- password = str(config.get('password'))
172
- device = str(config.get('device'))
190
+ config = Config("JDownloader")
191
+ user = str(config.get("user"))
192
+ password = str(config.get("password"))
193
+ device = str(config.get("device"))
173
194
 
174
195
  jd = Myjdapi()
175
- jd.set_app_key('Quasarr')
196
+ jd.set_app_key("Quasarr")
176
197
 
177
198
  if user and password and device:
178
199
  try:
@@ -197,7 +218,13 @@ def get_device():
197
218
  try:
198
219
  if check_device(values["device"]):
199
220
  break
200
- except (AttributeError, KeyError, TokenExpiredException, RequestTimeoutException, MYJDException):
221
+ except (
222
+ AttributeError,
223
+ KeyError,
224
+ TokenExpiredException,
225
+ RequestTimeoutException,
226
+ MYJDException,
227
+ ):
201
228
  pass
202
229
  attempts += 1
203
230
 
@@ -208,19 +235,27 @@ def get_device():
208
235
  # First 10 failures: 3 seconds
209
236
  sleep_time = 3
210
237
  if attempts == 10:
211
- info(f"WARNING: {attempts} consecutive JDownloader connection errors. Switching to 1-minute intervals.")
238
+ info(
239
+ f"WARNING: {attempts} consecutive JDownloader connection errors. Switching to 1-minute intervals."
240
+ )
212
241
  elif attempts <= 15:
213
242
  # Next 5 failures (11-15): 1 minute
214
243
  sleep_time = 60
215
244
  if attempts % 10 == 0:
216
- info(f"WARNING: {attempts} consecutive JDownloader connection errors. Please check your credentials!")
245
+ info(
246
+ f"WARNING: {attempts} consecutive JDownloader connection errors. Please check your credentials!"
247
+ )
217
248
  if attempts == 15:
218
- info(f"WARNING: Still failing after {attempts} attempts. Switching to 5-minute intervals.")
249
+ info(
250
+ f"WARNING: Still failing after {attempts} attempts. Switching to 5-minute intervals."
251
+ )
219
252
  else:
220
253
  # After 15 failures: 5 minutes
221
254
  sleep_time = 300
222
255
  if attempts % 10 == 0:
223
- info(f"WARNING: {attempts} consecutive JDownloader connection errors. Please check your credentials!")
256
+ info(
257
+ f"WARNING: {attempts} consecutive JDownloader connection errors. Please check your credentials!"
258
+ )
224
259
 
225
260
  if connect_device():
226
261
  break
@@ -232,7 +267,7 @@ def get_device():
232
267
 
233
268
  def get_devices(user, password):
234
269
  jd = Myjdapi()
235
- jd.set_app_key('Quasarr')
270
+ jd.set_app_key("Quasarr")
236
271
  try:
237
272
  jd.connect(user, password)
238
273
  jd.update_devices()
@@ -342,46 +377,96 @@ def set_device_settings():
342
377
  "storage": "cfg/org.jdownloader.extensions.extraction.ExtractionExtension",
343
378
  "setting": "BlacklistPatterns",
344
379
  "expected_values": [
345
- '.*sample/.*',
346
- '.*Sample/.*',
347
- '.*\\.jpe?g',
348
- '.*\\.idx',
349
- '.*\\.sub',
350
- '.*\\.srt',
351
- '.*\\.nfo',
352
- '.*\\.bat',
353
- '.*\\.txt',
354
- '.*\\.exe',
355
- '.*\\.sfv'
356
- ]
380
+ ".*sample/.*",
381
+ ".*Sample/.*",
382
+ ".*\\.jpe?g",
383
+ ".*\\.idx",
384
+ ".*\\.sub",
385
+ ".*\\.srt",
386
+ ".*\\.nfo",
387
+ ".*\\.bat",
388
+ ".*\\.txt",
389
+ ".*\\.exe",
390
+ ".*\\.sfv",
391
+ ],
357
392
  },
358
393
  {
359
394
  "namespace": "org.jdownloader.controlling.filter.LinkFilterSettings",
360
395
  "storage": "null",
361
396
  "setting": "FilterList",
362
397
  "expected_values": [
363
- {'conditionFilter':
364
- {'conditions': [], 'enabled': False, 'matchType': 'IS_TRUE'}, 'created': 0,
365
- 'enabled': True,
366
- 'filenameFilter': {
367
- 'enabled': True,
368
- 'matchType': 'CONTAINS',
369
- 'regex': '.*\\.(sfv|jpe?g|idx|srt|nfo|bat|txt|exe)',
370
- 'useRegex': True
371
- },
372
- 'filesizeFilter': {'enabled': False, 'from': 0, 'matchType': 'BETWEEN', 'to': 0},
373
- 'filetypeFilter': {'archivesEnabled': False, 'audioFilesEnabled': False, 'customs': None,
374
- 'docFilesEnabled': False, 'enabled': False, 'exeFilesEnabled': False,
375
- 'hashEnabled': False, 'imagesEnabled': False, 'matchType': 'IS',
376
- 'subFilesEnabled': False, 'useRegex': False, 'videoFilesEnabled': False},
377
- 'hosterURLFilter': {'enabled': False, 'matchType': 'CONTAINS', 'regex': '', 'useRegex': False},
378
- 'matchAlwaysFilter': {'enabled': False}, 'name': 'Quasarr_Block_Files',
379
- 'onlineStatusFilter': {'enabled': False, 'matchType': 'IS', 'onlineStatus': 'OFFLINE'},
380
- 'originFilter': {'enabled': False, 'matchType': 'IS', 'origins': []},
381
- 'packagenameFilter': {'enabled': False, 'matchType': 'CONTAINS', 'regex': '', 'useRegex': False},
382
- 'pluginStatusFilter': {'enabled': False, 'matchType': 'IS', 'pluginStatus': 'PREMIUM'},
383
- 'sourceURLFilter': {'enabled': False, 'matchType': 'CONTAINS', 'regex': '', 'useRegex': False},
384
- 'testUrl': ''}]
398
+ {
399
+ "conditionFilter": {
400
+ "conditions": [],
401
+ "enabled": False,
402
+ "matchType": "IS_TRUE",
403
+ },
404
+ "created": 0,
405
+ "enabled": True,
406
+ "filenameFilter": {
407
+ "enabled": True,
408
+ "matchType": "CONTAINS",
409
+ "regex": ".*\\.(sfv|jpe?g|idx|srt|nfo|bat|txt|exe)",
410
+ "useRegex": True,
411
+ },
412
+ "filesizeFilter": {
413
+ "enabled": False,
414
+ "from": 0,
415
+ "matchType": "BETWEEN",
416
+ "to": 0,
417
+ },
418
+ "filetypeFilter": {
419
+ "archivesEnabled": False,
420
+ "audioFilesEnabled": False,
421
+ "customs": None,
422
+ "docFilesEnabled": False,
423
+ "enabled": False,
424
+ "exeFilesEnabled": False,
425
+ "hashEnabled": False,
426
+ "imagesEnabled": False,
427
+ "matchType": "IS",
428
+ "subFilesEnabled": False,
429
+ "useRegex": False,
430
+ "videoFilesEnabled": False,
431
+ },
432
+ "hosterURLFilter": {
433
+ "enabled": False,
434
+ "matchType": "CONTAINS",
435
+ "regex": "",
436
+ "useRegex": False,
437
+ },
438
+ "matchAlwaysFilter": {"enabled": False},
439
+ "name": "Quasarr_Block_Files",
440
+ "onlineStatusFilter": {
441
+ "enabled": False,
442
+ "matchType": "IS",
443
+ "onlineStatus": "OFFLINE",
444
+ },
445
+ "originFilter": {
446
+ "enabled": False,
447
+ "matchType": "IS",
448
+ "origins": [],
449
+ },
450
+ "packagenameFilter": {
451
+ "enabled": False,
452
+ "matchType": "CONTAINS",
453
+ "regex": "",
454
+ "useRegex": False,
455
+ },
456
+ "pluginStatusFilter": {
457
+ "enabled": False,
458
+ "matchType": "IS",
459
+ "pluginStatus": "PREMIUM",
460
+ },
461
+ "sourceURLFilter": {
462
+ "enabled": False,
463
+ "matchType": "CONTAINS",
464
+ "regex": "",
465
+ "useRegex": False,
466
+ },
467
+ "testUrl": "",
468
+ }
469
+ ],
385
470
  },
386
471
  ]
387
472
 
@@ -418,7 +503,9 @@ def update_jdownloader():
418
503
  is_collecting = device.linkgrabber.is_collecting()
419
504
  update_available = device.update.update_available()
420
505
 
421
- if (current_state.lower() == "idle") and (not is_collecting and update_available):
506
+ if (current_state.lower() == "idle") and (
507
+ not is_collecting and update_available
508
+ ):
422
509
  info("JDownloader update ready. Starting update...")
423
510
  device.update.restart_and_update()
424
511
  except quasarr.providers.myjd_api.TokenExpiredException:
@@ -454,21 +541,23 @@ def get_db(table):
454
541
 
455
542
 
456
543
  def convert_to_mb(item):
457
- size = float(item['size'])
458
- unit = item['sizeunit'].upper()
544
+ size = float(item["size"])
545
+ unit = item["sizeunit"].upper()
459
546
 
460
- if unit == 'B':
547
+ if unit == "B":
461
548
  size_b = size
462
- elif unit == 'KB':
549
+ elif unit == "KB":
463
550
  size_b = size * 1024
464
- elif unit == 'MB':
551
+ elif unit == "MB":
465
552
  size_b = size * 1024 * 1024
466
- elif unit == 'GB':
553
+ elif unit == "GB":
467
554
  size_b = size * 1024 * 1024 * 1024
468
- elif unit == 'TB':
555
+ elif unit == "TB":
469
556
  size_b = size * 1024 * 1024 * 1024 * 1024
470
557
  else:
471
- raise ValueError(f"Unsupported size unit {item['name']} {item['size']} {item['sizeunit']}")
558
+ raise ValueError(
559
+ f"Unsupported size unit {item['name']} {item['size']} {item['sizeunit']}"
560
+ )
472
561
 
473
562
  size_mb = size_b / (1024 * 1024)
474
563
  return int(size_mb)
@@ -476,10 +565,13 @@ def convert_to_mb(item):
476
565
 
477
566
  def sanitize_title(title: str) -> str:
478
567
  umlaut_map = {
479
- "Ä": "Ae", "ä": "ae",
480
- "Ö": "Oe", "ö": "oe",
481
- "Ü": "Ue", "ü": "ue",
482
- "ß": "ss"
568
+ "Ä": "Ae",
569
+ "ä": "ae",
570
+ "Ö": "Oe",
571
+ "ö": "oe",
572
+ "Ü": "Ue",
573
+ "ü": "ue",
574
+ "ß": "ss",
483
575
  }
484
576
  for umlaut, replacement in umlaut_map.items():
485
577
  title = title.replace(umlaut, replacement)
@@ -503,32 +595,32 @@ def sanitize_string(s):
503
595
  s = s.lower()
504
596
 
505
597
  # Remove dots / pluses
506
- s = s.replace('.', ' ')
507
- s = s.replace('+', ' ')
508
- s = s.replace('_', ' ')
509
- s = s.replace('-', ' ')
598
+ s = s.replace(".", " ")
599
+ s = s.replace("+", " ")
600
+ s = s.replace("_", " ")
601
+ s = s.replace("-", " ")
510
602
 
511
603
  # Umlauts
512
- s = re.sub(r'ä', 'ae', s)
513
- s = re.sub(r'ö', 'oe', s)
514
- s = re.sub(r'ü', 'ue', s)
515
- s = re.sub(r'ß', 'ss', s)
604
+ s = re.sub(r"ä", "ae", s)
605
+ s = re.sub(r"ö", "oe", s)
606
+ s = re.sub(r"ü", "ue", s)
607
+ s = re.sub(r"ß", "ss", s)
516
608
 
517
609
  # Remove special characters
518
- s = re.sub(r'[^a-zA-Z0-9\s]', '', s)
610
+ s = re.sub(r"[^a-zA-Z0-9\s]", "", s)
519
611
 
520
612
  # Remove season and episode patterns
521
- s = re.sub(r'\bs\d{1,3}(e\d{1,3})?\b', '', s)
613
+ s = re.sub(r"\bs\d{1,3}(e\d{1,3})?\b", "", s)
522
614
 
523
615
  # Remove German and English articles
524
- articles = r'\b(?:der|die|das|ein|eine|einer|eines|einem|einen|the|a|an|and)\b'
525
- s = re.sub(articles, '', s, re.IGNORECASE)
616
+ articles = r"\b(?:der|die|das|ein|eine|einer|eines|einem|einen|the|a|an|and)\b"
617
+ s = re.sub(articles, "", s, re.IGNORECASE)
526
618
 
527
619
  # Replace obsolete titles
528
- s = s.replace('navy cis', 'ncis')
620
+ s = s.replace("navy cis", "ncis")
529
621
 
530
622
  # Remove extra whitespace
531
- s = ' '.join(s.split())
623
+ s = " ".join(s.split())
532
624
 
533
625
  return s
534
626
 
@@ -538,11 +630,15 @@ def search_string_in_sanitized_title(search_string, title):
538
630
  sanitized_title = sanitize_string(title)
539
631
 
540
632
  # Use word boundaries to ensure full word/phrase match
541
- if re.search(rf'\b{re.escape(sanitized_search_string)}\b', sanitized_title):
542
- debug(f"Matched search string: {sanitized_search_string} with title: {sanitized_title}")
633
+ if re.search(rf"\b{re.escape(sanitized_search_string)}\b", sanitized_title):
634
+ debug(
635
+ f"Matched search string: {sanitized_search_string} with title: {sanitized_title}"
636
+ )
543
637
  return True
544
638
  else:
545
- debug(f"Skipping {title} as it doesn't match search string: {sanitized_search_string}")
639
+ debug(
640
+ f"Skipping {title} as it doesn't match search string: {sanitized_search_string}"
641
+ )
546
642
  return False
547
643
 
548
644
 
@@ -602,11 +698,13 @@ def match_in_title(title: str, season: int = None, episode: int = None) -> bool:
602
698
  return False
603
699
 
604
700
 
605
- def is_valid_release(title: str,
606
- request_from: str,
607
- search_string: str,
608
- season: int = None,
609
- episode: int = None) -> bool:
701
+ def is_valid_release(
702
+ title: str,
703
+ request_from: str,
704
+ search_string: str,
705
+ season: int = None,
706
+ episode: int = None,
707
+ ) -> bool:
610
708
  """
611
709
  Return True if the given release title is valid for the given search parameters.
612
710
  - title: the release title to test
@@ -618,20 +716,24 @@ def is_valid_release(title: str,
618
716
  try:
619
717
  # Determine whether this is a movie or TV search
620
718
  rf = request_from.lower()
621
- is_movie_search = 'radarr' in rf
622
- is_tv_search = 'sonarr' in rf
623
- is_docs_search = 'lazylibrarian' in rf
719
+ is_movie_search = "radarr" in rf
720
+ is_tv_search = "sonarr" in rf
721
+ is_docs_search = "lazylibrarian" in rf
624
722
 
625
723
  # if search string is NOT an imdb id check search_string_in_sanitized_title - if not match, its not valid
626
724
  if not is_docs_search and not is_imdb_id(search_string):
627
725
  if not search_string_in_sanitized_title(search_string, title):
628
- debug(f"Skipping {title!r} as it doesn't match sanitized search string: {search_string!r}")
726
+ debug(
727
+ f"Skipping {title!r} as it doesn't match sanitized search string: {search_string!r}"
728
+ )
629
729
  return False
630
730
 
631
731
  # if it's a movie search, don't allow any TV show titles (check for NO season or episode tags in the title)
632
732
  if is_movie_search:
633
733
  if not MOVIE_REGEX.match(title):
634
- debug(f"Skipping {title!r} as title doesn't match movie regex: {MOVIE_REGEX.pattern}")
734
+ debug(
735
+ f"Skipping {title!r} as title doesn't match movie regex: {MOVIE_REGEX.pattern}"
736
+ )
635
737
  return False
636
738
  return True
637
739
 
@@ -639,12 +741,16 @@ def is_valid_release(title: str,
639
741
  if is_tv_search:
640
742
  # must have some S/E tag present
641
743
  if not SEASON_EP_REGEX.search(title):
642
- debug(f"Skipping {title!r} as title doesn't match TV show regex: {SEASON_EP_REGEX.pattern}")
744
+ debug(
745
+ f"Skipping {title!r} as title doesn't match TV show regex: {SEASON_EP_REGEX.pattern}"
746
+ )
643
747
  return False
644
748
  # if caller specified a season or episode, double‑check the match
645
749
  if season is not None or episode is not None:
646
750
  if not match_in_title(title, season, episode):
647
- debug(f"Skipping {title!r} as it doesn't match season {season} and episode {episode}")
751
+ debug(
752
+ f"Skipping {title!r} as it doesn't match season {season} and episode {episode}"
753
+ )
648
754
  return False
649
755
  return True
650
756
 
@@ -652,7 +758,9 @@ def is_valid_release(title: str,
652
758
  if is_docs_search:
653
759
  # must NOT have any S/E tag present
654
760
  if SEASON_EP_REGEX.search(title):
655
- debug(f"Skipping {title!r} as title matches TV show regex: {SEASON_EP_REGEX.pattern}")
761
+ debug(
762
+ f"Skipping {title!r} as title matches TV show regex: {SEASON_EP_REGEX.pattern}"
763
+ )
656
764
  return False
657
765
  return True
658
766
 
@@ -663,10 +771,12 @@ def is_valid_release(title: str,
663
771
  except Exception as e:
664
772
  # log exception message and short stack trace
665
773
  tb = traceback.format_exc()
666
- debug(f"Exception in is_valid_release: {e!r}\n{tb}"
667
- f"is_valid_release called with "
668
- f"title={title!r}, request_from={request_from!r}, "
669
- f"search_string={search_string!r}, season={season!r}, episode={episode!r}")
774
+ debug(
775
+ f"Exception in is_valid_release: {e!r}\n{tb}"
776
+ f"is_valid_release called with "
777
+ f"title={title!r}, request_from={request_from!r}, "
778
+ f"search_string={search_string!r}, season={season!r}, episode={episode!r}"
779
+ )
670
780
  return False
671
781
 
672
782
 
@@ -724,7 +834,7 @@ def normalize_magazine_title(title: str) -> str:
724
834
  r"\b(?:vom\s*)?(\d{1,2})\.?\s+([A-Za-zÄÖÜäöüß]+)\s+(\d{4})\b",
725
835
  repl_dmony,
726
836
  title,
727
- flags=re.IGNORECASE
837
+ flags=re.IGNORECASE,
728
838
  )
729
839
 
730
840
  # 3) MonthName YYYY -> "YYYY MM"
@@ -740,7 +850,9 @@ def normalize_magazine_title(title: str) -> str:
740
850
  pass
741
851
  return match.group(0)
742
852
 
743
- title = re.sub(r"\b([A-Za-zÄÖÜäöüß]+)\s+(\d{4})\b", repl_mony, title, flags=re.IGNORECASE)
853
+ title = re.sub(
854
+ r"\b([A-Za-zÄÖÜäöüß]+)\s+(\d{4})\b", repl_mony, title, flags=re.IGNORECASE
855
+ )
744
856
 
745
857
  # 4) YYYYMMDD -> "YYYY MM DD"
746
858
  def repl_ymd(match):
@@ -753,7 +865,9 @@ def normalize_magazine_title(title: str) -> str:
753
865
  except ValueError:
754
866
  return match.group(0)
755
867
 
756
- title = re.sub(r"\b(20\d{2})(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\b", repl_ymd, title)
868
+ title = re.sub(
869
+ r"\b(20\d{2})(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\b", repl_ymd, title
870
+ )
757
871
 
758
872
  # 5) YYYYMM -> "YYYY MM"
759
873
  def repl_ym(match):
@@ -798,7 +912,7 @@ def normalize_magazine_title(title: str) -> str:
798
912
  r"\b(?:No|Nr|Sonderheft)\s*(\d{1,2})\.(\d{4})\b",
799
913
  repl_nmy,
800
914
  title,
801
- flags=re.IGNORECASE
915
+ flags=re.IGNORECASE,
802
916
  )
803
917
 
804
918
  return title
@@ -808,11 +922,44 @@ def normalize_magazine_title(title: str) -> str:
808
922
  def _month_num(name: str) -> int:
809
923
  name = name.lower()
810
924
  mmap = {
811
- 'januar': 1, 'jan': 1, 'februar': 2, 'feb': 2, 'märz': 3, 'maerz': 3, 'mär': 3, 'mrz': 3, 'mae': 3,
812
- 'april': 4, 'apr': 4, 'mai': 5, 'juni': 6, 'jun': 6, 'juli': 7, 'jul': 7, 'august': 8, 'aug': 8,
813
- 'september': 9, 'sep': 9, 'oktober': 10, 'okt': 10, 'november': 11, 'nov': 11, 'dezember': 12, 'dez': 12,
814
- 'january': 1, 'february': 2, 'march': 3, 'april': 4, 'may': 5, 'june': 6, 'july': 7, 'august': 8,
815
- 'september': 9, 'october': 10, 'november': 11, 'december': 12
925
+ "januar": 1,
926
+ "jan": 1,
927
+ "februar": 2,
928
+ "feb": 2,
929
+ "märz": 3,
930
+ "maerz": 3,
931
+ "mär": 3,
932
+ "mrz": 3,
933
+ "mae": 3,
934
+ "april": 4,
935
+ "apr": 4,
936
+ "mai": 5,
937
+ "juni": 6,
938
+ "jun": 6,
939
+ "juli": 7,
940
+ "jul": 7,
941
+ "august": 8,
942
+ "aug": 8,
943
+ "september": 9,
944
+ "sep": 9,
945
+ "oktober": 10,
946
+ "okt": 10,
947
+ "november": 11,
948
+ "nov": 11,
949
+ "dezember": 12,
950
+ "dez": 12,
951
+ "january": 1,
952
+ "february": 2,
953
+ "march": 3,
954
+ "april": 4,
955
+ "may": 5,
956
+ "june": 6,
957
+ "july": 7,
958
+ "august": 8,
959
+ "september": 9,
960
+ "october": 10,
961
+ "november": 11,
962
+ "december": 12,
816
963
  }
817
964
  return mmap.get(name)
818
965
 
@@ -820,7 +967,11 @@ def _month_num(name: str) -> int:
820
967
  def get_recently_searched(shared_state, context, timeout_seconds):
821
968
  recently_searched = shared_state.values.get(context, {})
822
969
  threshold = datetime.now() - timedelta(seconds=timeout_seconds)
823
- keys_to_remove = [key for key, value in recently_searched.items() if value["timestamp"] <= threshold]
970
+ keys_to_remove = [
971
+ key
972
+ for key, value in recently_searched.items()
973
+ if value["timestamp"] <= threshold
974
+ ]
824
975
  for key in keys_to_remove:
825
976
  debug(f"Removing '{key}' from recently searched memory ({context})...")
826
977
  del recently_searched[key]
@@ -831,19 +982,21 @@ def download_package(links, title, password, package_id):
831
982
  links = [sanitize_url(link) for link in links]
832
983
 
833
984
  device = get_device()
834
- downloaded = device.linkgrabber.add_links(params=[
835
- {
836
- "autostart": False,
837
- "links": json.dumps(links),
838
- "packageName": title,
839
- "extractPassword": password,
840
- "priority": "DEFAULT",
841
- "downloadPassword": password,
842
- "destinationFolder": "Quasarr/<jd:packagename>",
843
- "comment": package_id,
844
- "overwritePackagizerRules": True
845
- }
846
- ])
985
+ downloaded = device.linkgrabber.add_links(
986
+ params=[
987
+ {
988
+ "autostart": False,
989
+ "links": json.dumps(links),
990
+ "packageName": title,
991
+ "extractPassword": password,
992
+ "priority": "DEFAULT",
993
+ "downloadPassword": password,
994
+ "destinationFolder": "Quasarr/<jd:packagename>",
995
+ "comment": package_id,
996
+ "overwritePackagizerRules": True,
997
+ }
998
+ ]
999
+ )
847
1000
  return downloaded
848
1001
 
849
1002
 
@@ -852,12 +1005,10 @@ def sanitize_url(url: str) -> str:
852
1005
  url = unicodedata.normalize("NFKC", url)
853
1006
 
854
1007
  # 1) real control characters (U+0000–U+001F, U+007F–U+009F)
855
- _REAL_CTRL_RE = re.compile(r'[\u0000-\u001f\u007f-\u009f]')
1008
+ _REAL_CTRL_RE = re.compile(r"[\u0000-\u001f\u007f-\u009f]")
856
1009
 
857
1010
  # 2) *literal* escaped unicode junk: \u0010, \x10, repeated variants
858
- _ESCAPED_CTRL_RE = re.compile(
859
- r'(?:\\u00[0-1][0-9a-fA-F]|\\x[0-1][0-9a-fA-F])'
860
- )
1011
+ _ESCAPED_CTRL_RE = re.compile(r"(?:\\u00[0-1][0-9a-fA-F]|\\x[0-1][0-9a-fA-F])")
861
1012
 
862
1013
  # remove literal escaped control sequences like "\u0010"
863
1014
  url = _ESCAPED_CTRL_RE.sub("", url)