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.
- quasarr/__init__.py +460 -0
- quasarr/api/__init__.py +187 -0
- quasarr/api/arr/__init__.py +373 -0
- quasarr/api/captcha/__init__.py +1075 -0
- quasarr/api/config/__init__.py +23 -0
- quasarr/api/sponsors_helper/__init__.py +166 -0
- quasarr/api/statistics/__init__.py +196 -0
- quasarr/downloads/__init__.py +267 -0
- quasarr/downloads/linkcrypters/__init__.py +0 -0
- quasarr/downloads/linkcrypters/al.py +237 -0
- quasarr/downloads/linkcrypters/filecrypt.py +444 -0
- quasarr/downloads/linkcrypters/hide.py +123 -0
- quasarr/downloads/packages/__init__.py +467 -0
- quasarr/downloads/sources/__init__.py +0 -0
- quasarr/downloads/sources/al.py +697 -0
- quasarr/downloads/sources/by.py +106 -0
- quasarr/downloads/sources/dd.py +76 -0
- quasarr/downloads/sources/dj.py +7 -0
- quasarr/downloads/sources/dt.py +66 -0
- quasarr/downloads/sources/dw.py +65 -0
- quasarr/downloads/sources/he.py +112 -0
- quasarr/downloads/sources/mb.py +47 -0
- quasarr/downloads/sources/nk.py +51 -0
- quasarr/downloads/sources/nx.py +105 -0
- quasarr/downloads/sources/sf.py +159 -0
- quasarr/downloads/sources/sj.py +7 -0
- quasarr/downloads/sources/sl.py +90 -0
- quasarr/downloads/sources/wd.py +110 -0
- quasarr/providers/__init__.py +0 -0
- quasarr/providers/cloudflare.py +204 -0
- quasarr/providers/html_images.py +20 -0
- quasarr/providers/html_templates.py +241 -0
- quasarr/providers/imdb_metadata.py +142 -0
- quasarr/providers/log.py +19 -0
- quasarr/providers/myjd_api.py +917 -0
- quasarr/providers/notifications.py +124 -0
- quasarr/providers/obfuscated.py +51 -0
- quasarr/providers/sessions/__init__.py +0 -0
- quasarr/providers/sessions/al.py +286 -0
- quasarr/providers/sessions/dd.py +78 -0
- quasarr/providers/sessions/nx.py +76 -0
- quasarr/providers/shared_state.py +826 -0
- quasarr/providers/statistics.py +154 -0
- quasarr/providers/version.py +118 -0
- quasarr/providers/web_server.py +49 -0
- quasarr/search/__init__.py +153 -0
- quasarr/search/sources/__init__.py +0 -0
- quasarr/search/sources/al.py +448 -0
- quasarr/search/sources/by.py +203 -0
- quasarr/search/sources/dd.py +135 -0
- quasarr/search/sources/dj.py +213 -0
- quasarr/search/sources/dt.py +265 -0
- quasarr/search/sources/dw.py +214 -0
- quasarr/search/sources/fx.py +223 -0
- quasarr/search/sources/he.py +196 -0
- quasarr/search/sources/mb.py +195 -0
- quasarr/search/sources/nk.py +188 -0
- quasarr/search/sources/nx.py +197 -0
- quasarr/search/sources/sf.py +374 -0
- quasarr/search/sources/sj.py +213 -0
- quasarr/search/sources/sl.py +246 -0
- quasarr/search/sources/wd.py +208 -0
- quasarr/storage/__init__.py +0 -0
- quasarr/storage/config.py +163 -0
- quasarr/storage/setup.py +458 -0
- quasarr/storage/sqlite_database.py +80 -0
- quasarr-1.20.6.dist-info/METADATA +304 -0
- quasarr-1.20.6.dist-info/RECORD +72 -0
- quasarr-1.20.6.dist-info/WHEEL +5 -0
- quasarr-1.20.6.dist-info/entry_points.txt +2 -0
- quasarr-1.20.6.dist-info/licenses/LICENSE +21 -0
- quasarr-1.20.6.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,826 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
# Quasarr
|
|
3
|
+
# Project by https://github.com/rix1337
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import time
|
|
9
|
+
import traceback
|
|
10
|
+
from datetime import datetime, timedelta, date
|
|
11
|
+
from urllib import parse
|
|
12
|
+
|
|
13
|
+
import quasarr
|
|
14
|
+
from quasarr.providers.log import info, debug
|
|
15
|
+
from quasarr.providers.myjd_api import Myjdapi, TokenExpiredException, RequestTimeoutException, MYJDException, Jddevice
|
|
16
|
+
from quasarr.storage.config import Config
|
|
17
|
+
from quasarr.storage.sqlite_database import DataBase
|
|
18
|
+
|
|
19
|
+
values = {}
|
|
20
|
+
lock = None
|
|
21
|
+
|
|
22
|
+
# regex to detect season/episode tags for series filtering during search
|
|
23
|
+
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})")
|
|
24
|
+
# regex to filter out season/episode tags for movies
|
|
25
|
+
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)
|
|
26
|
+
# List of known file hosters that should not be used as search/feed sites
|
|
27
|
+
SHARE_HOSTERS = {
|
|
28
|
+
"rapidgator",
|
|
29
|
+
"ddownload",
|
|
30
|
+
"keep2share",
|
|
31
|
+
"1fichier",
|
|
32
|
+
"katfile",
|
|
33
|
+
"filer",
|
|
34
|
+
"turbobit",
|
|
35
|
+
"nitroflare",
|
|
36
|
+
"filefactory",
|
|
37
|
+
"uptobox",
|
|
38
|
+
"mediafire",
|
|
39
|
+
"mega",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def set_state(manager_dict, manager_lock):
|
|
44
|
+
global values
|
|
45
|
+
global lock
|
|
46
|
+
values = manager_dict
|
|
47
|
+
lock = manager_lock
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def update(key, value):
|
|
51
|
+
global values
|
|
52
|
+
global lock
|
|
53
|
+
lock.acquire()
|
|
54
|
+
try:
|
|
55
|
+
values[key] = value
|
|
56
|
+
finally:
|
|
57
|
+
lock.release()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def set_connection_info(internal_address, external_address, port):
|
|
61
|
+
if internal_address.count(":") < 2:
|
|
62
|
+
internal_address = f"{internal_address}:{port}"
|
|
63
|
+
update("internal_address", internal_address)
|
|
64
|
+
update("external_address", external_address)
|
|
65
|
+
update("port", port)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def set_files(config_path):
|
|
69
|
+
update("configfile", os.path.join(config_path, "Quasarr.ini"))
|
|
70
|
+
update("dbfile", os.path.join(config_path, "Quasarr.db"))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def generate_api_key():
|
|
74
|
+
api_key = os.urandom(32).hex()
|
|
75
|
+
Config('API').save("key", api_key)
|
|
76
|
+
info(f'API key replaced with: "{api_key}!"')
|
|
77
|
+
return api_key
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def extract_valid_hostname(url, shorthand):
|
|
81
|
+
try:
|
|
82
|
+
if '://' not in url:
|
|
83
|
+
url = 'http://' + url
|
|
84
|
+
result = parse.urlparse(url)
|
|
85
|
+
domain = result.netloc
|
|
86
|
+
parts = domain.split('.')
|
|
87
|
+
|
|
88
|
+
if domain.startswith(".") or domain.endswith(".") or "." not in domain[1:-1]:
|
|
89
|
+
message = f'Error: "{domain}" must contain a "." somewhere in the middle – you need to provide a full domain name!'
|
|
90
|
+
domain = None
|
|
91
|
+
|
|
92
|
+
elif any(hoster in parts for hoster in SHARE_HOSTERS):
|
|
93
|
+
offending = next(host for host in parts if host in SHARE_HOSTERS)
|
|
94
|
+
message = (
|
|
95
|
+
f'Error: "{domain}" is a file‑hosting domain and cannot be used here directly! '
|
|
96
|
+
f'Instead please provide a valid hostname that serves direct file links (including "{offending}").'
|
|
97
|
+
)
|
|
98
|
+
domain = None
|
|
99
|
+
|
|
100
|
+
elif all(char in domain for char in shorthand):
|
|
101
|
+
message = f'"{domain}" contains both characters from shorthand "{shorthand}". Continuing...'
|
|
102
|
+
|
|
103
|
+
else:
|
|
104
|
+
message = f'Error: "{domain}" does not contain both characters from shorthand "{shorthand}".'
|
|
105
|
+
domain = None
|
|
106
|
+
except Exception as e:
|
|
107
|
+
message = f"Error: {e}. Please provide a valid URL."
|
|
108
|
+
domain = None
|
|
109
|
+
|
|
110
|
+
print(message)
|
|
111
|
+
return {"domain": domain, "message": message}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def connect_to_jd(jd, user, password, device_name):
|
|
115
|
+
try:
|
|
116
|
+
jd.connect(user, password)
|
|
117
|
+
jd.update_devices()
|
|
118
|
+
device = jd.get_device(device_name)
|
|
119
|
+
except (TokenExpiredException, RequestTimeoutException, MYJDException) as e:
|
|
120
|
+
info("Error connecting to JDownloader: " + str(e).strip())
|
|
121
|
+
return False
|
|
122
|
+
if not device or not isinstance(device, (type, Jddevice)):
|
|
123
|
+
return False
|
|
124
|
+
else:
|
|
125
|
+
device.downloadcontroller.get_current_state() # request forces direct_connection info update
|
|
126
|
+
connection_info = device.check_direct_connection()
|
|
127
|
+
if connection_info["status"]:
|
|
128
|
+
info(f'Direct connection to JDownloader established: "{connection_info['ip']}"')
|
|
129
|
+
else:
|
|
130
|
+
info("Could not establish direct connection to JDownloader.")
|
|
131
|
+
update("device", device)
|
|
132
|
+
return True
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def set_device(user, password, device):
|
|
136
|
+
jd = Myjdapi()
|
|
137
|
+
jd.set_app_key('Quasarr')
|
|
138
|
+
return connect_to_jd(jd, user, password, device)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def set_device_from_config():
|
|
142
|
+
config = Config('JDownloader')
|
|
143
|
+
user = str(config.get('user'))
|
|
144
|
+
password = str(config.get('password'))
|
|
145
|
+
device = str(config.get('device'))
|
|
146
|
+
|
|
147
|
+
update("device", device)
|
|
148
|
+
|
|
149
|
+
if user and password and device:
|
|
150
|
+
jd = Myjdapi()
|
|
151
|
+
jd.set_app_key('Quasarr')
|
|
152
|
+
return connect_to_jd(jd, user, password, device)
|
|
153
|
+
return False
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def check_device(device):
|
|
157
|
+
try:
|
|
158
|
+
valid = isinstance(device,
|
|
159
|
+
(type, Jddevice)) and device.downloadcontroller.get_current_state()
|
|
160
|
+
except (AttributeError, KeyError, TokenExpiredException, RequestTimeoutException, MYJDException):
|
|
161
|
+
valid = False
|
|
162
|
+
return valid
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def connect_device():
|
|
166
|
+
config = Config('JDownloader')
|
|
167
|
+
user = str(config.get('user'))
|
|
168
|
+
password = str(config.get('password'))
|
|
169
|
+
device = str(config.get('device'))
|
|
170
|
+
|
|
171
|
+
jd = Myjdapi()
|
|
172
|
+
jd.set_app_key('Quasarr')
|
|
173
|
+
|
|
174
|
+
if user and password and device:
|
|
175
|
+
try:
|
|
176
|
+
jd.connect(user, password)
|
|
177
|
+
jd.update_devices()
|
|
178
|
+
device = jd.get_device(device)
|
|
179
|
+
except (TokenExpiredException, RequestTimeoutException, MYJDException):
|
|
180
|
+
pass
|
|
181
|
+
|
|
182
|
+
if check_device(device):
|
|
183
|
+
update("device", device)
|
|
184
|
+
return True
|
|
185
|
+
else:
|
|
186
|
+
return False
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def get_device():
|
|
190
|
+
attempts = 0
|
|
191
|
+
|
|
192
|
+
while True:
|
|
193
|
+
try:
|
|
194
|
+
if check_device(values["device"]):
|
|
195
|
+
break
|
|
196
|
+
except (AttributeError, KeyError, TokenExpiredException, RequestTimeoutException, MYJDException):
|
|
197
|
+
pass
|
|
198
|
+
attempts += 1
|
|
199
|
+
|
|
200
|
+
update("device", False)
|
|
201
|
+
|
|
202
|
+
if attempts % 10 == 0:
|
|
203
|
+
info(
|
|
204
|
+
f"WARNING: {attempts} consecutive JDownloader connection errors. Please check your credentials!")
|
|
205
|
+
time.sleep(3)
|
|
206
|
+
|
|
207
|
+
if connect_device():
|
|
208
|
+
break
|
|
209
|
+
|
|
210
|
+
return values["device"]
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def get_devices(user, password):
|
|
214
|
+
jd = Myjdapi()
|
|
215
|
+
jd.set_app_key('Quasarr')
|
|
216
|
+
try:
|
|
217
|
+
jd.connect(user, password)
|
|
218
|
+
jd.update_devices()
|
|
219
|
+
devices = jd.list_devices()
|
|
220
|
+
return devices
|
|
221
|
+
except (TokenExpiredException, RequestTimeoutException, MYJDException) as e:
|
|
222
|
+
info("Error connecting to JDownloader: " + str(e))
|
|
223
|
+
return []
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def set_device_settings():
|
|
227
|
+
device = get_device()
|
|
228
|
+
|
|
229
|
+
settings_to_enforce = [
|
|
230
|
+
{
|
|
231
|
+
"namespace": "org.jdownloader.settings.GeneralSettings",
|
|
232
|
+
"storage": None,
|
|
233
|
+
"setting": "AutoStartDownloadOption",
|
|
234
|
+
"expected_value": "ALWAYS", # Downloads must start automatically for Quasarr to work
|
|
235
|
+
},
|
|
236
|
+
{
|
|
237
|
+
"namespace": "org.jdownloader.settings.GeneralSettings",
|
|
238
|
+
"storage": None,
|
|
239
|
+
"setting": "IfFileExistsAction",
|
|
240
|
+
"expected_value": "SKIP_FILE", # Prevents popups during download
|
|
241
|
+
},
|
|
242
|
+
{
|
|
243
|
+
"namespace": "org.jdownloader.settings.GeneralSettings",
|
|
244
|
+
"storage": None,
|
|
245
|
+
"setting": "CleanupAfterDownloadAction",
|
|
246
|
+
"expected_value": "NEVER", # Links must be kept after download for Quasarr to work
|
|
247
|
+
},
|
|
248
|
+
{
|
|
249
|
+
"namespace": "org.jdownloader.settings.GraphicalUserInterfaceSettings",
|
|
250
|
+
"storage": None,
|
|
251
|
+
"setting": "BannerEnabled",
|
|
252
|
+
"expected_value": False, # Removes UI clutter in JDownloader
|
|
253
|
+
},
|
|
254
|
+
{
|
|
255
|
+
"namespace": "org.jdownloader.settings.GraphicalUserInterfaceSettings",
|
|
256
|
+
"storage": None,
|
|
257
|
+
"setting": "DonateButtonState",
|
|
258
|
+
"expected_value": "CUSTOM_HIDDEN", # Removes UI clutter in JDownloader
|
|
259
|
+
},
|
|
260
|
+
{
|
|
261
|
+
"namespace": "org.jdownloader.extensions.extraction.ExtractionConfig",
|
|
262
|
+
"storage": "cfg/org.jdownloader.extensions.extraction.ExtractionExtension",
|
|
263
|
+
"setting": "DeleteArchiveFilesAfterExtractionAction",
|
|
264
|
+
"expected_value": "NULL", # "NULL" is the ENUM for "Delete files from Harddisk"
|
|
265
|
+
},
|
|
266
|
+
{
|
|
267
|
+
"namespace": "org.jdownloader.extensions.extraction.ExtractionConfig",
|
|
268
|
+
"storage": "cfg/org.jdownloader.extensions.extraction.ExtractionExtension",
|
|
269
|
+
"setting": "IfFileExistsAction",
|
|
270
|
+
"expected_value": "OVERWRITE_FILE", # Prevents popups during extraction
|
|
271
|
+
},
|
|
272
|
+
{
|
|
273
|
+
"namespace": "org.jdownloader.extensions.extraction.ExtractionConfig",
|
|
274
|
+
"storage": "cfg/org.jdownloader.extensions.extraction.ExtractionExtension",
|
|
275
|
+
"setting": "DeleteArchiveDownloadlinksAfterExtraction",
|
|
276
|
+
"expected_value": False, # Links must be kept after extraction for Quasarr to work
|
|
277
|
+
},
|
|
278
|
+
{
|
|
279
|
+
"namespace": "org.jdownloader.gui.views.linkgrabber.addlinksdialog.LinkgrabberSettings",
|
|
280
|
+
"storage": None,
|
|
281
|
+
"setting": "OfflinePackageEnabled",
|
|
282
|
+
"expected_value": False, # Don't move offline links to extra package
|
|
283
|
+
},
|
|
284
|
+
{
|
|
285
|
+
"namespace": "org.jdownloader.gui.views.linkgrabber.addlinksdialog.LinkgrabberSettings",
|
|
286
|
+
"storage": None,
|
|
287
|
+
"setting": "HandleOfflineOnConfirmLatestSelection",
|
|
288
|
+
"expected_value": "INCLUDE_OFFLINE", # Offline links must always be kept for Quasarr to handle packages
|
|
289
|
+
},
|
|
290
|
+
{
|
|
291
|
+
"namespace": "org.jdownloader.gui.views.linkgrabber.addlinksdialog.LinkgrabberSettings",
|
|
292
|
+
"storage": None,
|
|
293
|
+
"setting": "AutoConfirmManagerHandleOffline",
|
|
294
|
+
"expected_value": "INCLUDE_OFFLINE", # Offline links must always be kept for Quasarr to handle packages
|
|
295
|
+
},
|
|
296
|
+
{
|
|
297
|
+
"namespace": "org.jdownloader.gui.views.linkgrabber.addlinksdialog.LinkgrabberSettings",
|
|
298
|
+
"storage": None,
|
|
299
|
+
"setting": "DefaultOnAddedOfflineLinksAction",
|
|
300
|
+
"expected_value": "INCLUDE_OFFLINE", # Offline links must always be kept for Quasarr to handle packages
|
|
301
|
+
},
|
|
302
|
+
]
|
|
303
|
+
|
|
304
|
+
for setting in settings_to_enforce:
|
|
305
|
+
namespace = setting["namespace"]
|
|
306
|
+
storage = setting["storage"] or "null"
|
|
307
|
+
name = setting["setting"]
|
|
308
|
+
expected_value = setting["expected_value"]
|
|
309
|
+
|
|
310
|
+
settings = device.config.get(namespace, storage, name)
|
|
311
|
+
|
|
312
|
+
if settings != expected_value:
|
|
313
|
+
success = device.config.set(namespace, storage, name, expected_value)
|
|
314
|
+
|
|
315
|
+
location = f"{namespace}/{storage}" if storage != "null" else namespace
|
|
316
|
+
status = "Updated" if success else "Failed to update"
|
|
317
|
+
info(f'{status} "{name}" in "{location}" to "{expected_value}".')
|
|
318
|
+
|
|
319
|
+
settings_to_add = [
|
|
320
|
+
{
|
|
321
|
+
"namespace": "org.jdownloader.extensions.extraction.ExtractionConfig",
|
|
322
|
+
"storage": "cfg/org.jdownloader.extensions.extraction.ExtractionExtension",
|
|
323
|
+
"setting": "BlacklistPatterns",
|
|
324
|
+
"expected_values": [
|
|
325
|
+
'.*sample/.*',
|
|
326
|
+
'.*Sample/.*',
|
|
327
|
+
'.*\\.jpe?g',
|
|
328
|
+
'.*\\.idx',
|
|
329
|
+
'.*\\.sub',
|
|
330
|
+
'.*\\.srt',
|
|
331
|
+
'.*\\.nfo',
|
|
332
|
+
'.*\\.bat',
|
|
333
|
+
'.*\\.txt',
|
|
334
|
+
'.*\\.exe',
|
|
335
|
+
'.*\\.sfv'
|
|
336
|
+
]
|
|
337
|
+
},
|
|
338
|
+
{
|
|
339
|
+
"namespace": "org.jdownloader.controlling.filter.LinkFilterSettings",
|
|
340
|
+
"storage": "null",
|
|
341
|
+
"setting": "FilterList",
|
|
342
|
+
"expected_values": [
|
|
343
|
+
{'conditionFilter':
|
|
344
|
+
{'conditions': [], 'enabled': False, 'matchType': 'IS_TRUE'}, 'created': 0,
|
|
345
|
+
'enabled': True,
|
|
346
|
+
'filenameFilter': {
|
|
347
|
+
'enabled': True,
|
|
348
|
+
'matchType': 'CONTAINS',
|
|
349
|
+
'regex': '.*\\.(sfv|jpe?g|idx|srt|nfo|bat|txt|exe)',
|
|
350
|
+
'useRegex': True
|
|
351
|
+
},
|
|
352
|
+
'filesizeFilter': {'enabled': False, 'from': 0, 'matchType': 'BETWEEN', 'to': 0},
|
|
353
|
+
'filetypeFilter': {'archivesEnabled': False, 'audioFilesEnabled': False, 'customs': None,
|
|
354
|
+
'docFilesEnabled': False, 'enabled': False, 'exeFilesEnabled': False,
|
|
355
|
+
'hashEnabled': False, 'imagesEnabled': False, 'matchType': 'IS',
|
|
356
|
+
'subFilesEnabled': False, 'useRegex': False, 'videoFilesEnabled': False},
|
|
357
|
+
'hosterURLFilter': {'enabled': False, 'matchType': 'CONTAINS', 'regex': '', 'useRegex': False},
|
|
358
|
+
'matchAlwaysFilter': {'enabled': False}, 'name': 'Quasarr_Block_Files',
|
|
359
|
+
'onlineStatusFilter': {'enabled': False, 'matchType': 'IS', 'onlineStatus': 'OFFLINE'},
|
|
360
|
+
'originFilter': {'enabled': False, 'matchType': 'IS', 'origins': []},
|
|
361
|
+
'packagenameFilter': {'enabled': False, 'matchType': 'CONTAINS', 'regex': '', 'useRegex': False},
|
|
362
|
+
'pluginStatusFilter': {'enabled': False, 'matchType': 'IS', 'pluginStatus': 'PREMIUM'},
|
|
363
|
+
'sourceURLFilter': {'enabled': False, 'matchType': 'CONTAINS', 'regex': '', 'useRegex': False},
|
|
364
|
+
'testUrl': ''}]
|
|
365
|
+
},
|
|
366
|
+
]
|
|
367
|
+
|
|
368
|
+
for setting in settings_to_add:
|
|
369
|
+
namespace = setting["namespace"]
|
|
370
|
+
storage = setting["storage"] or "null"
|
|
371
|
+
name = setting["setting"]
|
|
372
|
+
expected_values = setting["expected_values"]
|
|
373
|
+
|
|
374
|
+
added_items = 0
|
|
375
|
+
settings = device.config.get(namespace, storage, name)
|
|
376
|
+
for item in expected_values:
|
|
377
|
+
if item not in settings:
|
|
378
|
+
settings.append(item)
|
|
379
|
+
added_items += 1
|
|
380
|
+
|
|
381
|
+
if added_items:
|
|
382
|
+
success = device.config.set(namespace, storage, name, json.dumps(settings))
|
|
383
|
+
|
|
384
|
+
location = f"{namespace}/{storage}" if storage != "null" else namespace
|
|
385
|
+
status = "Added" if success else "Failed to add"
|
|
386
|
+
info(f'{status} {added_items} items to "{name}" in "{location}".')
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def update_jdownloader():
|
|
390
|
+
try:
|
|
391
|
+
if not get_device():
|
|
392
|
+
set_device_from_config()
|
|
393
|
+
device = get_device()
|
|
394
|
+
|
|
395
|
+
if device:
|
|
396
|
+
try:
|
|
397
|
+
current_state = device.downloadcontroller.get_current_state()
|
|
398
|
+
is_collecting = device.linkgrabber.is_collecting()
|
|
399
|
+
update_available = device.update.update_available()
|
|
400
|
+
|
|
401
|
+
if (current_state.lower() == "idle") and (not is_collecting and update_available):
|
|
402
|
+
info("JDownloader update ready. Starting update...")
|
|
403
|
+
device.update.restart_and_update()
|
|
404
|
+
except quasarr.providers.myjd_api.TokenExpiredException:
|
|
405
|
+
return False
|
|
406
|
+
return True
|
|
407
|
+
else:
|
|
408
|
+
return False
|
|
409
|
+
except quasarr.providers.myjd_api.MYJDException as e:
|
|
410
|
+
info(f"Error updating JDownloader: {e}")
|
|
411
|
+
return False
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def start_downloads():
|
|
415
|
+
try:
|
|
416
|
+
if not get_device():
|
|
417
|
+
set_device_from_config()
|
|
418
|
+
device = get_device()
|
|
419
|
+
|
|
420
|
+
if device:
|
|
421
|
+
try:
|
|
422
|
+
return device.downloadcontroller.start_downloads()
|
|
423
|
+
except quasarr.providers.myjd_api.TokenExpiredException:
|
|
424
|
+
return False
|
|
425
|
+
else:
|
|
426
|
+
return False
|
|
427
|
+
except quasarr.providers.myjd_api.MYJDException as e:
|
|
428
|
+
info(f"Error starting Downloads: {e}")
|
|
429
|
+
return False
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def get_db(table):
|
|
433
|
+
return DataBase(table)
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def convert_to_mb(item):
|
|
437
|
+
size = float(item['size'])
|
|
438
|
+
unit = item['sizeunit'].upper()
|
|
439
|
+
|
|
440
|
+
if unit == 'B':
|
|
441
|
+
size_b = size
|
|
442
|
+
elif unit == 'KB':
|
|
443
|
+
size_b = size * 1024
|
|
444
|
+
elif unit == 'MB':
|
|
445
|
+
size_b = size * 1024 * 1024
|
|
446
|
+
elif unit == 'GB':
|
|
447
|
+
size_b = size * 1024 * 1024 * 1024
|
|
448
|
+
elif unit == 'TB':
|
|
449
|
+
size_b = size * 1024 * 1024 * 1024 * 1024
|
|
450
|
+
else:
|
|
451
|
+
raise ValueError(f"Unsupported size unit {item['name']} {item['size']} {item['sizeunit']}")
|
|
452
|
+
|
|
453
|
+
size_mb = size_b / (1024 * 1024)
|
|
454
|
+
return int(size_mb)
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def sanitize_title(title: str) -> str:
|
|
458
|
+
umlaut_map = {
|
|
459
|
+
"Ä": "Ae", "ä": "ae",
|
|
460
|
+
"Ö": "Oe", "ö": "oe",
|
|
461
|
+
"Ü": "Ue", "ü": "ue",
|
|
462
|
+
"ß": "ss"
|
|
463
|
+
}
|
|
464
|
+
for umlaut, replacement in umlaut_map.items():
|
|
465
|
+
title = title.replace(umlaut, replacement)
|
|
466
|
+
|
|
467
|
+
title = title.encode("ascii", errors="ignore").decode()
|
|
468
|
+
|
|
469
|
+
# Replace slashes and spaces with dots
|
|
470
|
+
title = title.replace("/", "").replace(" ", ".")
|
|
471
|
+
title = title.strip(".") # no leading/trailing dots
|
|
472
|
+
title = title.replace(".-.", "-") # .-. → -
|
|
473
|
+
|
|
474
|
+
# Finally, drop any chars except letters, digits, dots, hyphens, ampersands
|
|
475
|
+
title = re.sub(r"[^A-Za-z0-9.\-&]", "", title)
|
|
476
|
+
|
|
477
|
+
# remove any repeated dots
|
|
478
|
+
title = re.sub(r"\.{2,}", ".", title)
|
|
479
|
+
return title
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def sanitize_string(s):
|
|
483
|
+
s = s.lower()
|
|
484
|
+
|
|
485
|
+
# Remove dots / pluses
|
|
486
|
+
s = s.replace('.', ' ')
|
|
487
|
+
s = s.replace('+', ' ')
|
|
488
|
+
s = s.replace('_', ' ')
|
|
489
|
+
s = s.replace('-', ' ')
|
|
490
|
+
|
|
491
|
+
# Umlauts
|
|
492
|
+
s = re.sub(r'ä', 'ae', s)
|
|
493
|
+
s = re.sub(r'ö', 'oe', s)
|
|
494
|
+
s = re.sub(r'ü', 'ue', s)
|
|
495
|
+
s = re.sub(r'ß', 'ss', s)
|
|
496
|
+
|
|
497
|
+
# Remove special characters
|
|
498
|
+
s = re.sub(r'[^a-zA-Z0-9\s]', '', s)
|
|
499
|
+
|
|
500
|
+
# Remove season and episode patterns
|
|
501
|
+
s = re.sub(r'\bs\d{1,3}(e\d{1,3})?\b', '', s)
|
|
502
|
+
|
|
503
|
+
# Remove German and English articles
|
|
504
|
+
articles = r'\b(?:der|die|das|ein|eine|einer|eines|einem|einen|the|a|an|and)\b'
|
|
505
|
+
s = re.sub(articles, '', s, re.IGNORECASE)
|
|
506
|
+
|
|
507
|
+
# Replace obsolete titles
|
|
508
|
+
s = s.replace('navy cis', 'ncis')
|
|
509
|
+
|
|
510
|
+
# Remove extra whitespace
|
|
511
|
+
s = ' '.join(s.split())
|
|
512
|
+
|
|
513
|
+
return s
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def search_string_in_sanitized_title(search_string, title):
|
|
517
|
+
sanitized_search_string = sanitize_string(search_string)
|
|
518
|
+
sanitized_title = sanitize_string(title)
|
|
519
|
+
|
|
520
|
+
# Use word boundaries to ensure full word/phrase match
|
|
521
|
+
if re.search(rf'\b{re.escape(sanitized_search_string)}\b', sanitized_title):
|
|
522
|
+
debug(f"Matched search string: {sanitized_search_string} with title: {sanitized_title}")
|
|
523
|
+
return True
|
|
524
|
+
else:
|
|
525
|
+
debug(f"Skipping {title} as it doesn't match search string: {sanitized_search_string}")
|
|
526
|
+
return False
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def is_imdb_id(search_string):
|
|
530
|
+
if bool(re.fullmatch(r"tt\d{7,}", search_string)):
|
|
531
|
+
return search_string
|
|
532
|
+
else:
|
|
533
|
+
return None
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def match_in_title(title: str, season: int = None, episode: int = None) -> bool:
|
|
537
|
+
# ensure season/episode are ints (or None)
|
|
538
|
+
if isinstance(season, str):
|
|
539
|
+
try:
|
|
540
|
+
season = int(season)
|
|
541
|
+
except ValueError:
|
|
542
|
+
season = None
|
|
543
|
+
if isinstance(episode, str):
|
|
544
|
+
try:
|
|
545
|
+
episode = int(episode)
|
|
546
|
+
except ValueError:
|
|
547
|
+
episode = None
|
|
548
|
+
|
|
549
|
+
pattern = re.compile(
|
|
550
|
+
r"(?i)(?:\.|^)[sS](\d+)(?:-(\d+))?" # season or season‑range
|
|
551
|
+
r"(?:[eE](\d+)(?:-(?:[eE]?)(\d+))?)?" # episode or episode‑range
|
|
552
|
+
r"(?=[\.-]|$)"
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
matches = pattern.findall(title)
|
|
556
|
+
if not matches:
|
|
557
|
+
return False
|
|
558
|
+
|
|
559
|
+
for s_start, s_end, e_start, e_end in matches:
|
|
560
|
+
se_start, se_end = int(s_start), int(s_end or s_start)
|
|
561
|
+
|
|
562
|
+
# if a season was requested, ensure it falls in the range
|
|
563
|
+
if season is not None and not (se_start <= season <= se_end):
|
|
564
|
+
continue
|
|
565
|
+
|
|
566
|
+
# if no episode requested, only accept if the title itself had no episode tag
|
|
567
|
+
if episode is None:
|
|
568
|
+
if not e_start:
|
|
569
|
+
return True
|
|
570
|
+
else:
|
|
571
|
+
# title did specify an episode — skip this match
|
|
572
|
+
continue
|
|
573
|
+
|
|
574
|
+
# episode was requested, so title must supply one
|
|
575
|
+
if not e_start:
|
|
576
|
+
continue
|
|
577
|
+
|
|
578
|
+
ep_start, ep_end = int(e_start), int(e_end or e_start)
|
|
579
|
+
if ep_start <= episode <= ep_end:
|
|
580
|
+
return True
|
|
581
|
+
|
|
582
|
+
return False
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def is_valid_release(title: str,
|
|
586
|
+
request_from: str,
|
|
587
|
+
search_string: str,
|
|
588
|
+
season: int = None,
|
|
589
|
+
episode: int = None) -> bool:
|
|
590
|
+
"""
|
|
591
|
+
Return True if the given release title is valid for the given search parameters.
|
|
592
|
+
- title: the release title to test
|
|
593
|
+
- request_from: user agent, contains 'Radarr' for movie searches or 'Sonarr' for TV searches
|
|
594
|
+
- search_string: the original search phrase (could be an IMDb id or plain text)
|
|
595
|
+
- season: desired season number (or None)
|
|
596
|
+
- episode: desired episode number (or None)
|
|
597
|
+
"""
|
|
598
|
+
try:
|
|
599
|
+
# Determine whether this is a movie or TV search
|
|
600
|
+
rf = request_from.lower()
|
|
601
|
+
is_movie_search = 'radarr' in rf
|
|
602
|
+
is_tv_search = 'sonarr' in rf
|
|
603
|
+
is_docs_search = 'lazylibrarian' in rf
|
|
604
|
+
|
|
605
|
+
# if search string is NOT an imdb id check search_string_in_sanitized_title - if not match, its not valid
|
|
606
|
+
if not is_docs_search and not is_imdb_id(search_string):
|
|
607
|
+
if not search_string_in_sanitized_title(search_string, title):
|
|
608
|
+
debug(f"Skipping {title!r} as it doesn't match sanitized search string: {search_string!r}")
|
|
609
|
+
return False
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
# if it's a movie search, don't allow any TV show titles (check for NO season or episode tags in the title)
|
|
613
|
+
if is_movie_search:
|
|
614
|
+
if not MOVIE_REGEX.match(title):
|
|
615
|
+
debug(f"Skipping {title!r} as title doesn't match movie regex: {MOVIE_REGEX.pattern}")
|
|
616
|
+
return False
|
|
617
|
+
return True
|
|
618
|
+
|
|
619
|
+
# if it's a TV show search, don't allow any movies (check for season or episode tags in the title)
|
|
620
|
+
if is_tv_search:
|
|
621
|
+
# must have some S/E tag present
|
|
622
|
+
if not SEASON_EP_REGEX.search(title):
|
|
623
|
+
debug(f"Skipping {title!r} as title doesn't match TV show regex: {SEASON_EP_REGEX.pattern}")
|
|
624
|
+
return False
|
|
625
|
+
# if caller specified a season or episode, double‑check the match
|
|
626
|
+
if season is not None or episode is not None:
|
|
627
|
+
if not match_in_title(title, season, episode):
|
|
628
|
+
debug(f"Skipping {title!r} as it doesn't match season {season} and episode {episode}")
|
|
629
|
+
return False
|
|
630
|
+
return True
|
|
631
|
+
|
|
632
|
+
# if it's a document search, it should not contain Movie or TV show tags
|
|
633
|
+
if is_docs_search:
|
|
634
|
+
# must NOT have any S/E tag present
|
|
635
|
+
if SEASON_EP_REGEX.search(title):
|
|
636
|
+
debug(f"Skipping {title!r} as title matches TV show regex: {SEASON_EP_REGEX.pattern}")
|
|
637
|
+
return False
|
|
638
|
+
return True
|
|
639
|
+
|
|
640
|
+
# unknown search source — reject by default
|
|
641
|
+
debug(f"Skipping {title!r} as search source is unknown: {request_from!r}")
|
|
642
|
+
return False
|
|
643
|
+
|
|
644
|
+
except Exception as e:
|
|
645
|
+
# log exception message and short stack trace
|
|
646
|
+
tb = traceback.format_exc()
|
|
647
|
+
debug(f"Exception in is_valid_release: {e!r}\n{tb}"
|
|
648
|
+
f"is_valid_release called with "
|
|
649
|
+
f"title={title!r}, request_from={request_from!r}, "
|
|
650
|
+
f"search_string={search_string!r}, season={season!r}, episode={episode!r}")
|
|
651
|
+
return False
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def normalize_magazine_title(title: str) -> str:
|
|
655
|
+
"""
|
|
656
|
+
Massage magazine titles so LazyLibrarian's parser can pick up dates reliably:
|
|
657
|
+
- Convert date-like patterns into space-delimited numeric tokens (YYYY MM DD or YYYY MM).
|
|
658
|
+
- Handle malformed "DD.YYYY.YYYY" cases (e.g., 04.2006.2025 → 2025 06 04).
|
|
659
|
+
- Convert two-part month-year like "3.25" into YYYY MM.
|
|
660
|
+
- Convert "No/Nr/Sonderheft X.YYYY" when X≤12 into YYYY MM.
|
|
661
|
+
- Preserve pure issue/volume prefixes and other digit runs untouched.
|
|
662
|
+
"""
|
|
663
|
+
title = title.strip()
|
|
664
|
+
|
|
665
|
+
# 0) Bug: DD.YYYY.YYYY -> treat second YYYY's last two digits as month
|
|
666
|
+
def repl_bug(match):
|
|
667
|
+
d = int(match.group(1))
|
|
668
|
+
m_hint = match.group(2)
|
|
669
|
+
y = int(match.group(3))
|
|
670
|
+
m = int(m_hint[-2:])
|
|
671
|
+
try:
|
|
672
|
+
date(y, m, d)
|
|
673
|
+
return f"{y:04d} {m:02d} {d:02d}"
|
|
674
|
+
except ValueError:
|
|
675
|
+
return match.group(0)
|
|
676
|
+
|
|
677
|
+
title = re.sub(r"\b(\d{1,2})\.(20\d{2})\.(20\d{2})\b", repl_bug, title)
|
|
678
|
+
|
|
679
|
+
# 1) DD.MM.YYYY -> "YYYY MM DD"
|
|
680
|
+
def repl_dmy(match):
|
|
681
|
+
d, m, y = map(int, match.groups())
|
|
682
|
+
try:
|
|
683
|
+
date(y, m, d)
|
|
684
|
+
return f"{y:04d} {m:02d} {d:02d}"
|
|
685
|
+
except ValueError:
|
|
686
|
+
return match.group(0)
|
|
687
|
+
|
|
688
|
+
title = re.sub(r"\b(\d{1,2})\.(\d{1,2})\.(\d{4})\b", repl_dmy, title)
|
|
689
|
+
|
|
690
|
+
# 2) DD[.]? MonthName YYYY (optional 'vom') -> "YYYY MM DD"
|
|
691
|
+
def repl_dmony(match):
|
|
692
|
+
d = int(match.group(1))
|
|
693
|
+
name = match.group(2)
|
|
694
|
+
y = int(match.group(3))
|
|
695
|
+
mm = _month_num(name)
|
|
696
|
+
if mm:
|
|
697
|
+
try:
|
|
698
|
+
date(y, mm, d)
|
|
699
|
+
return f"{y:04d} {mm:02d} {d:02d}"
|
|
700
|
+
except ValueError:
|
|
701
|
+
pass
|
|
702
|
+
return match.group(0)
|
|
703
|
+
|
|
704
|
+
title = re.sub(
|
|
705
|
+
r"\b(?:vom\s*)?(\d{1,2})\.?\s+([A-Za-zÄÖÜäöüß]+)\s+(\d{4})\b",
|
|
706
|
+
repl_dmony,
|
|
707
|
+
title,
|
|
708
|
+
flags=re.IGNORECASE
|
|
709
|
+
)
|
|
710
|
+
|
|
711
|
+
# 3) MonthName YYYY -> "YYYY MM"
|
|
712
|
+
def repl_mony(match):
|
|
713
|
+
name = match.group(1)
|
|
714
|
+
y = int(match.group(2))
|
|
715
|
+
mm = _month_num(name)
|
|
716
|
+
if mm:
|
|
717
|
+
try:
|
|
718
|
+
date(y, mm, 1)
|
|
719
|
+
return f"{y:04d} {mm:02d}"
|
|
720
|
+
except ValueError:
|
|
721
|
+
pass
|
|
722
|
+
return match.group(0)
|
|
723
|
+
|
|
724
|
+
title = re.sub(r"\b([A-Za-zÄÖÜäöüß]+)\s+(\d{4})\b", repl_mony, title, flags=re.IGNORECASE)
|
|
725
|
+
|
|
726
|
+
# 4) YYYYMMDD -> "YYYY MM DD"
|
|
727
|
+
def repl_ymd(match):
|
|
728
|
+
y = int(match.group(1))
|
|
729
|
+
m = int(match.group(2))
|
|
730
|
+
d = int(match.group(3))
|
|
731
|
+
try:
|
|
732
|
+
date(y, m, d)
|
|
733
|
+
return f"{y:04d} {m:02d} {d:02d}"
|
|
734
|
+
except ValueError:
|
|
735
|
+
return match.group(0)
|
|
736
|
+
|
|
737
|
+
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)
|
|
738
|
+
|
|
739
|
+
# 5) YYYYMM -> "YYYY MM"
|
|
740
|
+
def repl_ym(match):
|
|
741
|
+
y = int(match.group(1))
|
|
742
|
+
m = int(match.group(2))
|
|
743
|
+
try:
|
|
744
|
+
date(y, m, 1)
|
|
745
|
+
return f"{y:04d} {m:02d}"
|
|
746
|
+
except ValueError:
|
|
747
|
+
return match.group(0)
|
|
748
|
+
|
|
749
|
+
title = re.sub(r"\b(20\d{2})(0[1-9]|1[0-2])\b", repl_ym, title)
|
|
750
|
+
|
|
751
|
+
# 6) X.YY (month.two-digit-year) -> "YYYY MM" (e.g., 3.25 -> 2025 03)
|
|
752
|
+
def repl_my2(match):
|
|
753
|
+
mm = int(match.group(1))
|
|
754
|
+
yy = int(match.group(2))
|
|
755
|
+
y = 2000 + yy
|
|
756
|
+
if 1 <= mm <= 12:
|
|
757
|
+
try:
|
|
758
|
+
date(y, mm, 1)
|
|
759
|
+
return f"{y:04d} {mm:02d}"
|
|
760
|
+
except ValueError:
|
|
761
|
+
pass
|
|
762
|
+
return match.group(0)
|
|
763
|
+
|
|
764
|
+
title = re.sub(r"\b([1-9]|1[0-2])\.(\d{2})\b", repl_my2, title)
|
|
765
|
+
|
|
766
|
+
# 7) No/Nr/Sonderheft <1-12>.<YYYY> -> "YYYY MM"
|
|
767
|
+
def repl_nmy(match):
|
|
768
|
+
num = int(match.group(1))
|
|
769
|
+
y = int(match.group(2))
|
|
770
|
+
if 1 <= num <= 12:
|
|
771
|
+
try:
|
|
772
|
+
date(y, num, 1)
|
|
773
|
+
return f"{y:04d} {num:02d}"
|
|
774
|
+
except ValueError:
|
|
775
|
+
pass
|
|
776
|
+
return match.group(0)
|
|
777
|
+
|
|
778
|
+
title = re.sub(
|
|
779
|
+
r"\b(?:No|Nr|Sonderheft)\s*(\d{1,2})\.(\d{4})\b",
|
|
780
|
+
repl_nmy,
|
|
781
|
+
title,
|
|
782
|
+
flags=re.IGNORECASE
|
|
783
|
+
)
|
|
784
|
+
|
|
785
|
+
return title
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
# Helper for month name mapping
|
|
789
|
+
def _month_num(name: str) -> int:
|
|
790
|
+
name = name.lower()
|
|
791
|
+
mmap = {
|
|
792
|
+
'januar': 1, 'jan': 1, 'februar': 2, 'feb': 2, 'märz': 3, 'maerz': 3, 'mär': 3, 'mrz': 3, 'mae': 3,
|
|
793
|
+
'april': 4, 'apr': 4, 'mai': 5, 'juni': 6, 'jun': 6, 'juli': 7, 'jul': 7, 'august': 8, 'aug': 8,
|
|
794
|
+
'september': 9, 'sep': 9, 'oktober': 10, 'okt': 10, 'november': 11, 'nov': 11, 'dezember': 12, 'dez': 12,
|
|
795
|
+
'january': 1, 'february': 2, 'march': 3, 'april': 4, 'may': 5, 'june': 6, 'july': 7, 'august': 8,
|
|
796
|
+
'september': 9, 'october': 10, 'november': 11, 'december': 12
|
|
797
|
+
}
|
|
798
|
+
return mmap.get(name)
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
def get_recently_searched(shared_state, context, timeout_seconds):
|
|
802
|
+
recently_searched = shared_state.values.get(context, {})
|
|
803
|
+
threshold = datetime.now() - timedelta(seconds=timeout_seconds)
|
|
804
|
+
keys_to_remove = [key for key, value in recently_searched.items() if value["timestamp"] <= threshold]
|
|
805
|
+
for key in keys_to_remove:
|
|
806
|
+
debug(f"Removing '{key}' from recently searched memory ({context})...")
|
|
807
|
+
del recently_searched[key]
|
|
808
|
+
return recently_searched
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
def download_package(links, title, password, package_id):
|
|
812
|
+
device = get_device()
|
|
813
|
+
downloaded = device.linkgrabber.add_links(params=[
|
|
814
|
+
{
|
|
815
|
+
"autostart": False,
|
|
816
|
+
"links": json.dumps(links),
|
|
817
|
+
"packageName": title,
|
|
818
|
+
"extractPassword": password,
|
|
819
|
+
"priority": "DEFAULT",
|
|
820
|
+
"downloadPassword": password,
|
|
821
|
+
"destinationFolder": "Quasarr/<jd:packagename>",
|
|
822
|
+
"comment": package_id,
|
|
823
|
+
"overwritePackagizerRules": True
|
|
824
|
+
}
|
|
825
|
+
])
|
|
826
|
+
return downloaded
|