quasarr 0.1.6__py3-none-any.whl → 1.23.0__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 (77) hide show
  1. quasarr/__init__.py +316 -42
  2. quasarr/api/__init__.py +187 -0
  3. quasarr/api/arr/__init__.py +387 -0
  4. quasarr/api/captcha/__init__.py +1189 -0
  5. quasarr/api/config/__init__.py +23 -0
  6. quasarr/api/sponsors_helper/__init__.py +166 -0
  7. quasarr/api/statistics/__init__.py +196 -0
  8. quasarr/downloads/__init__.py +319 -256
  9. quasarr/downloads/linkcrypters/__init__.py +0 -0
  10. quasarr/downloads/linkcrypters/al.py +237 -0
  11. quasarr/downloads/linkcrypters/filecrypt.py +444 -0
  12. quasarr/downloads/linkcrypters/hide.py +123 -0
  13. quasarr/downloads/packages/__init__.py +476 -0
  14. quasarr/downloads/sources/al.py +697 -0
  15. quasarr/downloads/sources/by.py +106 -0
  16. quasarr/downloads/sources/dd.py +76 -0
  17. quasarr/downloads/sources/dj.py +7 -0
  18. quasarr/downloads/sources/dl.py +199 -0
  19. quasarr/downloads/sources/dt.py +66 -0
  20. quasarr/downloads/sources/dw.py +14 -7
  21. quasarr/downloads/sources/he.py +112 -0
  22. quasarr/downloads/sources/mb.py +47 -0
  23. quasarr/downloads/sources/nk.py +54 -0
  24. quasarr/downloads/sources/nx.py +42 -83
  25. quasarr/downloads/sources/sf.py +159 -0
  26. quasarr/downloads/sources/sj.py +7 -0
  27. quasarr/downloads/sources/sl.py +90 -0
  28. quasarr/downloads/sources/wd.py +110 -0
  29. quasarr/downloads/sources/wx.py +127 -0
  30. quasarr/providers/cloudflare.py +204 -0
  31. quasarr/providers/html_images.py +22 -0
  32. quasarr/providers/html_templates.py +211 -104
  33. quasarr/providers/imdb_metadata.py +108 -3
  34. quasarr/providers/log.py +19 -0
  35. quasarr/providers/myjd_api.py +201 -40
  36. quasarr/providers/notifications.py +99 -11
  37. quasarr/providers/obfuscated.py +65 -0
  38. quasarr/providers/sessions/__init__.py +0 -0
  39. quasarr/providers/sessions/al.py +286 -0
  40. quasarr/providers/sessions/dd.py +78 -0
  41. quasarr/providers/sessions/dl.py +175 -0
  42. quasarr/providers/sessions/nx.py +76 -0
  43. quasarr/providers/shared_state.py +656 -79
  44. quasarr/providers/statistics.py +154 -0
  45. quasarr/providers/version.py +60 -1
  46. quasarr/providers/web_server.py +1 -1
  47. quasarr/search/__init__.py +144 -15
  48. quasarr/search/sources/al.py +448 -0
  49. quasarr/search/sources/by.py +204 -0
  50. quasarr/search/sources/dd.py +135 -0
  51. quasarr/search/sources/dj.py +213 -0
  52. quasarr/search/sources/dl.py +354 -0
  53. quasarr/search/sources/dt.py +265 -0
  54. quasarr/search/sources/dw.py +94 -67
  55. quasarr/search/sources/fx.py +89 -33
  56. quasarr/search/sources/he.py +196 -0
  57. quasarr/search/sources/mb.py +195 -0
  58. quasarr/search/sources/nk.py +188 -0
  59. quasarr/search/sources/nx.py +75 -21
  60. quasarr/search/sources/sf.py +374 -0
  61. quasarr/search/sources/sj.py +213 -0
  62. quasarr/search/sources/sl.py +246 -0
  63. quasarr/search/sources/wd.py +208 -0
  64. quasarr/search/sources/wx.py +337 -0
  65. quasarr/storage/config.py +39 -10
  66. quasarr/storage/setup.py +269 -97
  67. quasarr/storage/sqlite_database.py +6 -1
  68. quasarr-1.23.0.dist-info/METADATA +306 -0
  69. quasarr-1.23.0.dist-info/RECORD +77 -0
  70. {quasarr-0.1.6.dist-info → quasarr-1.23.0.dist-info}/WHEEL +1 -1
  71. quasarr/arr/__init__.py +0 -423
  72. quasarr/captcha_solver/__init__.py +0 -284
  73. quasarr-0.1.6.dist-info/METADATA +0 -81
  74. quasarr-0.1.6.dist-info/RECORD +0 -31
  75. {quasarr-0.1.6.dist-info → quasarr-1.23.0.dist-info}/entry_points.txt +0 -0
  76. {quasarr-0.1.6.dist-info → quasarr-1.23.0.dist-info/licenses}/LICENSE +0 -0
  77. {quasarr-0.1.6.dist-info → quasarr-1.23.0.dist-info}/top_level.txt +0 -0
