quasarr 1.20.6__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 (72) hide show
  1. quasarr/__init__.py +460 -0
  2. quasarr/api/__init__.py +187 -0
  3. quasarr/api/arr/__init__.py +373 -0
  4. quasarr/api/captcha/__init__.py +1075 -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 +267 -0
  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 +467 -0
  14. quasarr/downloads/sources/__init__.py +0 -0
  15. quasarr/downloads/sources/al.py +697 -0
  16. quasarr/downloads/sources/by.py +106 -0
  17. quasarr/downloads/sources/dd.py +76 -0
  18. quasarr/downloads/sources/dj.py +7 -0
  19. quasarr/downloads/sources/dt.py +66 -0
  20. quasarr/downloads/sources/dw.py +65 -0
  21. quasarr/downloads/sources/he.py +112 -0
  22. quasarr/downloads/sources/mb.py +47 -0
  23. quasarr/downloads/sources/nk.py +51 -0
  24. quasarr/downloads/sources/nx.py +105 -0
  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/providers/__init__.py +0 -0
  30. quasarr/providers/cloudflare.py +204 -0
  31. quasarr/providers/html_images.py +20 -0
  32. quasarr/providers/html_templates.py +241 -0
  33. quasarr/providers/imdb_metadata.py +142 -0
  34. quasarr/providers/log.py +19 -0
  35. quasarr/providers/myjd_api.py +917 -0
  36. quasarr/providers/notifications.py +124 -0
  37. quasarr/providers/obfuscated.py +51 -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/nx.py +76 -0
  42. quasarr/providers/shared_state.py +826 -0
  43. quasarr/providers/statistics.py +154 -0
  44. quasarr/providers/version.py +118 -0
  45. quasarr/providers/web_server.py +49 -0
  46. quasarr/search/__init__.py +153 -0
  47. quasarr/search/sources/__init__.py +0 -0
  48. quasarr/search/sources/al.py +448 -0
  49. quasarr/search/sources/by.py +203 -0
  50. quasarr/search/sources/dd.py +135 -0
  51. quasarr/search/sources/dj.py +213 -0
  52. quasarr/search/sources/dt.py +265 -0
  53. quasarr/search/sources/dw.py +214 -0
  54. quasarr/search/sources/fx.py +223 -0
  55. quasarr/search/sources/he.py +196 -0
  56. quasarr/search/sources/mb.py +195 -0
  57. quasarr/search/sources/nk.py +188 -0
  58. quasarr/search/sources/nx.py +197 -0
  59. quasarr/search/sources/sf.py +374 -0
  60. quasarr/search/sources/sj.py +213 -0
  61. quasarr/search/sources/sl.py +246 -0
  62. quasarr/search/sources/wd.py +208 -0
  63. quasarr/storage/__init__.py +0 -0
  64. quasarr/storage/config.py +163 -0
  65. quasarr/storage/setup.py +458 -0
  66. quasarr/storage/sqlite_database.py +80 -0
  67. quasarr-1.20.6.dist-info/METADATA +304 -0
  68. quasarr-1.20.6.dist-info/RECORD +72 -0
  69. quasarr-1.20.6.dist-info/WHEEL +5 -0
  70. quasarr-1.20.6.dist-info/entry_points.txt +2 -0
  71. quasarr-1.20.6.dist-info/licenses/LICENSE +21 -0
  72. quasarr-1.20.6.dist-info/top_level.txt +1 -0
