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.
- quasarr/__init__.py +316 -42
- quasarr/api/__init__.py +187 -0
- quasarr/api/arr/__init__.py +387 -0
- quasarr/api/captcha/__init__.py +1189 -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 +319 -256
- 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 +476 -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/dl.py +199 -0
- quasarr/downloads/sources/dt.py +66 -0
- quasarr/downloads/sources/dw.py +14 -7
- quasarr/downloads/sources/he.py +112 -0
- quasarr/downloads/sources/mb.py +47 -0
- quasarr/downloads/sources/nk.py +54 -0
- quasarr/downloads/sources/nx.py +42 -83
- 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/downloads/sources/wx.py +127 -0
- quasarr/providers/cloudflare.py +204 -0
- quasarr/providers/html_images.py +22 -0
- quasarr/providers/html_templates.py +211 -104
- quasarr/providers/imdb_metadata.py +108 -3
- quasarr/providers/log.py +19 -0
- quasarr/providers/myjd_api.py +201 -40
- quasarr/providers/notifications.py +99 -11
- quasarr/providers/obfuscated.py +65 -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/dl.py +175 -0
- quasarr/providers/sessions/nx.py +76 -0
- quasarr/providers/shared_state.py +656 -79
- quasarr/providers/statistics.py +154 -0
- quasarr/providers/version.py +60 -1
- quasarr/providers/web_server.py +1 -1
- quasarr/search/__init__.py +144 -15
- quasarr/search/sources/al.py +448 -0
- quasarr/search/sources/by.py +204 -0
- quasarr/search/sources/dd.py +135 -0
- quasarr/search/sources/dj.py +213 -0
- quasarr/search/sources/dl.py +354 -0
- quasarr/search/sources/dt.py +265 -0
- quasarr/search/sources/dw.py +94 -67
- quasarr/search/sources/fx.py +89 -33
- 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 +75 -21
- 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/search/sources/wx.py +337 -0
- quasarr/storage/config.py +39 -10
- quasarr/storage/setup.py +269 -97
- quasarr/storage/sqlite_database.py +6 -1
- quasarr-1.23.0.dist-info/METADATA +306 -0
- quasarr-1.23.0.dist-info/RECORD +77 -0
- {quasarr-0.1.6.dist-info → quasarr-1.23.0.dist-info}/WHEEL +1 -1
- quasarr/arr/__init__.py +0 -423
- quasarr/captcha_solver/__init__.py +0 -284
- quasarr-0.1.6.dist-info/METADATA +0 -81
- quasarr-0.1.6.dist-info/RECORD +0 -31
- {quasarr-0.1.6.dist-info → quasarr-1.23.0.dist-info}/entry_points.txt +0 -0
- {quasarr-0.1.6.dist-info → quasarr-1.23.0.dist-info/licenses}/LICENSE +0 -0
- {quasarr-0.1.6.dist-info → quasarr-1.23.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
# Quasarr
|
|
3
|
+
# Project by https://github.com/rix1337
|
|
4
|
+
|
|
5
|
+
import traceback
|
|
6
|
+
import xml.sax.saxutils as sax_utils
|
|
7
|
+
from base64 import urlsafe_b64decode
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from functools import wraps
|
|
10
|
+
from urllib.parse import urlparse, parse_qs
|
|
11
|
+
from xml.etree import ElementTree
|
|
12
|
+
|
|
13
|
+
from bottle import abort, request
|
|
14
|
+
|
|
15
|
+
from quasarr.downloads import download
|
|
16
|
+
from quasarr.downloads.packages import get_packages, delete_package
|
|
17
|
+
from quasarr.providers import shared_state
|
|
18
|
+
from quasarr.providers.log import info, debug
|
|
19
|
+
from quasarr.providers.version import get_version
|
|
20
|
+
from quasarr.search import get_search_results
|
|
21
|
+
from quasarr.storage.config import Config
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def require_api_key(func):
|
|
25
|
+
@wraps(func)
|
|
26
|
+
def decorated(*args, **kwargs):
|
|
27
|
+
api_key = Config('API').get('key')
|
|
28
|
+
if not request.query.apikey:
|
|
29
|
+
return abort(401, "Missing API key")
|
|
30
|
+
if request.query.apikey != api_key:
|
|
31
|
+
return abort(403, "Invalid API key")
|
|
32
|
+
return func(*args, **kwargs)
|
|
33
|
+
|
|
34
|
+
return decorated
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def setup_arr_routes(app):
|
|
38
|
+
@app.get('/download/')
|
|
39
|
+
def fake_nzb_file():
|
|
40
|
+
payload = request.query.payload
|
|
41
|
+
decoded_payload = urlsafe_b64decode(payload).decode("utf-8").split("|")
|
|
42
|
+
title = decoded_payload[0]
|
|
43
|
+
url = decoded_payload[1]
|
|
44
|
+
mirror = decoded_payload[2]
|
|
45
|
+
size_mb = decoded_payload[3]
|
|
46
|
+
password = decoded_payload[4]
|
|
47
|
+
imdb_id = decoded_payload[5]
|
|
48
|
+
return f'<nzb><file title="{title}" url="{url}" mirror="{mirror}" size_mb="{size_mb}" password="{password}" imdb_id="{imdb_id}"/></nzb>'
|
|
49
|
+
|
|
50
|
+
@app.post('/api')
|
|
51
|
+
@require_api_key
|
|
52
|
+
def download_fake_nzb_file():
|
|
53
|
+
downloads = request.files.getall('name')
|
|
54
|
+
nzo_ids = [] # naming structure for package IDs expected in newznab
|
|
55
|
+
|
|
56
|
+
for upload in downloads:
|
|
57
|
+
file_content = upload.file.read()
|
|
58
|
+
root = ElementTree.fromstring(file_content)
|
|
59
|
+
|
|
60
|
+
title = sax_utils.unescape(root.find(".//file").attrib["title"])
|
|
61
|
+
|
|
62
|
+
url = root.find(".//file").attrib["url"]
|
|
63
|
+
mirror = None if (mirror := root.find(".//file").attrib.get("mirror")) == "None" else mirror
|
|
64
|
+
|
|
65
|
+
size_mb = root.find(".//file").attrib["size_mb"]
|
|
66
|
+
password = root.find(".//file").attrib.get("password")
|
|
67
|
+
imdb_id = root.find(".//file").attrib.get("imdb_id")
|
|
68
|
+
|
|
69
|
+
info(f'Attempting download for "{title}"')
|
|
70
|
+
request_from = request.headers.get('User-Agent')
|
|
71
|
+
downloaded = download(shared_state, request_from, title, url, mirror, size_mb, password, imdb_id)
|
|
72
|
+
try:
|
|
73
|
+
success = downloaded["success"]
|
|
74
|
+
package_id = downloaded["package_id"]
|
|
75
|
+
title = downloaded["title"]
|
|
76
|
+
|
|
77
|
+
if success:
|
|
78
|
+
info(f'"{title}" added successfully!')
|
|
79
|
+
else:
|
|
80
|
+
info(f'"{title}" added unsuccessfully! See log for details.')
|
|
81
|
+
nzo_ids.append(package_id)
|
|
82
|
+
except KeyError:
|
|
83
|
+
info(f'Failed to download "{title}" - no package_id returned')
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
"status": True,
|
|
87
|
+
"nzo_ids": nzo_ids
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
@app.get('/api')
|
|
91
|
+
@app.get('/api/<mirror>')
|
|
92
|
+
@require_api_key
|
|
93
|
+
def quasarr_api(mirror=None):
|
|
94
|
+
api_type = 'arr_download_client' if request.query.mode else 'arr_indexer' if request.query.t else None
|
|
95
|
+
|
|
96
|
+
if api_type == 'arr_download_client':
|
|
97
|
+
# This builds a mock SABnzbd API response based on the My JDownloader integration
|
|
98
|
+
try:
|
|
99
|
+
mode = request.query.mode
|
|
100
|
+
if mode == "auth":
|
|
101
|
+
return {
|
|
102
|
+
"auth": "apikey"
|
|
103
|
+
}
|
|
104
|
+
elif mode == "version":
|
|
105
|
+
return {
|
|
106
|
+
"version": f"Quasarr {get_version()}"
|
|
107
|
+
}
|
|
108
|
+
elif mode == "get_cats":
|
|
109
|
+
return {
|
|
110
|
+
"categories": [
|
|
111
|
+
"*",
|
|
112
|
+
"movies",
|
|
113
|
+
"tv",
|
|
114
|
+
"docs"
|
|
115
|
+
]
|
|
116
|
+
}
|
|
117
|
+
elif mode == "get_config":
|
|
118
|
+
return {
|
|
119
|
+
"config": {
|
|
120
|
+
"misc": {
|
|
121
|
+
"quasarr": True,
|
|
122
|
+
"complete_dir": "/tmp/"
|
|
123
|
+
},
|
|
124
|
+
"categories": [
|
|
125
|
+
{
|
|
126
|
+
"name": "*",
|
|
127
|
+
"order": 0,
|
|
128
|
+
"dir": "",
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
"name": "movies",
|
|
132
|
+
"order": 1,
|
|
133
|
+
"dir": "",
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
"name": "tv",
|
|
137
|
+
"order": 2,
|
|
138
|
+
"dir": "",
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
"name": "docs",
|
|
142
|
+
"order": 3,
|
|
143
|
+
"dir": "",
|
|
144
|
+
},
|
|
145
|
+
]
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
elif mode == "fullstatus":
|
|
149
|
+
return {
|
|
150
|
+
"status": {
|
|
151
|
+
"quasarr": True
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
elif mode == "addurl":
|
|
155
|
+
raw_name = getattr(request.query, "name", None)
|
|
156
|
+
if not raw_name:
|
|
157
|
+
abort(400, "missing or empty 'name' parameter")
|
|
158
|
+
|
|
159
|
+
payload = False
|
|
160
|
+
try:
|
|
161
|
+
parsed = urlparse(raw_name)
|
|
162
|
+
qs = parse_qs(parsed.query)
|
|
163
|
+
payload = qs.get("payload", [None])[0]
|
|
164
|
+
except Exception as e:
|
|
165
|
+
abort(400, f"invalid URL in 'name': {e}")
|
|
166
|
+
if not payload:
|
|
167
|
+
abort(400, "missing 'payload' parameter in URL")
|
|
168
|
+
|
|
169
|
+
title = url = mirror = size_mb = password = imdb_id = None
|
|
170
|
+
try:
|
|
171
|
+
decoded = urlsafe_b64decode(payload.encode()).decode()
|
|
172
|
+
parts = decoded.split("|")
|
|
173
|
+
if len(parts) != 6:
|
|
174
|
+
raise ValueError(f"expected 6 fields, got {len(parts)}")
|
|
175
|
+
title, url, mirror, size_mb, password, imdb_id = parts
|
|
176
|
+
except Exception as e:
|
|
177
|
+
abort(400, f"invalid payload format: {e}")
|
|
178
|
+
|
|
179
|
+
mirror = None if mirror == "None" else mirror
|
|
180
|
+
|
|
181
|
+
nzo_ids = []
|
|
182
|
+
info(f'Attempting download for "{title}"')
|
|
183
|
+
request_from = "lazylibrarian"
|
|
184
|
+
|
|
185
|
+
downloaded = download(
|
|
186
|
+
shared_state,
|
|
187
|
+
request_from,
|
|
188
|
+
title,
|
|
189
|
+
url,
|
|
190
|
+
mirror,
|
|
191
|
+
size_mb,
|
|
192
|
+
password or None,
|
|
193
|
+
imdb_id or None,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
try:
|
|
197
|
+
success = downloaded["success"]
|
|
198
|
+
package_id = downloaded["package_id"]
|
|
199
|
+
title = downloaded.get("title", title)
|
|
200
|
+
|
|
201
|
+
if success:
|
|
202
|
+
info(f'"{title}" added successfully!')
|
|
203
|
+
else:
|
|
204
|
+
info(f'"{title}" added unsuccessfully! See log for details.')
|
|
205
|
+
nzo_ids.append(package_id)
|
|
206
|
+
except KeyError:
|
|
207
|
+
info(f'Failed to download "{title}" - no package_id returned')
|
|
208
|
+
|
|
209
|
+
return {
|
|
210
|
+
"status": True,
|
|
211
|
+
"nzo_ids": nzo_ids
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
elif mode == "queue" or mode == "history":
|
|
215
|
+
if request.query.name and request.query.name == "delete":
|
|
216
|
+
package_id = request.query.value
|
|
217
|
+
deleted = delete_package(shared_state, package_id)
|
|
218
|
+
return {
|
|
219
|
+
"status": deleted,
|
|
220
|
+
"nzo_ids": [package_id]
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
packages = get_packages(shared_state)
|
|
224
|
+
if mode == "queue":
|
|
225
|
+
return {
|
|
226
|
+
"queue": {
|
|
227
|
+
"paused": False,
|
|
228
|
+
"slots": packages.get("queue", [])
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
elif mode == "history":
|
|
232
|
+
return {
|
|
233
|
+
"history": {
|
|
234
|
+
"paused": False,
|
|
235
|
+
"slots": packages.get("history", [])
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
except Exception as e:
|
|
239
|
+
info(f"Error loading packages: {e}")
|
|
240
|
+
info(traceback.format_exc())
|
|
241
|
+
info(f"[ERROR] Unknown download client request: {dict(request.query)}")
|
|
242
|
+
return {
|
|
243
|
+
"status": False
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
elif api_type == 'arr_indexer':
|
|
247
|
+
# this builds a mock Newznab API response based on Quasarr search
|
|
248
|
+
try:
|
|
249
|
+
if mirror:
|
|
250
|
+
debug(f'Search will only return releases that match this mirror: "{mirror}"')
|
|
251
|
+
|
|
252
|
+
mode = request.query.t
|
|
253
|
+
request_from = request.headers.get('User-Agent')
|
|
254
|
+
|
|
255
|
+
if mode == 'caps':
|
|
256
|
+
info(f"Providing indexer capability information to {request_from}")
|
|
257
|
+
return '''<?xml version="1.0" encoding="UTF-8"?>
|
|
258
|
+
<caps>
|
|
259
|
+
<server
|
|
260
|
+
version="1.33.7"
|
|
261
|
+
title="Quasarr"
|
|
262
|
+
url="https://quasarr.indexer/"
|
|
263
|
+
email="support@quasarr.indexer"
|
|
264
|
+
/>
|
|
265
|
+
<limits max="9999" default="9999" />
|
|
266
|
+
<registration available="no" open="no" />
|
|
267
|
+
<searching>
|
|
268
|
+
<search available="yes" supportedParams="q" />
|
|
269
|
+
<tv-search available="yes" supportedParams="imdbid,season,ep" />
|
|
270
|
+
<movie-search available="yes" supportedParams="imdbid" />
|
|
271
|
+
</searching>
|
|
272
|
+
<categories>
|
|
273
|
+
<category id="5000" name="TV" />
|
|
274
|
+
<category id="2000" name="Movies" />
|
|
275
|
+
<category id="7000" name="Books">
|
|
276
|
+
</category>
|
|
277
|
+
</categories>
|
|
278
|
+
</caps>'''
|
|
279
|
+
elif mode in ['movie', 'tvsearch', 'book', 'search']:
|
|
280
|
+
releases = []
|
|
281
|
+
|
|
282
|
+
try:
|
|
283
|
+
offset = int(getattr(request.query, 'offset', 0))
|
|
284
|
+
except (AttributeError, ValueError):
|
|
285
|
+
offset = 0
|
|
286
|
+
|
|
287
|
+
if offset > 0:
|
|
288
|
+
debug(f"Ignoring offset parameter: {offset} - it leads to redundant requests")
|
|
289
|
+
|
|
290
|
+
else:
|
|
291
|
+
if mode == 'movie':
|
|
292
|
+
# supported params: imdbid
|
|
293
|
+
imdb_id = getattr(request.query, 'imdbid', '')
|
|
294
|
+
|
|
295
|
+
releases = get_search_results(shared_state, request_from,
|
|
296
|
+
imdb_id=imdb_id,
|
|
297
|
+
mirror=mirror
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
elif mode == 'tvsearch':
|
|
301
|
+
# supported params: imdbid, season, ep
|
|
302
|
+
imdb_id = getattr(request.query, 'imdbid', '')
|
|
303
|
+
season = getattr(request.query, 'season', None)
|
|
304
|
+
episode = getattr(request.query, 'ep', None)
|
|
305
|
+
releases = get_search_results(shared_state, request_from,
|
|
306
|
+
imdb_id=imdb_id,
|
|
307
|
+
mirror=mirror,
|
|
308
|
+
season=season,
|
|
309
|
+
episode=episode
|
|
310
|
+
)
|
|
311
|
+
elif mode == 'book':
|
|
312
|
+
author = getattr(request.query, 'author', '')
|
|
313
|
+
title = getattr(request.query, 'title', '')
|
|
314
|
+
search_phrase = " ".join(filter(None, [author, title]))
|
|
315
|
+
releases = get_search_results(shared_state, request_from,
|
|
316
|
+
search_phrase=search_phrase,
|
|
317
|
+
mirror=mirror
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
elif mode == 'search':
|
|
321
|
+
if "lazylibrarian" in request_from.lower():
|
|
322
|
+
search_phrase = getattr(request.query, 'q', '')
|
|
323
|
+
releases = get_search_results(shared_state, request_from,
|
|
324
|
+
search_phrase=search_phrase,
|
|
325
|
+
mirror=mirror
|
|
326
|
+
)
|
|
327
|
+
else:
|
|
328
|
+
info(
|
|
329
|
+
f'Ignoring search request from {request_from} - only imdbid searches are supported')
|
|
330
|
+
releases = [{}] # sonarr expects this but we will not support non-imdbid searches
|
|
331
|
+
|
|
332
|
+
items = ""
|
|
333
|
+
for release in releases:
|
|
334
|
+
release = release.get("details", {})
|
|
335
|
+
|
|
336
|
+
# Ensure clean XML output
|
|
337
|
+
title = sax_utils.escape(release.get("title", ""))
|
|
338
|
+
source = sax_utils.escape(release.get("source", ""))
|
|
339
|
+
|
|
340
|
+
if not "lazylibrarian" in request_from.lower():
|
|
341
|
+
title = f'[{release.get("hostname", "").upper()}] {title}'
|
|
342
|
+
|
|
343
|
+
# Get publication date - sources should provide valid dates
|
|
344
|
+
pub_date = release.get("date", "").strip()
|
|
345
|
+
|
|
346
|
+
items += f'''
|
|
347
|
+
<item>
|
|
348
|
+
<title>{title}</title>
|
|
349
|
+
<guid isPermaLink="True">{release.get("link", "")}</guid>
|
|
350
|
+
<link>{release.get("link", "")}</link>
|
|
351
|
+
<comments>{source}</comments>
|
|
352
|
+
<pubDate>{pub_date}</pubDate>
|
|
353
|
+
<enclosure url="{release.get("link", "")}" length="{release.get("size", 0)}" type="application/x-nzb" />
|
|
354
|
+
</item>'''
|
|
355
|
+
|
|
356
|
+
if not items:
|
|
357
|
+
items = f'''
|
|
358
|
+
<item>
|
|
359
|
+
<title>No results found</title>
|
|
360
|
+
<guid isPermaLink="False">0</guid>
|
|
361
|
+
<link>https://github.com/rix1337/Quasarr</link>
|
|
362
|
+
<comments>No results matched your search criteria.</comments>
|
|
363
|
+
<pubDate>{datetime.now().strftime("%a, %d %b %Y %H:%M:%S +0000")}</pubDate>
|
|
364
|
+
<enclosure url="https://github.com/rix1337/Quasarr" length="0" type="application/x-nzb" />
|
|
365
|
+
</item>'''
|
|
366
|
+
|
|
367
|
+
return f'''<?xml version="1.0" encoding="UTF-8"?>
|
|
368
|
+
<rss>
|
|
369
|
+
<channel>
|
|
370
|
+
{items}
|
|
371
|
+
</channel>
|
|
372
|
+
</rss>'''
|
|
373
|
+
except Exception as e:
|
|
374
|
+
info(f"Error loading search results: {e}")
|
|
375
|
+
info(traceback.format_exc())
|
|
376
|
+
info(f"[ERROR] Unknown indexer request: {dict(request.query)}")
|
|
377
|
+
return '''<?xml version="1.0" encoding="UTF-8"?>
|
|
378
|
+
<rss>
|
|
379
|
+
<channel>
|
|
380
|
+
<title>Quasarr Indexer</title>
|
|
381
|
+
<description>Quasarr Indexer API</description>
|
|
382
|
+
<link>https://quasarr.indexer/</link>
|
|
383
|
+
</channel>
|
|
384
|
+
</rss>'''
|
|
385
|
+
|
|
386
|
+
info(f"[ERROR] Unknown general request: {dict(request.query)}")
|
|
387
|
+
return {"error": True}
|