@@ -35,8 +35,13 @@ import time
35
35
  from urllib.parse import quote
36
36
 
37
37
  import requests
38
+ import urllib3
38
39
  from Cryptodome.Cipher import AES
39
40
 
41
+ from quasarr.providers.log import info, debug
42
+ from quasarr.providers.version import get_version
43
+
44
+ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
40
45
  BS = 16
41
46
 
42
47
 
@@ -60,6 +65,90 @@ def unpad(s):
60
65
  return s[0:-s[-1]]
61
66
 
62
67
 
68
+ class Update:
69
+ """
70
+ Class that represents the update-functionality of a Device
71
+ """
72
+
73
+ def __init__(self, device):
74
+ self.device = device
75
+ self.url = '/update'
76
+
77
+ def restart_and_update(self):
78
+ """
79
+
80
+ :return:
81
+ """
82
+ resp = self.device.action(self.url + "/restartAndUpdate")
83
+ return resp
84
+
85
+ def run_update_check(self):
86
+ """
87
+
88
+ :return:
89
+ """
90
+ resp = self.device.action(self.url + "/runUpdateCheck")
91
+ return resp
92
+
93
+ def is_update_available(self):
94
+ """
95
+
96
+ :return:
97
+ """
98
+ resp = self.device.action(self.url + "/isUpdateAvailable")
99
+ return resp
100
+
101
+ def update_available(self):
102
+ self.run_update_check()
103
+ resp = self.is_update_available()
104
+ return resp
105
+
106
+
107
+ class Config:
108
+ """
109
+ Class that represents the Config of a Device
110
+ """
111
+
112
+ def __init__(self, device):
113
+ self.device = device
114
+ self.url = '/config'
115
+
116
+ def list(self):
117
+ """
118
+ :return: List<AdvancedConfigAPIEntry>
119
+ """
120
+ resp = self.device.action(self.url + "/list")
121
+ return resp
122
+
123
+ def get(self, interface_name, storage, key):
124
+ """
125
+ :param interfaceName: a valid interface name from List<AdvancedConfigAPIEntry>
126
+ :type: str:
127
+ :param storage: 'null' to use default or 'cfg/' + interfaceName
128
+ :type: str:
129
+ :param key: a valid key from from List<AdvancedConfigAPIEntry>
130
+ :type: str:
131
+ """
132
+ params = [interface_name, storage, key]
133
+ resp = self.device.action(self.url + "/get", params)
134
+ return resp
135
+
136
+ def set(self, interface_name, storage, key, value):
137
+ """
138
+ :param interfaceName: a valid interface name from List<AdvancedConfigAPIEntry>
139
+ :type: str:
140
+ :param storage: 'null' to use default or 'cfg/' + interfaceName
141
+ :type: str:
142
+ :param key: a valid key from from List<AdvancedConfigAPIEntry>
143
+ :type: str:
144
+ :param value: a valid value for the given key (see type value from List<AdvancedConfigAPIEntry>)
145
+ :type: Object:
146
+ """
147
+ params = [interface_name, storage, key, value]
148
+ resp = self.device.action(self.url + "/set", params)
149
+ return resp
150
+
151
+
63
152
  class DownloadController:
64
153
  """
65
154
  Class that represents the downloads-controller of a Device
@@ -96,7 +185,7 @@ class Linkgrabber:
96
185
  self.url = '/linkgrabberv2'
97
186
 
98
187
  def is_collecting(self):
99
- resp = self.device.action("/linkgrabberv2/isCollecting")
188
+ resp = self.device.action(self.url + "/isCollecting")
100
189
  return resp
101
190
 
102
191
  def add_links(self,
@@ -123,7 +212,33 @@ class Linkgrabber:
123
212
  "destinationFolder" : null
124
213
  }
125
214
  """
126
- resp = self.device.action("/linkgrabberv2/addLinks", params)
215
+ resp = self.device.action(self.url + "/addLinks", params)
216
+ return resp
217
+
218
+ def cleanup(self,
219
+ action,
220
+ mode,
221
+ selection_type,
222
+ link_ids=[],
223
+ package_ids=[]):
224
+ """
225
+ Clean packages and/or links of the linkgrabber list.
226
+ Requires at least a package_ids or link_ids list, or both.
227
+
228
+ :param package_ids: Package UUID's.
229
+ :type: list of strings.
230
+ :param link_ids: link UUID's.
231
+ :type: list of strings
232
+ :param action: Action to be done. Actions: DELETE_ALL, DELETE_DISABLED, DELETE_FAILED, DELETE_FINISHED, DELETE_OFFLINE, DELETE_DUPE, DELETE_MODE
233
+ :type: str:
234
+ :param mode: Mode to use. Modes: REMOVE_LINKS_AND_DELETE_FILES, REMOVE_LINKS_AND_RECYCLE_FILES, REMOVE_LINKS_ONLY
235
+ :type: str:
236
+ :param selection_type: Type of selection to use. Types: SELECTED, UNSELECTED, ALL, NONE
237
+ :type: str:
238
+ """
239
+ params = [link_ids, package_ids]
240
+ params += [action, mode, selection_type]
241
+ resp = self.device.action(self.url + "/cleanup", params)
127
242
  return resp
128
243
 
129
244
  def remove_links(self, links_ids, packages_ids):
@@ -208,6 +323,32 @@ class Downloads:
208
323
  self.device = device
209
324
  self.url = "/downloadsV2"
210
325
 