@@ -0,0 +1,917 @@
1
+ # -*- encoding: utf-8 -*-
2
+ # Quasarr
3
+ # Project by https://github.com/rix1337
4
+ #
5
+ # Original Code by:
6
+ # https://github.com/mmarquezs/My.Jdownloader-API-Python-Library/
7
+ #
8
+ # The MIT License (MIT)
9
+ #
10
+ # Copyright (c) 2015 Marc Marquez Santamaria
11
+ #
12
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
13
+ # of this software and associated documentation files (the "Software"), to deal
14
+ # in the Software without restriction, including without limitation the rights
15
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
16
+ # copies of the Software, and to permit persons to whom the Software is
17
+ # furnished to do so, subject to the following conditions:
18
+ #
19
+ # The above copyright notice and this permission notice shall be included in all
20
+ # copies or substantial portions of the Software.
21
+ #
22
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
23
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
24
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
25
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
26
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
27
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
28
+ # SOFTWARE.
29
+
30
+ import base64
31
+ import hashlib
32
+ import hmac
33
+ import json
34
+ import time
35
+ from urllib.parse import quote
36
+
37
+ import requests
38
+ import urllib3
39
+ from Cryptodome.Cipher import AES
40
+
41
+ from quasarr.providers.log import debug
42
+ from quasarr.providers.version import get_version
43
+
44
+ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
45
+ BS = 16
46
+
47
+
48
+ class MYJDException(BaseException):
49
+ pass
50
+
51
+
52
+ class TokenExpiredException(BaseException):
53
+ pass
54
+
55
+
56
+ class RequestTimeoutException(BaseException):
57
+ pass
58
+
59
+
60
+ def pad(s):
61
+ return s + ((BS - len(s) % BS) * chr(BS - len(s) % BS)).encode()
62
+
63
+
64
+ def unpad(s):
65
+ return s[0:-s[-1]]
66
+
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
+
152
+ class DownloadController:
153
+ """
154
+ Class that represents the downloads-controller of a Device
155
+ """
156
+
157
+ def __init__(self, device):
158
+ self.device = device
159
+ self.url = '/downloadcontroller'
160
+
161
+ def start_downloads(self):
162
+ """
163
+
164
+ :return:
165
+ """
166
+ resp = self.device.action(self.url + "/start")
167
+ return resp
168
+
169
+ def get_current_state(self):
170
+ """
171
+
172
+ :return:
173
+ """
174
+ resp = self.device.action(self.url + "/getCurrentState")
175
+ return resp
176
+
177
+
178
+ class Linkgrabber:
179
+ """
180
+ Class that represents the linkgrabber of a Device
181
+ """
182
+
183
+ def __init__(self, device):
184
+ self.device = device
185
+ self.url = '/linkgrabberv2'
186
+
187
+ def is_collecting(self):
188
+ resp = self.device.action(self.url + "/isCollecting")
189
+ return resp
190
+
191
+ def add_links(self,
192
+ params=[{
193
+ "autostart": True,
194
+ "links": None,
195
+ "packageName": None,
196
+ "extractPassword": None,
197
+ "priority": "DEFAULT",
198
+ "downloadPassword": None,
199
+ "destinationFolder": None,
200
+ "overwritePackagizerRules": False
201
+ }]):
202
+ """
203
+ Add links to the linkcollector
204
+
205
+ {
206
+ "autostart" : false,
207
+ "links" : null,
208
+ "packageName" : null,
209
+ "extractPassword" : null,
210
+ "priority" : "DEFAULT",
211
+ "downloadPassword" : null,
212
+ "destinationFolder" : null
213
+ }
214
+ """
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)
242
+ return resp
243
+
244
+ def remove_links(self, links_ids, packages_ids):
245
+ params = [links_ids, packages_ids]
246
+ resp = self.device.action(self.url + "/removeLinks", params)
247
+ return resp
248
+
249
+ def move_to_downloadlist(self, link_ids, package_ids):
250
+ """
251
+ Moves packages and/or links to download list.
252
+
253
+ :param package_ids: Package UUID's.
254
+ :type: list of strings.
255
+ :param link_ids: Link UUID's.
256
+ """
257
+ params = [link_ids, package_ids]
258
+ resp = self.device.action(self.url + "/moveToDownloadlist", params)
259
+ return resp
260
+
261
+ def query_links(self,
262
+ params=[{
263
+ "bytesTotal": True,
264
+ "comment": True,
265
+ "status": True,
266
+ "enabled": True,
267
+ "maxResults": -1,
268
+ "startAt": 0,
269
+ "hosts": True,
270
+ "url": True,
271
+ "availability": True,
272
+ "variantIcon": True,
273
+ "variantName": True,
274
+ "variantID": True,
275
+ "variants": True,
276
+ "priority": True
277
+ }]):
278
+ """
279
+
280
+ Get the links in the linkcollector/linkgrabber
281
+
282
+ :param params: A dictionary with options. The default dictionary is
283
+ configured so it returns you all the downloads with all details, but you
284
+ can put your own with your options
285
+ :type: Dictionary
286
+ :rtype: List of dictionaries of this style, with more or less detail based on your options.
287
+ """
288
+ resp = self.device.action(self.url + "/queryLinks", params)
289
+ return resp
290
+
291
+ def query_packages(self, params=[
292
+ {
293
+ "bytesLoaded": True,
294
+ "bytesTotal": True,
295
+ "comment": True,
296
+ "enabled": True,
297
+ "eta": True,
298
+ "priority": False,
299
+ "finished": True,
300
+ "running": True,
301
+ "speed": True,
302
+ "status": True,
303
+ "childCount": True,
304
+ "hosts": True,
305
+ "saveTo": True,
306
+ "maxResults": -1,
307
+ "startAt": 0,
308
+ }
309
+ ]):
310
+ """
311
+ Get the links in the linkgrabber list
312
+ """
313
+ resp = self.device.action("/linkgrabberv2/queryPackages", params)
314
+ return resp
315
+
316
+
317
+ class Downloads:
318
+ """
319
+ Class that represents the downloads list of a Device
320
+ """
321
+
322
+ def __init__(self, device):
323
+ self.device = device
324
+ self.url = "/downloadsV2"
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
+
352
+ def query_links(self,
353
+ params=[{
354
+ "bytesTotal": True,
355
+ "comment": True,
356
+ "status": True,
357
+ "enabled": True,
358
+ "maxResults": -1,
359
+ "startAt": 0,
360
+ "packageUUIDs": [],
361
+ "host": True,
362
+ "url": True,
363
+ "bytesloaded": True,
364
+ "speed": True,
365
+ "eta": True,
366
+ "finished": True,
367
+ "priority": True,
368
+ "running": True,
369
+ "skipped": True,
370
+ "extractionStatus": True
371
+ }]):
372
+ """
373
+ Get the links in the download list
374
+ """
375
+ resp = self.device.action(self.url + "/queryLinks", params)
376
+ return resp
377
+
378
+ def query_packages(self,
379
+ params=[{
380
+ "bytesLoaded": True,
381
+ "bytesTotal": True,
382
+ "comment": True,
383
+ "enabled": True,
384
+ "eta": True,
385
+ "priority": False,
386
+ "finished": True,
387
+ "running": True,
388
+ "speed": True,
389
+ "status": True,
390
+ "childCount": True,
391
+ "hosts": True,
392
+ "saveTo": True,
393
+ "maxResults": -1,
394
+ "startAt": 0,
395
+ }]):
396
+ """
397
+ Get the packages in the downloads list
398
+ """
399
+ resp = self.device.action(self.url + "/queryPackages", params)
400
+ return resp
401
+
402
+
403
+ class Extraction:
404
+ """
405
+ Class that represents the extraction details of a Device
406
+ """
407
+
408
+ def __init__(self, device):
409
+ self.device = device
410
+ self.url = "/extraction"
411
+
412
+ def get_archive_info(self, link_ids=[], package_ids=[]):
413
+ """
414
+ Get ArchiveStatus for links and/or packages.
415
+
416
+ :param package_ids: Package UUID's.
417
+ :type: list of strings.
418
+ :param link_ids: link UUID's.
419
+ :type: list of strings
420
+ """
421
+ params = [link_ids, package_ids]
422
+ resp = self.device.action(self.url + "/getArchiveInfo", params)
423
+ return resp
424
+
425
+
426
+ class Jddevice:
427
+ """
428
+ Class that represents a JDownloader device and it's functions
429
+ """
430
+
431
+ def __init__(self, jd, device_dict):
432
+ """ This functions initializates the device instance.
433
+ It uses the provided dictionary to create the device.
434
+
435
+ :param device_dict: Device dictionary
436
+ """
437
+ self.name = device_dict["name"]
438
+ self.device_id = device_dict["id"]
439
+ self.device_type = device_dict["type"]
440
+ self.myjd = jd
441
+ self.config = Config(self)
442
+ self.linkgrabber = Linkgrabber(self)
443
+ self.downloads = Downloads(self)
444
+ self.extraction = Extraction(self)
445
+ self.downloadcontroller = DownloadController(self)
446
+ self.update = Update(self)
447
+ self.__direct_connection_info = None
448
+ self.__refresh_direct_connections()
449
+ self.__direct_connection_enabled = True
450
+ self.__direct_connection_cooldown = 0
451
+ self.__direct_connection_consecutive_failures = 0
452
+
453
+ def __refresh_direct_connections(self):
454
+ response = self.myjd.request_api("/device/getDirectConnectionInfos",
455
+ "POST", None, self.__action_url())
456
+ if response is not None \
457
+ and 'data' in response \
458
+ and 'infos' in response["data"] \
459
+ and len(response["data"]["infos"]) != 0:
460
+ self.__update_direct_connections(response["data"]["infos"])
461
+
462
+ def __update_direct_connections(self, direct_info):
463
+ """
464
+ Updates the direct_connections info keeping the order.
465
+ """
466
+ tmp = []
467
+ if self.__direct_connection_info is None:
468
+ for conn in direct_info:
469
+ tmp.append({'conn': conn, 'cooldown': 0})
470
+ self.__direct_connection_info = tmp
471
+ return
472
+ # We remove old connections not available anymore.
473
+ for i in self.__direct_connection_info:
474
+ if i['conn'] not in direct_info:
475
+ tmp.remove(i)
476
+ else:
477
+ direct_info.remove(i['conn'])
478
+ # We add new connections
479
+ for conn in direct_info:
480
+ tmp.append({'conn': conn, 'cooldown': 0})
481
+ self.__direct_connection_info = tmp
482
+
483
+ def enable_direct_connection(self):
484
+ self.__direct_connection_enabled = True
485
+ self.__refresh_direct_connections()
486
+
487
+ def disable_direct_connection(self):
488
+ self.__direct_connection_enabled = False
489
+ self.__direct_connection_info = None
490
+
491
+ def check_direct_connection(self):
492
+ if self.__direct_connection_enabled and self.__direct_connection_cooldown == 0 and self.__direct_connection_consecutive_failures == 0:
493
+ if self.__direct_connection_info:
494
+ return {"status": True, "ip": self.__direct_connection_info[0]['conn']['ip']}
495
+ return {"status": False, "ip": None}
496
+
497
+ def action(self, path, params=(), http_action="POST"):
498
+ """Execute any action in the device using the postparams and params.
499
+ All the info of which params are required and what are they default value, type,etc
500
+ can be found in the MY.Jdownloader API Specifications ( https://goo.gl/pkJ9d1 ).
501
+
502
+ :param path:
503
+ :param http_action:
504
+ :param params: Params in the url, in a list of tuples. Example:
505
+ /example?param1=ex&param2=ex2 [("param1","ex"),("param2","ex2")]
506
+ """
507
+ action_url = self.__action_url()
508
+ if not self.__direct_connection_enabled or self.__direct_connection_info is None \
509
+ or time.time() < self.__direct_connection_cooldown:
510
+ # No direct connection available, we use My.JDownloader api.
511
+ response = self.myjd.request_api(path, http_action, params,
512
+ action_url)
513
+ if response is None:
514
+ # My.JDownloader Api failed too.
515
+ return False
516
+ else:
517
+ # My.JDownloader Api worked, lets refresh the direct connections and return
518
+ # the response.
519
+ if self.__direct_connection_enabled \
520
+ and time.time() >= self.__direct_connection_cooldown:
521
+ self.__refresh_direct_connections()
522
+ return response['data']
523
+ else:
524
+ # Direct connection info available, we try to use it.
525
+ for conn in self.__direct_connection_info[:]:
526
+ connection_ip = conn['conn']['ip']
527
+ # prevent connection to internal docker ip
528
+ if time.time() > conn['cooldown']:
529
+ # We can use the connection
530
+ connection = conn['conn']
531
+ api = "http://" + connection_ip + ":" + str(
532
+ connection["port"])
533
+ try:
534
+ response = self.myjd.request_api(path, http_action, params,
535
+ action_url, api, timeout=3, output_errors=False)
536
+ except (TokenExpiredException, RequestTimeoutException, MYJDException):
537
+ response = None
538
+ if response is not None:
539
+ # This connection worked so we push it to the top of the list.
540
+ self.__direct_connection_info.remove(conn)
541
+ self.__direct_connection_info.insert(0, conn)
542
+ self.__direct_connection_consecutive_failures = 0
543
+ return response['data']
544
+ else:
545
+ # We don't try to use this connection for an hour.
546
+ conn['cooldown'] = time.time() + 3600
547
+ self.__direct_connection_info.remove(conn)
548
+ self.__direct_connection_info.append(conn)
549
+ # None of the direct connections worked, we set a cooldown for direct connections
550
+ self.__direct_connection_consecutive_failures += 1
551
+ self.__direct_connection_cooldown = time.time() + (60 * self.__direct_connection_consecutive_failures)
552
+ # None of the direct connections worked, we use the My.JDownloader api
553
+ response = self.myjd.request_api(path, http_action, params,
554
+ action_url)
555
+ if response is None:
556
+ # My.JDownloader Api failed too.
557
+ return False
558
+ # My.JDownloader Api worked, lets refresh the direct connections and return
559
+ # the response.
560
+ self.__refresh_direct_connections()
561
+ return response['data']
562
+
563
+ def __action_url(self):
564
+ return "/t_" + self.myjd.get_session_token() + "_" + self.device_id
565
+
566
+
567
+ class Myjdapi:
568
+ """
569
+ Main class for connecting to JD API.
570
+
571
+ """
572
+
573
+ def __init__(self):
574
+ """
575
+ This functions initializates the myjd_api object.
576
+
577
+ """
578
+ self.__request_id = int(time.time() * 1000)
579
+ self.__api_url = "https://api.jdownloader.org"
580
+ self.__app_key = "quasarr"
581
+ self.__api_version = 1
582
+ self.__devices = None
583
+ self.__login_secret = None
584
+ self.__device_secret = None
585
+ self.__session_token = None
586
+ self.__regain_token = None
587
+ self.__server_encryption_token = None
588
+ self.__device_encryption_token = None
589
+ self.__connected = False
590
+
591
+ def get_session_token(self):
592
+ return self.__session_token
593
+
594
+ def is_connected(self):
595
+ """
596
+ Indicates if there is a connection established.
597
+ """
598
+ return self.__connected
599
+
600
+ def set_app_key(self, app_key):
601
+ """
602
+ Sets the APP Key.
603
+ """
604
+ self.__app_key = app_key
605
+
606
+ def __secret_create(self, email, password, domain):
607
+ """
608
+ Calculates the login_secret and device_secret
609
+
610
+ :param email: My.Jdownloader User email
611
+ :param password: My.Jdownloader User password
612
+ :param domain: The domain , if is for Server (login_secret) or Device (device_secret)
613
+ :return: secret hash
614
+
615
+ """
616
+ secret_hash = hashlib.sha256()
617
+ secret_hash.update(email.lower().encode('utf-8')
618
+ + password.encode('utf-8')
619
+ + domain.lower().encode('utf-8'))
620
+ return secret_hash.digest()
621
+
622
+ def __update_encryption_tokens(self):
623
+ """
624
+ Updates the server_encryption_token and device_encryption_token
625
+
626
+ """
627
+ if self.__server_encryption_token is None:
628
+ old_token = self.__login_secret
629
+ else:
630
+ old_token = self.__server_encryption_token
631
+ new_token = hashlib.sha256()
632
+ new_token.update(old_token + bytearray.fromhex(self.__session_token))
633
+ self.__server_encryption_token = new_token.digest()
634
+ new_token = hashlib.sha256()
635
+ new_token.update(self.__device_secret +
636
+ bytearray.fromhex(self.__session_token))
637
+ self.__device_encryption_token = new_token.digest()
638
+
639
+ def __signature_create(self, key, data):
640
+ """
641
+ Calculates the signature for the data given a key.
642
+
643
+ :param key:
644
+ :param data:
645
+ """
646
+ signature = hmac.new(key, data.encode('utf-8'), hashlib.sha256)
647
+ return signature.hexdigest()
648
+
649
+ def __decrypt(self, secret_token, data):
650
+ """
651
+ Decrypts the data from the server using the provided token
652
+
653
+ :param secret_token:
654
+ :param data:
655
+ """
656
+ init_vector = secret_token[: len(secret_token) // 2]
657
+ key = secret_token[len(secret_token) // 2:]
658
+ decryptor = AES.new(key, AES.MODE_CBC, init_vector)
659
+ try:
660
+ decrypted_data = unpad(decryptor.decrypt(self.__base64_decode(data)))
661
+ except:
662
+ raise MYJDException(
663
+ "Failed to decode response: {}", data
664
+ )
665
+
666
+ return decrypted_data
667
+
668
+ def __base64_decode(self, s):
669
+ """Add missing padding to string and return the decoded base64 string."""
670
+ s = str(s).strip()
671
+ try:
672
+ return base64.b64decode(s)
673
+ except TypeError:
674
+ padding = len(s) % 4
675
+ if padding == 1:
676
+ return ""
677
+ elif padding == 2:
678
+ s += b"=="
679
+ elif padding == 3:
680
+ s += b"="
681
+ return base64.b64decode(s)
682
+
683
+ def __encrypt(self, secret_token, data):
684
+ """
685
+ Encrypts the data from the server using the provided token
686
+
687
+ :param secret_token:
688
+ :param data:
689
+ """
690
+ data = pad(data.encode('utf-8'))
691
+ init_vector = secret_token[:len(secret_token) // 2]
692
+ key = secret_token[len(secret_token) // 2:]
693
+ encryptor = AES.new(key, AES.MODE_CBC, init_vector)
694
+ encrypted_data = base64.b64encode(encryptor.encrypt(data))
695
+ return encrypted_data.decode('utf-8')
696
+
697
+ def update_request_id(self):
698
+ """
699
+ Updates Request_Id
700
+ """
701
+ self.__request_id = int(time.time())
702
+
703
+ def connect(self, email, password):
704
+ """Establish connection to api
705
+
706
+ :param email: My.Jdownloader User email
707
+ :param password: My.Jdownloader User password
708
+ :returns: boolean -- True if succesful, False if there was any error.
709
+
710
+ """
711
+ self.update_request_id()
712
+ self.__login_secret = None
713
+ self.__device_secret = None
714
+ self.__session_token = None
715
+ self.__regain_token = None
716
+ self.__server_encryption_token = None
717
+ self.__device_encryption_token = None
718
+ self.__devices = None
719
+ self.__connected = False
720
+
721
+ self.__login_secret = self.__secret_create(email, password, "server")
722
+ self.__device_secret = self.__secret_create(email, password, "device")
723
+ response = self.request_api("/my/connect", "GET", [("email", email),
724
+ ("appkey",
725
+ self.__app_key)])
726
+ self.__connected = True
727
+ self.update_request_id()
728
+ self.__session_token = response["sessiontoken"]
729
+ self.__regain_token = response["regaintoken"]
730
+ self.__update_encryption_tokens()
731
+ self.update_devices()
732
+ return response
733
+
734
+ def update_devices(self):
735
+ """
736
+ Updates available devices. Use list_devices() to get the devices list.
737
+
738
+ :returns: boolean -- True if successful, False if there was any error.
739
+ """
740
+ response = self.request_api("/my/listdevices", "GET",
741
+ [("sessiontoken", self.__session_token)])
742
+ self.update_request_id()
743
+ self.__devices = response["list"]
744
+
745
+ def list_devices(self):
746
+ """
747
+ Returns available devices. Use getDevices() to update the devices list.
748
+ Each device in the list is a dictionary like this example:
749
+
750
+ {
751
+ 'name': 'Device',
752
+ 'id': 'af9d03a21ddb917492dc1af8a6427f11',
753
+ 'type': 'jd'
754
+ }
755
+
756
+ :returns: list -- list of devices.
757
+ """
758
+ return self.__devices
759
+
760
+ def get_device(self, device_name=None, device_id=None):
761
+ """
762
+ Returns a jddevice instance of the device
763
+ :param device_name:
764
+ :param device_id:
765
+
766
+ """
767
+ if not self.is_connected():
768
+ raise (MYJDException("No connection established\n"))
769
+ if device_id is not None:
770
+ for device in self.__devices:
771
+ if device["id"] == device_id:
772
+ return Jddevice(self, device)
773
+ elif device_name is not None:
774
+ for device in self.__devices:
775
+ if device["name"] == device_name:
776
+ return Jddevice(self, device)
777
+ raise (MYJDException("Device not found\n"))
778
+
779
+ def request_api(self,
780
+ path,
781
+ http_method="GET",
782
+ params=None,
783
+ action=None,
784
+ api=None,
785
+ timeout=30,
786
+ output_errors=True):
787
+ """
788
+ Makes a request to the API to the 'path' using the 'http_method' with parameters,'params'.
789
+ Ex:
790
+ http_method=GET
791
+ params={"test":"test"}
792
+ post_params={"test2":"test2"}
793
+ action=True
794
+ This would make a request to "https://api.jdownloader.org"
795
+ """
796
+ if not api:
797
+ api = self.__api_url
798
+ data = None
799
+ if not self.is_connected() and path != "/my/connect":
800
+ raise (MYJDException("No connection established\n"))
801
+ if http_method == "GET":
802
+ query = [path + "?"]
803
+ if params is not None:
804
+ for param in params:
805
+ if param[0] != "encryptedLoginSecret":
806
+ query += [f"{param[0]}={quote(param[1])}"]
807
+ else:
808
+ query += [f"&{param[0]}={param[1]}"]
809
+ query += ["rid=" + str(self.__request_id)]
810
+ if self.__server_encryption_token is None:
811
+ query += [
812
+ "signature="
813
+ + str(self.__signature_create(self.__login_secret,
814
+ query[0] + "&".join(query[1:])))
815
+ ]
816
+ else:
817
+ query += [
818
+ "signature="
819
+ + str(self.__signature_create(self.__server_encryption_token,
820
+ query[0] + "&".join(query[1:])))
821
+ ]
822
+ query = query[0] + "&".join(query[1:])
823
+
824
+ headers = {
825
+ "User-Agent": f"Quasarr/{get_version()}"
826
+ }
827
+ try:
828
+ encrypted_response = requests.get(api + query, timeout=timeout, headers=headers)
829
+ except Exception:
830
+ encrypted_response = requests.get(api + query, timeout=timeout, headers=headers, verify=False)
831
+ debug("Could not establish secure connection to JDownloader.")
832
+ else:
833
+ params_request = []
834
+ if params is not None:
835
+ for param in params:
836
+ if not isinstance(param, list):
837
+ params_request += [json.dumps(param)]
838
+ else:
839
+ params_request += [param]
840
+ params_request = {
841
+ "apiVer": self.__api_version,
842
+ "url": path,
843
+ "params": params_request,
844
+ "rid": self.__request_id
845
+ }
846
+ data = json.dumps(params_request)
847
+ # Removing quotes around null elements.
848
+ data = data.replace('"null"', "null")
849
+ data = data.replace("'null'", "null")
850
+ encrypted_data = self.__encrypt(self.__device_encryption_token,
851
+ data)
852
+ if action is not None:
853
+ request_url = api + action + path
854
+ else:
855
+ request_url = api + path
856
+ try:
857
+ encrypted_response = requests.post(
858
+ request_url,
859
+ headers={
860
+ "Content-Type": "application/aesjson-jd; charset=utf-8",
861
+ "User-Agent": f"Quasarr/{get_version()}"
862
+ },
863
+ data=encrypted_data,
864
+ timeout=timeout
865
+ )
866
+ except Exception:
867
+ try:
868
+ encrypted_response = requests.post(
869
+ request_url,
870
+ headers={
871
+ "Content-Type": "application/aesjson-jd; charset=utf-8",
872
+ "User-Agent": f"Quasarr/{get_version()}"
873
+ },
874
+ data=encrypted_data,
875
+ timeout=timeout,
876
+ verify=False
877
+ )
878
+ debug("Could not establish secure connection to JDownloader.")
879
+ except Exception:
880
+ return None
881
+ if encrypted_response.status_code == 403:
882
+ raise TokenExpiredException
883
+ if encrypted_response.status_code == 503:
884
+ raise RequestTimeoutException
885
+ if encrypted_response.status_code != 200:
886
+ try:
887
+ error_msg = json.loads(encrypted_response.text)
888
+ except:
889
+ try:
890
+ error_msg = json.loads(self.__decrypt(self.__device_encryption_token, encrypted_response.text))
891
+ except:
892
+ raise MYJDException("Failed to decode response: {}", encrypted_response.text)
893
+ msg = "\n\tSOURCE: " + error_msg["src"] + "\n\tTYPE: " + \
894
+ error_msg["type"] + "\n------\nREQUEST_URL: " + \
895
+ api + path
896
+ if http_method == "GET":
897
+ msg += query
898
+ msg += "\n"
899
+ if data is not None:
900
+ msg += "DATA:\n" + data
901
+ raise (MYJDException(msg))
902
+ if action is None:
903
+ if not self.__server_encryption_token:
904
+ response = self.__decrypt(self.__login_secret,
905
+ encrypted_response.text)
906
+ else:
907
+ response = self.__decrypt(self.__server_encryption_token,
908
+ encrypted_response.text)
909
+ else:
910
+ response = self.__decrypt(self.__device_encryption_token,
911
+ encrypted_response.text)
912
+ jsondata = json.loads(response.decode('utf-8'))
913
+ if jsondata['rid'] != self.__request_id:
914
+ self.update_request_id()
915
+ return None
916
+ self.update_request_id()
917
+ return jsondata