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
quasarr/__init__.py
ADDED
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
# Quasarr
|
|
3
|
+
# Project by https://github.com/rix1337
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import multiprocessing
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import socket
|
|
10
|
+
import sys
|
|
11
|
+
import tempfile
|
|
12
|
+
import time
|
|
13
|
+
from urllib.parse import urlparse
|
|
14
|
+
|
|
15
|
+
import requests
|
|
16
|
+
|
|
17
|
+
from quasarr.api import get_api
|
|
18
|
+
from quasarr.providers import shared_state, version
|
|
19
|
+
from quasarr.providers.log import info, debug
|
|
20
|
+
from quasarr.providers.notifications import send_discord_message
|
|
21
|
+
from quasarr.storage.config import Config, get_clean_hostnames
|
|
22
|
+
from quasarr.storage.setup import path_config, hostnames_config, hostname_credentials_config, flaresolverr_config, \
|
|
23
|
+
jdownloader_config
|
|
24
|
+
from quasarr.storage.sqlite_database import DataBase
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def run():
|
|
28
|
+
with multiprocessing.Manager() as manager:
|
|
29
|
+
shared_state_dict = manager.dict()
|
|
30
|
+
shared_state_lock = manager.Lock()
|
|
31
|
+
shared_state.set_state(shared_state_dict, shared_state_lock)
|
|
32
|
+
|
|
33
|
+
parser = argparse.ArgumentParser()
|
|
34
|
+
parser.add_argument("--port", help="Desired Port, defaults to 8080")
|
|
35
|
+
parser.add_argument("--internal_address", help="Must be provided when running in Docker")
|
|
36
|
+
parser.add_argument("--external_address", help="External address for CAPTCHA notifications")
|
|
37
|
+
parser.add_argument("--discord", help="Discord Webhook URL")
|
|
38
|
+
parser.add_argument("--hostnames", help="Public HTTP(s) Link that contains hostnames definition.")
|
|
39
|
+
arguments = parser.parse_args()
|
|
40
|
+
|
|
41
|
+
sys.stdout = Unbuffered(sys.stdout)
|
|
42
|
+
|
|
43
|
+
print(f"""┌────────────────────────────────────┐
|
|
44
|
+
Quasarr {version.get_version()} by RiX
|
|
45
|
+
https://github.com/rix1337/Quasarr
|
|
46
|
+
└────────────────────────────────────┘""")
|
|
47
|
+
|
|
48
|
+
print("\n===== Recommended Services =====")
|
|
49
|
+
print('For convenient universal premium downloads use: "https://linksnappy.com/?ref=397097"')
|
|
50
|
+
print(
|
|
51
|
+
'Sponsors get automated CAPTCHA solutions: "https://github.com/rix1337/Quasarr?tab=readme-ov-file#sponsorshelper"')
|
|
52
|
+
|
|
53
|
+
print("\n===== Startup Info =====")
|
|
54
|
+
port = int('8080')
|
|
55
|
+
config_path = ""
|
|
56
|
+
if os.environ.get('DOCKER'):
|
|
57
|
+
config_path = "/config"
|
|
58
|
+
if not arguments.internal_address:
|
|
59
|
+
print(
|
|
60
|
+
"You must set the INTERNAL_ADDRESS variable to a locally reachable URL, e.g. http://192.168.1.1:8080")
|
|
61
|
+
print("The local URL will be used by Radarr/Sonarr to connect to Quasarr")
|
|
62
|
+
print("Stopping Quasarr...")
|
|
63
|
+
sys.exit(1)
|
|
64
|
+
else:
|
|
65
|
+
if arguments.port:
|
|
66
|
+
port = int(arguments.port)
|
|
67
|
+
internal_address = f'http://{check_ip()}:{port}'
|
|
68
|
+
|
|
69
|
+
if arguments.internal_address:
|
|
70
|
+
internal_address = arguments.internal_address
|
|
71
|
+
if arguments.external_address:
|
|
72
|
+
external_address = arguments.external_address
|
|
73
|
+
else:
|
|
74
|
+
external_address = internal_address
|
|
75
|
+
|
|
76
|
+
validate_address(internal_address, "--internal_address")
|
|
77
|
+
validate_address(external_address, "--external_address")
|
|
78
|
+
|
|
79
|
+
shared_state.set_connection_info(internal_address, external_address, port)
|
|
80
|
+
|
|
81
|
+
if not config_path:
|
|
82
|
+
config_path_file = "Quasarr.conf"
|
|
83
|
+
if not os.path.exists(config_path_file):
|
|
84
|
+
path_config(shared_state)
|
|
85
|
+
with open(config_path_file, "r") as f:
|
|
86
|
+
config_path = f.readline().strip()
|
|
87
|
+
|
|
88
|
+
os.makedirs(config_path, exist_ok=True)
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
temp_file = tempfile.TemporaryFile(dir=config_path)
|
|
92
|
+
temp_file.close()
|
|
93
|
+
except Exception as e:
|
|
94
|
+
print(f'Could not access "{config_path}": {e}"'
|
|
95
|
+
f'Stopping Quasarr...')
|
|
96
|
+
sys.exit(1)
|
|
97
|
+
|
|
98
|
+
shared_state.set_files(config_path)
|
|
99
|
+
shared_state.update("config", Config)
|
|
100
|
+
shared_state.update("database", DataBase)
|
|
101
|
+
supported_hostnames = extract_allowed_keys(Config._DEFAULT_CONFIG, 'Hostnames')
|
|
102
|
+
shared_state.update("sites", [key.upper() for key in supported_hostnames])
|
|
103
|
+
shared_state.update("user_agent", "") # will be set by FlareSolverr
|
|
104
|
+
shared_state.update("helper_active", False)
|
|
105
|
+
|
|
106
|
+
print(f'Config path: "{config_path}"')
|
|
107
|
+
|
|
108
|
+
flaresolverr_url = Config('FlareSolverr').get('url')
|
|
109
|
+
if not flaresolverr_url:
|
|
110
|
+
flaresolverr_config(shared_state)
|
|
111
|
+
else:
|
|
112
|
+
print(f'Flaresolverr URL: "{flaresolverr_url}"')
|
|
113
|
+
flaresolverr_check = check_flaresolverr(shared_state, flaresolverr_url)
|
|
114
|
+
if flaresolverr_check:
|
|
115
|
+
print(f'User Agent: "{shared_state.values["user_agent"]}"')
|
|
116
|
+
|
|
117
|
+
print("\n===== Hostnames =====")
|
|
118
|
+
try:
|
|
119
|
+
if arguments.hostnames:
|
|
120
|
+
hostnames_link = make_raw_pastebin_link(arguments.hostnames)
|
|
121
|
+
|
|
122
|
+
if is_valid_url(hostnames_link):
|
|
123
|
+
print(f"Extracting hostnames from {hostnames_link}...")
|
|
124
|
+
allowed_keys = supported_hostnames
|
|
125
|
+
max_keys = len(allowed_keys)
|
|
126
|
+
shorthand_list = ', '.join(
|
|
127
|
+
[f'"{key}"' for key in allowed_keys[:-1]]) + ' and ' + f'"{allowed_keys[-1]}"'
|
|
128
|
+
print(f'There are up to {max_keys} hostnames currently supported: {shorthand_list}')
|
|
129
|
+
data = requests.get(hostnames_link).text
|
|
130
|
+
results = extract_kv_pairs(data, allowed_keys)
|
|
131
|
+
|
|
132
|
+
extracted_hostnames = 0
|
|
133
|
+
|
|
134
|
+
if results:
|
|
135
|
+
hostnames = Config('Hostnames')
|
|
136
|
+
for shorthand, hostname in results.items():
|
|
137
|
+
domain_check = shared_state.extract_valid_hostname(hostname, shorthand)
|
|
138
|
+
valid_domain = domain_check.get('domain', None)
|
|
139
|
+
if valid_domain:
|
|
140
|
+
hostnames.save(shorthand, hostname)
|
|
141
|
+
extracted_hostnames += 1
|
|
142
|
+
print(f'Hostname for "{shorthand}" successfully set to "{hostname}"')
|
|
143
|
+
else:
|
|
144
|
+
print(f'Skipping invalid hostname for "{shorthand}" ("{hostname}")')
|
|
145
|
+
if extracted_hostnames == max_keys:
|
|
146
|
+
print(f'All {max_keys} hostnames successfully extracted!')
|
|
147
|
+
print('You can now remove the hostnames link from the command line / environment variable.')
|
|
148
|
+
else:
|
|
149
|
+
print(f'No Hostnames found at "{hostnames_link}". '
|
|
150
|
+
'Ensure to pass a plain hostnames list, not html or json!')
|
|
151
|
+
else:
|
|
152
|
+
print(f'Invalid hostnames URL: "{hostnames_link}"')
|
|
153
|
+
except Exception as e:
|
|
154
|
+
print(f'Error parsing hostnames link: "{e}"')
|
|
155
|
+
|
|
156
|
+
hostnames = get_clean_hostnames(shared_state)
|
|
157
|
+
if not hostnames:
|
|
158
|
+
hostnames_config(shared_state)
|
|
159
|
+
hostnames = get_clean_hostnames(shared_state)
|
|
160
|
+
print(f"You have [{len(hostnames)} of {len(Config._DEFAULT_CONFIG['Hostnames'])}] supported hostnames set up")
|
|
161
|
+
print(f"For efficiency it is recommended to set up as few hostnames as needed.")
|
|
162
|
+
|
|
163
|
+
al = Config('Hostnames').get('al')
|
|
164
|
+
if al:
|
|
165
|
+
user = Config('AL').get('user')
|
|
166
|
+
password = Config('AL').get('password')
|
|
167
|
+
if not user or not password:
|
|
168
|
+
hostname_credentials_config(shared_state, "AL", al)
|
|
169
|
+
|
|
170
|
+
dd = Config('Hostnames').get('dd')
|
|
171
|
+
if dd:
|
|
172
|
+
user = Config('DD').get('user')
|
|
173
|
+
password = Config('DD').get('password')
|
|
174
|
+
if not user or not password:
|
|
175
|
+
hostname_credentials_config(shared_state, "DD", dd)
|
|
176
|
+
|
|
177
|
+
nx = Config('Hostnames').get('nx')
|
|
178
|
+
if nx:
|
|
179
|
+
user = Config('NX').get('user')
|
|
180
|
+
password = Config('NX').get('password')
|
|
181
|
+
if not user or not password:
|
|
182
|
+
hostname_credentials_config(shared_state, "NX", nx)
|
|
183
|
+
|
|
184
|
+
config = Config('JDownloader')
|
|
185
|
+
user = config.get('user')
|
|
186
|
+
password = config.get('password')
|
|
187
|
+
device = config.get('device')
|
|
188
|
+
|
|
189
|
+
if not user or not password or not device:
|
|
190
|
+
jdownloader_config(shared_state)
|
|
191
|
+
|
|
192
|
+
print("\n===== Notifications =====")
|
|
193
|
+
discord_url = ""
|
|
194
|
+
if arguments.discord:
|
|
195
|
+
discord_webhook_pattern = r'^https://discord\.com/api/webhooks/\d+/[\w-]+$'
|
|
196
|
+
if re.match(discord_webhook_pattern, arguments.discord):
|
|
197
|
+
shared_state.update("webhook", arguments.discord)
|
|
198
|
+
print(f"Using Discord Webhook URL for notifications.")
|
|
199
|
+
discord_url = arguments.discord
|
|
200
|
+
else:
|
|
201
|
+
print(f"Invalid Discord Webhook URL provided: {arguments.discord}")
|
|
202
|
+
else:
|
|
203
|
+
print("No Discord Webhook URL provided")
|
|
204
|
+
shared_state.update("discord", discord_url)
|
|
205
|
+
|
|
206
|
+
print("\n===== API Information =====")
|
|
207
|
+
api_key = Config('API').get('key')
|
|
208
|
+
if not api_key:
|
|
209
|
+
api_key = shared_state.generate_api_key()
|
|
210
|
+
|
|
211
|
+
print('Setup instructions: "https://github.com/rix1337/Quasarr?tab=readme-ov-file#instructions"')
|
|
212
|
+
print(f'URL: "{shared_state.values['internal_address']}"')
|
|
213
|
+
print(f'API key: "{api_key}" (without quotes)')
|
|
214
|
+
|
|
215
|
+
if external_address != internal_address:
|
|
216
|
+
print(f'External URL: "{shared_state.values["external_address"]}"')
|
|
217
|
+
|
|
218
|
+
print("\n===== Quasarr Info Log =====")
|
|
219
|
+
if os.getenv('DEBUG'):
|
|
220
|
+
print("===== / Debug Log =====")
|
|
221
|
+
|
|
222
|
+
protected = shared_state.get_db("protected").retrieve_all_titles()
|
|
223
|
+
if protected:
|
|
224
|
+
package_count = len(protected)
|
|
225
|
+
info(f'CAPTCHA-Solution required for {package_count} package{'s' if package_count > 1 else ''} at: '
|
|
226
|
+
f'"{shared_state.values["external_address"]}/captcha"!')
|
|
227
|
+
|
|
228
|
+
jdownloader = multiprocessing.Process(
|
|
229
|
+
target=jdownloader_connection,
|
|
230
|
+
args=(shared_state_dict, shared_state_lock)
|
|
231
|
+
)
|
|
232
|
+
jdownloader.start()
|
|
233
|
+
|
|
234
|
+
updater = multiprocessing.Process(
|
|
235
|
+
target=update_checker,
|
|
236
|
+
args=(shared_state_dict, shared_state_lock)
|
|
237
|
+
)
|
|
238
|
+
updater.start()
|
|
239
|
+
|
|
240
|
+
try:
|
|
241
|
+
get_api(shared_state_dict, shared_state_lock)
|
|
242
|
+
except KeyboardInterrupt:
|
|
243
|
+
jdownloader.kill()
|
|
244
|
+
updater.kill()
|
|
245
|
+
sys.exit(0)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def update_checker(shared_state_dict, shared_state_lock):
|
|
249
|
+
try:
|
|
250
|
+
shared_state.set_state(shared_state_dict, shared_state_lock)
|
|
251
|
+
|
|
252
|
+
message = "!!! UPDATE AVAILABLE !!!"
|
|
253
|
+
link = "https://github.com/rix1337/Quasarr/releases/latest"
|
|
254
|
+
|
|
255
|
+
shared_state.update("last_checked_version", f"v.{version.get_version()}")
|
|
256
|
+
|
|
257
|
+
while True:
|
|
258
|
+
try:
|
|
259
|
+
update_available = version.newer_version_available()
|
|
260
|
+
except Exception as e:
|
|
261
|
+
info(f"Error getting latest version: {e}")
|
|
262
|
+
info(f'Please manually check: "{link}" for more information!')
|
|
263
|
+
update_available = None
|
|
264
|
+
|
|
265
|
+
if update_available and shared_state.values["last_checked_version"] != update_available:
|
|
266
|
+
shared_state.update("last_checked_version", update_available)
|
|
267
|
+
info(message)
|
|
268
|
+
info(f"Please update to {update_available} as soon as possible!")
|
|
269
|
+
info(f'Release notes at: "{link}"')
|
|
270
|
+
update_available = {
|
|
271
|
+
"version": update_available,
|
|
272
|
+
"link": link
|
|
273
|
+
}
|
|
274
|
+
send_discord_message(shared_state, message, "quasarr_update", details=update_available)
|
|
275
|
+
|
|
276
|
+
# wait one hour before next check
|
|
277
|
+
time.sleep(60 * 60)
|
|
278
|
+
except KeyboardInterrupt:
|
|
279
|
+
pass
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def jdownloader_connection(shared_state_dict, shared_state_lock):
|
|
283
|
+
try:
|
|
284
|
+
shared_state.set_state(shared_state_dict, shared_state_lock)
|
|
285
|
+
|
|
286
|
+
shared_state.set_device_from_config()
|
|
287
|
+
|
|
288
|
+
connection_established = shared_state.get_device() and shared_state.get_device().name
|
|
289
|
+
if not connection_established:
|
|
290
|
+
i = 0
|
|
291
|
+
while i < 10:
|
|
292
|
+
i += 1
|
|
293
|
+
info(f'Connection {i} to JDownloader failed. Device name: "{shared_state.values["device"]}"')
|
|
294
|
+
time.sleep(60)
|
|
295
|
+
shared_state.set_device_from_config()
|
|
296
|
+
connection_established = shared_state.get_device() and shared_state.get_device().name
|
|
297
|
+
if connection_established:
|
|
298
|
+
break
|
|
299
|
+
|
|
300
|
+
try:
|
|
301
|
+
info(f'Connection to JDownloader successful. Device name: "{shared_state.get_device().name}"')
|
|
302
|
+
except Exception as e:
|
|
303
|
+
info(f'Error connecting to JDownloader: {e}! Stopping Quasarr!')
|
|
304
|
+
sys.exit(1)
|
|
305
|
+
|
|
306
|
+
try:
|
|
307
|
+
shared_state.set_device_settings()
|
|
308
|
+
except Exception as e:
|
|
309
|
+
print(f"Error checking settings: {e}")
|
|
310
|
+
|
|
311
|
+
try:
|
|
312
|
+
shared_state.update_jdownloader()
|
|
313
|
+
except Exception as e:
|
|
314
|
+
print(f"Error updating JDownloader: {e}")
|
|
315
|
+
|
|
316
|
+
try:
|
|
317
|
+
shared_state.start_downloads()
|
|
318
|
+
except Exception as e:
|
|
319
|
+
print(f"Error starting downloads: {e}")
|
|
320
|
+
|
|
321
|
+
except KeyboardInterrupt:
|
|
322
|
+
pass
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
class Unbuffered(object):
|
|
326
|
+
def __init__(self, stream):
|
|
327
|
+
self.stream = stream
|
|
328
|
+
|
|
329
|
+
def write(self, data):
|
|
330
|
+
self.stream.write(data)
|
|
331
|
+
self.stream.flush()
|
|
332
|
+
|
|
333
|
+
def writelines(self, datas):
|
|
334
|
+
self.stream.writelines(datas)
|
|
335
|
+
self.stream.flush()
|
|
336
|
+
|
|
337
|
+
def __getattr__(self, attr):
|
|
338
|
+
return getattr(self.stream, attr)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def check_ip():
|
|
342
|
+
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
343
|
+
try:
|
|
344
|
+
s.connect(('10.255.255.255', 0))
|
|
345
|
+
ip = s.getsockname()[0]
|
|
346
|
+
except:
|
|
347
|
+
ip = '127.0.0.1'
|
|
348
|
+
finally:
|
|
349
|
+
s.close()
|
|
350
|
+
return ip
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def check_flaresolverr(shared_state, flaresolverr_url):
|
|
354
|
+
# Ensure it ends with /v<digit+>
|
|
355
|
+
if not re.search(r"/v\d+$", flaresolverr_url):
|
|
356
|
+
print(f"FlareSolverr URL does not end with /v#: {flaresolverr_url}")
|
|
357
|
+
return False
|
|
358
|
+
|
|
359
|
+
# Try sending a simple test request
|
|
360
|
+
headers = {"Content-Type": "application/json"}
|
|
361
|
+
data = {
|
|
362
|
+
"cmd": "request.get",
|
|
363
|
+
"url": "http://www.google.com/",
|
|
364
|
+
"maxTimeout": 10000
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
try:
|
|
368
|
+
response = requests.post(flaresolverr_url, headers=headers, json=data, timeout=10)
|
|
369
|
+
response.raise_for_status()
|
|
370
|
+
json_data = response.json()
|
|
371
|
+
|
|
372
|
+
# Check if the structure looks like a valid FlareSolverr response
|
|
373
|
+
if "status" in json_data and json_data["status"] == "ok":
|
|
374
|
+
solution = json_data["solution"]
|
|
375
|
+
solution_ua = solution.get("userAgent", None)
|
|
376
|
+
if solution_ua:
|
|
377
|
+
shared_state.update("user_agent", solution_ua)
|
|
378
|
+
return True
|
|
379
|
+
else:
|
|
380
|
+
print(f"Unexpected FlareSolverr response: {json_data}")
|
|
381
|
+
return False
|
|
382
|
+
|
|
383
|
+
except Exception as e:
|
|
384
|
+
print(f"Failed to connect to FlareSolverr: {e}")
|
|
385
|
+
return False
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def make_raw_pastebin_link(url):
|
|
389
|
+
"""
|
|
390
|
+
Takes a Pastebin URL and ensures it is a raw link.
|
|
391
|
+
If it's not a Pastebin URL, it returns the URL unchanged.
|
|
392
|
+
"""
|
|
393
|
+
# Check if the URL is already a raw Pastebin link
|
|
394
|
+
if re.match(r"https?://(?:www\.)?pastebin\.com/raw/\w+", url):
|
|
395
|
+
return url # Already raw, return as is
|
|
396
|
+
|
|
397
|
+
# Check if the URL is a standard Pastebin link
|
|
398
|
+
pastebin_pattern = r"https?://(?:www\.)?pastebin\.com/(\w+)"
|
|
399
|
+
match = re.match(pastebin_pattern, url)
|
|
400
|
+
|
|
401
|
+
if match:
|
|
402
|
+
paste_id = match.group(1)
|
|
403
|
+
print(f"The link you provided is not a raw Pastebin link. Attempting to convert it to a raw link from {url}...")
|
|
404
|
+
return f"https://pastebin.com/raw/{paste_id}"
|
|
405
|
+
|
|
406
|
+
return url # Not a Pastebin link, return unchanged
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def is_valid_url(url):
|
|
410
|
+
if "https://pastebin.com/raw/eX4Mpl3" in url:
|
|
411
|
+
print("Example URL detected. Please provide a valid URL found on pastebin or another public site!")
|
|
412
|
+
return False
|
|
413
|
+
|
|
414
|
+
parsed = urlparse(url)
|
|
415
|
+
return parsed.scheme in ("http", "https") and bool(parsed.netloc)
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def validate_address(address, name):
|
|
419
|
+
if not address.startswith("http"):
|
|
420
|
+
sys.exit(f"Error: {name} '{address}' is invalid. It must start with 'http'.")
|
|
421
|
+
|
|
422
|
+
colon_count = address.count(":")
|
|
423
|
+
if colon_count < 1 or colon_count > 2:
|
|
424
|
+
sys.exit(
|
|
425
|
+
f"Error: {name} '{address}' is invalid. It must contain 1 or 2 colons, but it has {colon_count}.")
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def extract_allowed_keys(config, section):
|
|
429
|
+
"""
|
|
430
|
+
Extracts allowed keys from the specified section in the configuration.
|
|
431
|
+
|
|
432
|
+
:param config: The configuration dictionary.
|
|
433
|
+
:param section: The section from which to extract keys.
|
|
434
|
+
:return: A list of allowed keys.
|
|
435
|
+
"""
|
|
436
|
+
if section not in config:
|
|
437
|
+
raise ValueError(f"Section '{section}' not found in configuration.")
|
|
438
|
+
return [key for key, *_ in config[section]]
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def extract_kv_pairs(input_text, allowed_keys):
|
|
442
|
+
"""
|
|
443
|
+
Extracts key-value pairs from the given text where keys match allowed_keys.
|
|
444
|
+
|
|
445
|
+
:param input_text: The input text containing key-value pairs.
|
|
446
|
+
:param allowed_keys: A list of allowed two-letter shorthand keys.
|
|
447
|
+
:return: A dictionary of extracted key-value pairs.
|
|
448
|
+
"""
|
|
449
|
+
kv_pattern = re.compile(rf"^({'|'.join(map(re.escape, allowed_keys))})\s*=\s*(.*)$")
|
|
450
|
+
kv_pairs = {}
|
|
451
|
+
|
|
452
|
+
for line in input_text.splitlines():
|
|
453
|
+
match = kv_pattern.match(line.strip())
|
|
454
|
+
if match:
|
|
455
|
+
key, value = match.groups()
|
|
456
|
+
kv_pairs[key] = value
|
|
457
|
+
else:
|
|
458
|
+
print(f"Skipping line because it does not contain any supported hostname: {line}")
|
|
459
|
+
|
|
460
|
+
return kv_pairs
|
quasarr/api/__init__.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
# Quasarr
|
|
3
|
+
# Project by https://github.com/rix1337
|
|
4
|
+
|
|
5
|
+
from bottle import Bottle
|
|
6
|
+
|
|
7
|
+
import quasarr.providers.html_images as images
|
|
8
|
+
from quasarr.api.arr import setup_arr_routes
|
|
9
|
+
from quasarr.api.captcha import setup_captcha_routes
|
|
10
|
+
from quasarr.api.config import setup_config
|
|
11
|
+
from quasarr.api.sponsors_helper import setup_sponsors_helper_routes
|
|
12
|
+
from quasarr.api.statistics import setup_statistics
|
|
13
|
+
from quasarr.providers import shared_state
|
|
14
|
+
from quasarr.providers.html_templates import render_button, render_centered_html
|
|
15
|
+
from quasarr.providers.web_server import Server
|
|
16
|
+
from quasarr.storage.config import Config
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def get_api(shared_state_dict, shared_state_lock):
|
|
20
|
+
shared_state.set_state(shared_state_dict, shared_state_lock)
|
|
21
|
+
|
|
22
|
+
app = Bottle()
|
|
23
|
+
|
|
24
|
+
setup_arr_routes(app)
|
|
25
|
+
setup_captcha_routes(app)
|
|
26
|
+
setup_config(app, shared_state)
|
|
27
|
+
setup_statistics(app, shared_state)
|
|
28
|
+
setup_sponsors_helper_routes(app)
|
|
29
|
+
|
|
30
|
+
@app.get('/')
|
|
31
|
+
def index():
|
|
32
|
+
protected = shared_state.get_db("protected").retrieve_all_titles()
|
|
33
|
+
api_key = Config('API').get('key')
|
|
34
|
+
|
|
35
|
+
captcha_hint = ""
|
|
36
|
+
if protected:
|
|
37
|
+
plural = 's' if len(protected) > 1 else ''
|
|
38
|
+
captcha_hint += f"""
|
|
39
|
+
<div class="section">
|
|
40
|
+
<h2>🔒 Link{plural} waiting for CAPTCHA solution</h2>
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
if not shared_state.values.get("helper_active"):
|
|
44
|
+
captcha_hint += f"""
|
|
45
|
+
<p>
|
|
46
|
+
<a href="https://github.com/rix1337/Quasarr?tab=readme-ov-file#sponsorshelper" target="_blank">
|
|
47
|
+
Sponsors get automated CAPTCHA solutions!
|
|
48
|
+
</a>
|
|
49
|
+
</p>
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
plural = 's' if len(protected) > 1 else ''
|
|
53
|
+
captcha_hint += f"""
|
|
54
|
+
<p>{render_button(f"Solve CAPTCHA{plural}", 'primary', {'onclick': "location.href='/captcha'"})}</p>
|
|
55
|
+
</div>
|
|
56
|
+
<hr>
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
info = f"""
|
|
60
|
+
<h1><img src="{images.logo}" type="image/png" alt="Quasarr logo" class="logo"/>Quasarr</h1>
|
|
61
|
+
|
|
62
|
+
{captcha_hint}
|
|
63
|
+
|
|
64
|
+
<div class="section">
|
|
65
|
+
<h2>📖 Setup Instructions</h2>
|
|
66
|
+
<p>
|
|
67
|
+
<a href="https://github.com/rix1337/Quasarr?tab=readme-ov-file#instructions" target="_blank">
|
|
68
|
+
Refer to the README for detailed instructions.
|
|
69
|
+
</a>
|
|
70
|
+
</p>
|
|
71
|
+
</div>
|
|
72
|
+
|
|
73
|
+
<hr>
|
|
74
|
+
|
|
75
|
+
<div class="section">
|
|
76
|
+
<h2>⚙️ API Configuration</h2>
|
|
77
|
+
<p>Use the URL and API Key below to set up a <strong>Newznab Indexer</strong> and <strong>SABnzbd Download Client</strong> in Radarr/Sonarr:</p>
|
|
78
|
+
|
|
79
|
+
<details id="apiDetails">
|
|
80
|
+
<summary id="apiSummary">Show API Settings</summary>
|
|
81
|
+
<div class="api-settings">
|
|
82
|
+
|
|
83
|
+
<h3>URL</h3>
|
|
84
|
+
<div class="url-wrapper">
|
|
85
|
+
<input id="urlInput" class="copy-input" type="text" readonly value="{shared_state.values['internal_address']}" />
|
|
86
|
+
<button id="copyUrl" class="btn-primary small">Copy</button>
|
|
87
|
+
</div>
|
|
88
|
+
|
|
89
|
+
<h3>API Key</h3>
|
|
90
|
+
<div class="api-key-wrapper">
|
|
91
|
+
<input id="apiKeyInput" class="copy-input" type="password" readonly value="{api_key}" />
|
|
92
|
+
<button id="toggleKey" class="btn-secondary small">Show</button>
|
|
93
|
+
<button id="copyKey" class="btn-primary small">Copy</button>
|
|
94
|
+
</div>
|
|
95
|
+
|
|
96
|
+
<p>{render_button("Regenerate API key", "secondary", {"onclick": "if(confirm('Regenerate API key?')) location.href='/regenerate-api-key';"})}</p>
|
|
97
|
+
</div>
|
|
98
|
+
</details>
|
|
99
|
+
</div>
|
|
100
|
+
|
|
101
|
+
<hr>
|
|
102
|
+
|
|
103
|
+
<div class="section">
|
|
104
|
+
<h2>🔧 Quick Actions</h2>
|
|
105
|
+
<p><button class="btn-primary" onclick="location.href='/hostnames'">Update Hostnames</button></p>
|
|
106
|
+
<p><button class="btn-primary" onclick="location.href='/statistics'">View Statistics</button></p>
|
|
107
|
+
</div>
|
|
108
|
+
|
|
109
|
+
<style>
|
|
110
|
+
.section {{ margin: 20px 0; }}
|
|
111
|
+
.api-settings {{ padding: 15px 0; }}
|
|
112
|
+
hr {{ margin: 25px 0; border: none; border-top: 1px solid #ddd; }}
|
|
113
|
+
details {{ margin: 10px 0; }}
|
|
114
|
+
summary {{
|
|
115
|
+
cursor: pointer;
|
|
116
|
+
padding: 8px 0;
|
|
117
|
+
font-weight: 500;
|
|
118
|
+
}}
|
|
119
|
+
summary:hover {{
|
|
120
|
+
color: #0066cc;
|
|
121
|
+
}}
|
|
122
|
+
</style>
|
|
123
|
+
|
|
124
|
+
<script>
|
|
125
|
+
const urlInput = document.getElementById('urlInput');
|
|
126
|
+
const copyUrlBtn = document.getElementById('copyUrl');
|
|
127
|
+
|
|
128
|
+
if (copyUrlBtn) {{
|
|
129
|
+
copyUrlBtn.onclick = () => {{
|
|
130
|
+
urlInput.select();
|
|
131
|
+
document.execCommand('copy');
|
|
132
|
+
copyUrlBtn.innerText = 'Copied!';
|
|
133
|
+
setTimeout(() => {{ copyUrlBtn.innerText = 'Copy'; }}, 2000);
|
|
134
|
+
}};
|
|
135
|
+
}}
|
|
136
|
+
|
|
137
|
+
const apiInput = document.getElementById('apiKeyInput');
|
|
138
|
+
const toggleBtn = document.getElementById('toggleKey');
|
|
139
|
+
const copyBtn = document.getElementById('copyKey');
|
|
140
|
+
|
|
141
|
+
if (toggleBtn) {{
|
|
142
|
+
toggleBtn.onclick = () => {{
|
|
143
|
+
const isHidden = apiInput.type === 'password';
|
|
144
|
+
apiInput.type = isHidden ? 'text' : 'password';
|
|
145
|
+
toggleBtn.innerText = isHidden ? 'Hide' : 'Show';
|
|
146
|
+
}};
|
|
147
|
+
}}
|
|
148
|
+
|
|
149
|
+
if (copyBtn) {{
|
|
150
|
+
copyBtn.onclick = () => {{
|
|
151
|
+
apiInput.type = 'text';
|
|
152
|
+
apiInput.select();
|
|
153
|
+
document.execCommand('copy');
|
|
154
|
+
copyBtn.innerText = 'Copied!';
|
|
155
|
+
toggleBtn.innerText = 'Hide';
|
|
156
|
+
setTimeout(() => {{ copyBtn.innerText = 'Copy'; }}, 2000);
|
|
157
|
+
}};
|
|
158
|
+
}}
|
|
159
|
+
|
|
160
|
+
// Handle details toggle
|
|
161
|
+
const apiDetails = document.getElementById('apiDetails');
|
|
162
|
+
const apiSummary = document.getElementById('apiSummary');
|
|
163
|
+
|
|
164
|
+
if (apiDetails && apiSummary) {{
|
|
165
|
+
apiDetails.addEventListener('toggle', () => {{
|
|
166
|
+
if (apiDetails.open) {{
|
|
167
|
+
apiSummary.textContent = 'Hide API Settings';
|
|
168
|
+
}} else {{
|
|
169
|
+
apiSummary.textContent = 'Show API Settings';
|
|
170
|
+
}}
|
|
171
|
+
}});
|
|
172
|
+
}}
|
|
173
|
+
</script>
|
|
174
|
+
"""
|
|
175
|
+
return render_centered_html(info)
|
|
176
|
+
|
|
177
|
+
@app.get('/regenerate-api-key')
|
|
178
|
+
def regenerate_api_key():
|
|
179
|
+
api_key = shared_state.generate_api_key()
|
|
180
|
+
return f"""
|
|
181
|
+
<script>
|
|
182
|
+
alert('API key replaced with: {api_key}');
|
|
183
|
+
window.location.href = '/';
|
|
184
|
+
</script>
|
|
185
|
+
"""
|
|
186
|
+
|
|
187
|
+
Server(app, listen='0.0.0.0', port=shared_state.values["port"]).serve_forever()
|