326
+ def cleanup(self,
327
+ action,
328
+ mode,
329
+ selection_type,
330
+ link_ids=[],
331
+ package_ids=[]):
332
+ """
333
+ Clean packages and/or links of the linkgrabber list.
334
+ Requires at least a package_ids or link_ids list, or both.
335
+
336
+ :param package_ids: Package UUID's.
337
+ :type: list of strings.
338
+ :param link_ids: link UUID's.
339
+ :type: list of strings
340
+ :param action: Action to be done. Actions: DELETE_ALL, DELETE_DISABLED, DELETE_FAILED, DELETE_FINISHED, DELETE_OFFLINE, DELETE_DUPE, DELETE_MODE
341
+ :type: str:
342
+ :param mode: Mode to use. Modes: REMOVE_LINKS_AND_DELETE_FILES, REMOVE_LINKS_AND_RECYCLE_FILES, REMOVE_LINKS_ONLY
343
+ :type: str:
344
+ :param selection_type: Type of selection to use. Types: SELECTED, UNSELECTED, ALL, NONE
345
+ :type: str:
346
+ """
347
+ params = [link_ids, package_ids]
348
+ params += [action, mode, selection_type]
349
+ resp = self.device.action(self.url + "/cleanup", params)
350
+ return resp
351
+
211
352
  def query_links(self,
212
353
  params=[{
213
354
  "bytesTotal": True,
@@ -258,46 +399,27 @@ class Downloads:
258
399
  resp = self.device.action(self.url + "/queryPackages", params)
259
400
  return resp
260
401
 
261
- def remove_links(self, links_ids, packages_ids):
262
- params = [links_ids, packages_ids]
263
- resp = self.device.action(self.url + "/removeLinks", params)
264
- return resp
265
-
266
402
 
267
403
  class Extraction:
268
404
  """
269
- Class that represents the extraction functionalities of a Device.
405
+ Class that represents the extraction details of a Device
270
406
  """
271
407
 
272
408
  def __init__(self, device):
273
409
  self.device = device
274
- self.url = '/extraction'
275
-
276
- def get_archive_info(self, link_ids, package_ids):
277
- params = [link_ids, package_ids]
278
- resp = self.device.action(self.url + "/getArchiveInfo", params)
279
- return resp
410
+ self.url = "/extraction"
280
411
 
281
- def set_archive_settings(self, archive_id, archive_settings=None):
412
+ def get_archive_info(self, link_ids=[], package_ids=[]):
282
413
  """
283
- Sets the extraction settings for a specific archive.
414
+ Get ArchiveStatus for links and/or packages.
284
415
 
285
- :param archive_id: The ID of the archive.
286
- :type archive_id: string
287
- :param archive_settings: Dictionary of archive settings.
288
- :type archive_settings: dict
289
- :rtype: boolean indicating success or failure
416
+ :param package_ids: Package UUID's.
417
+ :type: list of strings.
418
+ :param link_ids: link UUID's.
419
+ :type: list of strings
290
420
  """
291
- if archive_settings is None:
292
- archive_settings = {
293
- "autoExtract": True,
294
- "removeDownloadLinksAfterExtraction": True,
295
- "removeFilesAfterExtraction": True
296
- }
297
-
298
- params = [archive_id, archive_settings]
299
-
300
- resp = self.device.action(self.url + "/setArchiveSettings", params)
421
+ params = [link_ids, package_ids]
422
+ resp = self.device.action(self.url + "/getArchiveInfo", params)
301
423
  return resp
302
424
 
303
425
 
@@ -316,10 +438,12 @@ class Jddevice:
316
438
  self.device_id = device_dict["id"]
317
439
  self.device_type = device_dict["type"]
318
440
  self.myjd = jd
441
+ self.config = Config(self)
319
442
  self.linkgrabber = Linkgrabber(self)
320
443
  self.downloads = Downloads(self)
321
- self.downloadcontroller = DownloadController(self)
322
444
  self.extraction = Extraction(self)
445
+ self.downloadcontroller = DownloadController(self)
446
+ self.update = Update(self)
323
447
  self.__direct_connection_info = None
324
448
  self.__refresh_direct_connections()
325
449
  self.__direct_connection_enabled = True
@@ -398,7 +522,7 @@ class Jddevice:
398
522
  return response['data']
399
523
  else:
400
524
  # Direct connection info available, we try to use it.
401
- for conn in self.__direct_connection_info:
525
+ for conn in self.__direct_connection_info[:]:
402
526
  connection_ip = conn['conn']['ip']
403
527
  # prevent connection to internal docker ip
404
528
  if time.time() > conn['cooldown']:
@@ -445,6 +569,8 @@ class Myjdapi:
445
569
  Main class for connecting to JD API.
446
570
 
447
571
  """
572
+ # Class variable to track connection failures across all instances
573
+ _connection_failed_at = None
448
574
 
449
575
  def __init__(self):
450
576
  """
@@ -453,7 +579,7 @@ class Myjdapi:
453
579
  """
454
580
  self.__request_id = int(time.time() * 1000)
455
581
  self.__api_url = "https://api.jdownloader.org"
456
- self.__app_key = "http://git.io/vmcsk"
582
+ self.__app_key = "quasarr"
457
583
  self.__api_version = 1
458
584
  self.__devices = None
459
585
  self.__login_secret = None
@@ -584,6 +710,15 @@ class Myjdapi:
584
710
  :returns: boolean -- True if succesful, False if there was any error.
585
711
 
586
712
  """
713
+ # Check if we're in cooldown period (5 minutes = 300 seconds)
714
+ if Myjdapi._connection_failed_at is not None:
715
+ time_since_failure = time.time() - Myjdapi._connection_failed_at
716
+ if time_since_failure < 300:
717
+ # Silently return False during cooldown - don't log anything
718
+ return False
719
+ # Cooldown expired, reset for retry
720
+ Myjdapi._connection_failed_at = None
721
+
587
722
  self.update_request_id()
588
723
  self.__login_secret = None
589
724
  self.__device_secret = None
@@ -599,6 +734,15 @@ class Myjdapi:
599
734
  response = self.request_api("/my/connect", "GET", [("email", email),
600
735
  ("appkey",
601
736
  self.__app_key)])
737
+
738
+ if response is None:
739
+ # Log and set failure timestamp
740
+ info("JDownloader API is currently unavailable! Stopping connection attempts for 5 minutes.")
741
+ Myjdapi._connection_failed_at = time.time()
742
+ return False
743
+
744
+ # Connection successful, reset failure timestamp
745
+ Myjdapi._connection_failed_at = None
602
746
  self.__connected = True
603
747
  self.update_request_id()
604
748
  self.__session_token = response["sessiontoken"]
@@ -696,11 +840,22 @@ class Myjdapi:
696
840
  query[0] + "&".join(query[1:])))
697
841
  ]
698
842
  query = query[0] + "&".join(query[1:])
843
+
844
+ headers = {
845
+ "User-Agent": f"Quasarr/{get_version()}"
846
+ }
699
847
  try:
700
- encrypted_response = requests.get(api + query, timeout=timeout)
848
+ encrypted_response = requests.get(api + query, timeout=timeout, headers=headers)
849
+ except requests.exceptions.ConnectionError:
850
+ return None
701
851
  except Exception:
702
- encrypted_response = requests.get(api + query, timeout=timeout, verify=False)
703
- print("Could not establish secure connection to JDownloader.")
852
+ try:
853
+ encrypted_response = requests.get(api + query, timeout=timeout, headers=headers, verify=False)
854
+ debug("Could not establish secure connection to JDownloader. Is your time / timezone correct?")
855
+ except requests.exceptions.ConnectionError:
856
+ return None
857
+ except Exception:
858
+ return None
704
859
  else:
705
860
  params_request = []
706
861
  if params is not None:
@@ -729,23 +884,29 @@ class Myjdapi:
729
884
  encrypted_response = requests.post(
730
885
  request_url,
731
886
  headers={
732
- "Content-Type": "application/aesjson-jd; charset=utf-8"
887
+ "Content-Type": "application/aesjson-jd; charset=utf-8",
888
+ "User-Agent": f"Quasarr/{get_version()}"
733
889
  },
734
890
  data=encrypted_data,
735
891
  timeout=timeout
736
892
  )
893
+ except requests.exceptions.ConnectionError:
894
+ return None
737
895
  except Exception:
738
896
  try:
739
897
  encrypted_response = requests.post(
740
898
  request_url,
741
899
  headers={
742
- "Content-Type": "application/aesjson-jd; charset=utf-8"
900
+ "Content-Type": "application/aesjson-jd; charset=utf-8",
901
+ "User-Agent": f"Quasarr/{get_version()}"
743
902
  },
744
903
  data=encrypted_data,
745
904
  timeout=timeout,
746
905
  verify=False
747
906
  )
748
- print("Could not establish secure connection to JDownloader.")
907
+ debug("Could not establish secure connection to JDownloader. Is your time / timezone correct?")
908
+ except requests.exceptions.ConnectionError:
909
+ return None
749
910
  except Exception:
750
911
  return None
751
912
  if encrypted_response.status_code == 403:
@@ -3,34 +3,122 @@
3
3
  # Project by https://github.com/rix1337
4
4
 
5
5
  import json
6
+ import os
6
7
 
7
8
  import requests
8
9
 
10
+ from quasarr.providers.imdb_metadata import get_imdb_id_from_title
11
+ from quasarr.providers.imdb_metadata import get_poster_link
12
+ from quasarr.providers.log import info
9
13
 
10
- def send_discord_captcha_alert(shared_state, title):
14
+ # Discord message flag for suppressing notifications
15
+ SUPPRESS_NOTIFICATIONS = 1 << 12 # 4096
16
+
17
+ silent = False
18
+ if os.getenv('SILENT'):
19
+ silent = True
20
+
21
+
22
+ def send_discord_message(shared_state, title, case, imdb_id=None, details=None, source=None):
23
+ """
24
+ Sends a Discord message to the webhook provided in the shared state, based on the specified case.
25
+
26
+ :param shared_state: Shared state object containing configuration.
27
+ :param title: Title of the embed to be sent.
28
+ :param case: A string representing the scenario (e.g., 'captcha', 'failed', 'unprotected').
29
+ :param imdb_id: A string starting with "tt" followed by at least 7 digits, representing an object on IMDb
30
+ :param details: A dictionary containing additional details, such as version and link for updates.
31
+ :param source: Optional source of the notification, sent as a field in the embed.
32
+ :return: True if the message was sent successfully, False otherwise.
33
+ """
11
34
  if not shared_state.values.get("discord"):
12
35
  return False
13
36
 
37
+ poster_object = None
38
+ if case == "unprotected" or case == "captcha":
39
+ if not imdb_id and " " not in title: # this should prevent imdb_search for ebooks and magazines
40
+ imdb_id = get_imdb_id_from_title(shared_state, title)
41
+ if imdb_id:
42
+ poster_link = get_poster_link(shared_state, imdb_id)
43
+ if poster_link:
44
+ poster_object = {
45
+ 'url': poster_link
46
+ }
47
+
48
+ # Decide the embed content based on the case
49
+ if case == "unprotected":
50
+ description = 'No CAPTCHA required. Links were added directly!'
51
+ fields = None
52
+ elif case == "solved":
53
+ description = 'CAPTCHA solved by SponsorsHelper!'
54
+ fields = None
55
+ elif case == "failed":
56
+ description = 'SponsorsHelper failed to solve the CAPTCHA! Package marked es failed.'
57
+ fields = None
58
+ elif case == "captcha":
59
+ description = 'Download will proceed, once the CAPTCHA has been solved.'
60
+ fields = [
61
+ {
62
+ 'name': 'Solve CAPTCHA',
63
+ 'value': f'Open [this link]({f"{shared_state.values['external_address']}/captcha"}) to solve the CAPTCHA.',
64
+ }
65
+ ]
66
+ if not shared_state.values.get("helper_active"):
67
+ fields.append({
68
+ 'name': 'SponsorsHelper',
69
+ 'value': f'[Sponsors get automated CAPTCHA solutions!](https://github.com/rix1337/Quasarr?tab=readme-ov-file#sponsorshelper)',
70
+ })
71
+ elif case == "quasarr_update":
72
+ description = f'Please update to {details["version"]} as soon as possible!'
73
+ if details:
74
+ fields = [
75
+ {
76
+ 'name': 'Release notes at: ',
77
+ 'value': f'[GitHub.com: rix1337/Quasarr/{details["version"]}]({details["link"]})',
78
+ }
79
+ ]
80
+ else:
81
+ fields = None
82
+ else:
83
+ info(f"Unknown notification case: {case}")
84
+ return False
85
+
14
86
  data = {
15
87
  'username': 'Quasarr',
16
- 'avatar_url': 'https://i.imgur.com/UXBdr1h.png',
88
+ 'avatar_url': 'https://raw.githubusercontent.com/rix1337/Quasarr/main/Quasarr.png',
17
89
  'embeds': [{
18
90
  'title': title,
19
- 'description': 'Links are protected. Please solve the CAPTCHA to continue downloading.',
20
- 'fields': [
21
- {
22
- 'name': '',
23
- 'value': f'[Solve the CAPTCHA here!]({f"{shared_state.values['external_address']}/captcha"})',
24
- }
25
-
26
- ]
91
+ 'description': description,
27
92
  }]
28
93
  }
29
94
 
95
+ if source and source.startswith("http"):
96
+ if not fields:
97
+ fields = []
98
+ fields.append({
99
+ 'name': 'Source',
100
+ 'value': f'[View release details here]({source})',
101
+ })
102
+
103
+ if fields:
104
+ data['embeds'][0]['fields'] = fields
105
+
106
+ if poster_object:
107
+ data['embeds'][0]['thumbnail'] = poster_object
108
+ data['embeds'][0]['image'] = poster_object
109
+ elif case == "quasarr_update":
110
+ data['embeds'][0]['thumbnail'] = {
111
+ 'url': "https://raw.githubusercontent.com/rix1337/Quasarr/main/Quasarr.png"
112
+ }
113
+
114
+ # Apply silent mode: suppress notifications for all cases except 'deleted'
115
+ if silent and case not in ["failed", "quasarr_update"]:
116
+ data['flags'] = SUPPRESS_NOTIFICATIONS
117
+
30
118
  response = requests.post(shared_state.values["discord"], data=json.dumps(data),
31
119
  headers={"Content-Type": "application/json"})
32
120
  if response.status_code != 204:
33
- print(f"Failed to send message to Discord webhook. Status code: {response.status_code}")
121
+ info(f"Failed to send message to Discord webhook. Status code: {response.status_code}")
34
122
  return False
35
123
 
36
124
  return True