quasarr 2.4.11__py3-none-any.whl → 2.6.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.

@@ -0,0 +1,232 @@
1
+ # -*- coding: utf-8 -*-
2
+ # Quasarr
3
+ # Project by https://github.com/rix1337
4
+
5
+ from quasarr.providers.html_templates import render_button
6
+ from quasarr.storage.config import Config
7
+
8
+
9
+ def get_jdownloader_status(shared_state):
10
+ """Get JDownloader connection status and device name."""
11
+ try:
12
+ device = shared_state.values.get("device")
13
+ jd_connected = device is not None and device is not False
14
+ except:
15
+ jd_connected = False
16
+
17
+ jd_config = Config("JDownloader")
18
+ jd_device = jd_config.get("device") or ""
19
+
20
+ dev_name = jd_device if jd_device else "JDownloader"
21
+ dev_name_safe = dev_name.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")
22
+
23
+ if jd_connected:
24
+ status_text = f"✅ {dev_name_safe} connected"
25
+ status_class = "success"
26
+ elif jd_device:
27
+ status_text = f"❌ {dev_name_safe} disconnected"
28
+ status_class = "error"
29
+ else:
30
+ status_text = "❌ JDownloader disconnected"
31
+ status_class = "error"
32
+
33
+ return {
34
+ "connected": jd_connected,
35
+ "device_name": jd_device,
36
+ "status_text": status_text,
37
+ "status_class": status_class
38
+ }
39
+
40
+
41
+ def get_jdownloader_modal_script():
42
+ """Return the JavaScript for the JDownloader configuration modal."""
43
+ jd_config = Config("JDownloader")
44
+ jd_user = jd_config.get("user") or ""
45
+ jd_pass = jd_config.get("password") or ""
46
+ jd_device = jd_config.get("device") or ""
47
+
48
+ jd_user_js = jd_user.replace("\\", "\\\\").replace("'", "\\'")
49
+ jd_pass_js = jd_pass.replace("\\", "\\\\").replace("'", "\\'")
50
+ jd_device_js = jd_device.replace("\\", "\\\\").replace("'", "\\'")
51
+
52
+ return f"""
53
+ <script>
54
+ function openJDownloaderModal() {{
55
+ var currentUser = '{jd_user_js}';
56
+ var currentPass = '{jd_pass_js}';
57
+ var currentDevice = '{jd_device_js}';
58
+
59
+ var content = `
60
+ <div id="jd-step-1">
61
+ <input type="hidden" id="jd-current-device" value="${{currentDevice}}">
62
+ <p><strong>JDownloader must be running and connected to My JDownloader!</strong></p>
63
+ <div style="margin-bottom: 1rem;">
64
+ <label style="display:block; font-size: 0.875rem;">E-Mail</label>
65
+ <input type="text" id="jd-user" value="${{currentUser}}" placeholder="user@example.org" style="width: 100%; padding: 0.375rem 0.75rem; border: 1px solid #ced4da; border-radius: 0.25rem;">
66
+ </div>
67
+ <div style="margin-bottom: 1rem;">
68
+ <label style="display:block; font-size: 0.875rem;">Password</label>
69
+ <input type="password" id="jd-pass" value="${{currentPass}}" placeholder="Password" style="width: 100%; padding: 0.375rem 0.75rem; border: 1px solid #ced4da; border-radius: 0.25rem;">
70
+ </div>
71
+ <div id="jd-status" style="margin-bottom: 0.5rem; font-size: 0.875rem; min-height: 1.25em;"></div>
72
+ <button class="btn-primary" onclick="verifyJDCredentials()">Verify Credentials</button>
73
+ </div>
74
+
75
+ <div id="jd-step-2" style="display:none;">
76
+ <p>Select your JDownloader instance:</p>
77
+ <div style="margin-bottom: 1rem;">
78
+ <select id="jd-device" style="width: 100%; padding: 0.375rem 0.75rem; border: 1px solid #ced4da; border-radius: 0.25rem;"></select>
79
+ </div>
80
+ <div id="jd-save-status" style="margin-bottom: 0.5rem; font-size: 0.875rem; min-height: 1.25em;"></div>
81
+ <button class="btn-primary" onclick="saveJDSettings()">Save</button>
82
+ </div>
83
+ `;
84
+
85
+ showModal('Configure JDownloader', content, '<button class="btn-secondary" onclick="closeModal()">Close</button>');
86
+ }}
87
+
88
+ function verifyJDCredentials() {{
89
+ var user = document.getElementById('jd-user').value;
90
+ var pass = document.getElementById('jd-pass').value;
91
+ var statusDiv = document.getElementById('jd-status');
92
+
93
+ statusDiv.innerHTML = 'Verifying...';
94
+ statusDiv.style.color = 'var(--secondary, #6c757d)';
95
+
96
+ fetch('/api/jdownloader/verify', {{
97
+ method: 'POST',
98
+ headers: {{ 'Content-Type': 'application/json' }},
99
+ body: JSON.stringify({{ user: user, pass: pass }})
100
+ }})
101
+ .then(response => response.json())
102
+ .then(data => {{
103
+ if (data.success) {{
104
+ var select = document.getElementById('jd-device');
105
+ select.innerHTML = '';
106
+ var currentDevice = document.getElementById('jd-current-device').value;
107
+ data.devices.forEach(device => {{
108
+ var opt = document.createElement('option');
109
+ opt.value = device;
110
+ opt.innerHTML = device;
111
+ if (device === currentDevice) {{
112
+ opt.selected = true;
113
+ }}
114
+ select.appendChild(opt);
115
+ }});
116
+
117
+ document.getElementById('jd-step-1').style.display = 'none';
118
+ document.getElementById('jd-step-2').style.display = 'block';
119
+ }} else {{
120
+ statusDiv.innerHTML = '❌ ' + (data.message || 'Verification failed');
121
+ statusDiv.style.color = '#dc3545';
122
+ }}
123
+ }})
124
+ .catch(error => {{
125
+ statusDiv.innerHTML = '❌ Error: ' + error.message;
126
+ statusDiv.style.color = '#dc3545';
127
+ }});
128
+ }}
129
+
130
+ function saveJDSettings() {{
131
+ var user = document.getElementById('jd-user').value;
132
+ var pass = document.getElementById('jd-pass').value;
133
+ var device = document.getElementById('jd-device').value;
134
+ var statusDiv = document.getElementById('jd-save-status');
135
+
136
+ statusDiv.innerHTML = 'Saving...';
137
+ statusDiv.style.color = 'var(--secondary, #6c757d)';
138
+
139
+ fetch('/api/jdownloader/save', {{
140
+ method: 'POST',
141
+ headers: {{ 'Content-Type': 'application/json' }},
142
+ body: JSON.stringify({{ user: user, pass: pass, device: device }})
143
+ }})
144
+ .then(response => response.json())
145
+ .then(data => {{
146
+ if (data.success) {{
147
+ statusDiv.innerHTML = '✅ ' + data.message;
148
+ statusDiv.style.color = '#198754';
149
+ setTimeout(function() {{
150
+ window.location.reload();
151
+ }}, 1000);
152
+ }} else {{
153
+ statusDiv.innerHTML = '❌ ' + data.message;
154
+ statusDiv.style.color = '#dc3545';
155
+ }}
156
+ }})
157
+ .catch(error => {{
158
+ statusDiv.innerHTML = '❌ Error: ' + error.message;
159
+ statusDiv.style.color = '#dc3545';
160
+ }});
161
+ }}
162
+ </script>
163
+ """
164
+
165
+
166
+ def get_jdownloader_status_pill(shared_state):
167
+ """Return the HTML for the JDownloader status pill."""
168
+ status = get_jdownloader_status(shared_state)
169
+
170
+ return f"""
171
+ <span class="status-pill {status['status_class']}"
172
+ onclick="openJDownloaderModal()"
173
+ style="cursor: pointer;"
174
+ title="Click to configure JDownloader">
175
+ {status['status_text']}
176
+ </span>
177
+ """
178
+
179
+
180
+ def get_jdownloader_disconnected_page(shared_state, back_url="/"):
181
+ """Return a full error page when JDownloader is disconnected."""
182
+ import quasarr.providers.html_images as images
183
+ from quasarr.providers.html_templates import render_centered_html
184
+
185
+ status_pill = get_jdownloader_status_pill(shared_state)
186
+ modal_script = get_jdownloader_modal_script()
187
+
188
+ back_btn = render_button("Back", "secondary", {"onclick": f"location.href='{back_url}'"})
189
+
190
+ content = f'''
191
+ <h1><img src="{images.logo}" type="image/png" alt="Quasarr logo" class="logo"/>Quasarr</h1>
192
+ <div class="status-bar">
193
+ {status_pill}
194
+ </div>
195
+ <p>{back_btn}</p>
196
+ <style>
197
+ .status-pill {{
198
+ font-size: 0.9em;
199
+ padding: 8px 16px;
200
+ border-radius: 0.5rem;
201
+ font-weight: 500;
202
+ transition: transform 0.1s ease;
203
+ }}
204
+ .status-pill:hover {{
205
+ transform: scale(1.05);
206
+ }}
207
+ .status-pill.success {{
208
+ background: var(--status-success-bg, #e8f5e9);
209
+ color: var(--status-success-color, #2e7d32);
210
+ border: 1px solid var(--status-success-border, #a5d6a7);
211
+ }}
212
+ .status-pill.error {{
213
+ background: var(--status-error-bg, #ffebee);
214
+ color: var(--status-error-color, #c62828);
215
+ border: 1px solid var(--status-error-border, #ef9a9a);
216
+ }}
217
+ /* Dark mode */
218
+ @media (prefers-color-scheme: dark) {{
219
+ :root {{
220
+ --status-success-bg: #1c4532;
221
+ --status-success-color: #68d391;
222
+ --status-success-border: #276749;
223
+ --status-error-bg: #3d2d2d;
224
+ --status-error-color: #fc8181;
225
+ --status-error-border: #c53030;
226
+ }}
227
+ }}
228
+ </style>
229
+ {modal_script}
230
+ '''
231
+
232
+ return render_centered_html(content)
@@ -3,6 +3,7 @@
3
3
  # Project by https://github.com/rix1337
4
4
 
5
5
  import quasarr.providers.html_images as images
6
+ from quasarr.api.jdownloader import get_jdownloader_disconnected_page
6
7
  from quasarr.downloads.packages import delete_package, get_packages
7
8
  from quasarr.providers import shared_state
8
9
  from quasarr.providers.html_templates import render_button, render_centered_html
@@ -349,18 +350,7 @@ def setup_packages_routes(app):
349
350
  device = None
350
351
 
351
352
  if not device:
352
- back_btn = render_button(
353
- "Back", "secondary", {"onclick": "location.href='/'"}
354
- )
355
- return render_centered_html(f'''
356
- <h1><img src="{images.logo}" type="image/png" alt="Quasarr logo" class="logo"/>Quasarr</h1>
357
- <div class="status-bar">
358
- <span class="status-pill error">
359
- ❌ JDownloader disconnected
360
- </span>
361
- </div>
362
- <p>{back_btn}</p>
363
- ''')
353
+ return get_jdownloader_disconnected_page(shared_state)
364
354
 
365
355
  # Check for delete status from redirect
366
356
  deleted = request.query.get("deleted")
@@ -16,6 +16,7 @@ from quasarr.downloads.sources.dl import get_dl_download_links
16
16
  from quasarr.downloads.sources.dt import get_dt_download_links
17
17
  from quasarr.downloads.sources.dw import get_dw_download_links
18
18
  from quasarr.downloads.sources.he import get_he_download_links
19
+ from quasarr.downloads.sources.hs import get_hs_download_links
19
20
  from quasarr.downloads.sources.mb import get_mb_download_links
20
21
  from quasarr.downloads.sources.nk import get_nk_download_links
21
22
  from quasarr.downloads.sources.nx import get_nx_download_links
@@ -57,6 +58,7 @@ SOURCE_GETTERS = {
57
58
  "dt": get_dt_download_links,
58
59
  "dw": get_dw_download_links,
59
60
  "he": get_he_download_links,
61
+ "hs": get_hs_download_links,
60
62
  "mb": get_mb_download_links,
61
63
  "nk": get_nk_download_links,
62
64
  "nx": get_nx_download_links,
@@ -0,0 +1,131 @@
1
+ # -*- coding: utf-8 -*-
2
+ # Quasarr
3
+ # Project by https://github.com/rix1337
4
+
5
+ import re
6
+
7
+ import requests
8
+ from bs4 import BeautifulSoup
9
+
10
+ from quasarr.providers.hostname_issues import mark_hostname_issue
11
+ from quasarr.providers.log import debug, info
12
+
13
+ hostname = "hs"
14
+
15
+ FILECRYPT_REGEX = re.compile(
16
+ r"https?://(?:www\.)?filecrypt\.(?:cc|co|to)/[Cc]ontainer/[A-Za-z0-9]+\.html", re.I
17
+ )
18
+ AFFILIATE_REGEX = re.compile(r"af\.php\?v=([a-zA-Z0-9]+)")
19
+
20
+
21
+ def normalize_mirror_name(name):
22
+ """Normalize mirror names - ddlto/ddl.to -> ddownload"""
23
+ if not name:
24
+ return None
25
+ name_lower = name.lower().strip()
26
+ if "ddlto" in name_lower or "ddl.to" in name_lower or "ddownload" in name_lower:
27
+ return "ddownload"
28
+ if "rapidgator" in name_lower:
29
+ return "rapidgator"
30
+ if "katfile" in name_lower:
31
+ return "katfile"
32
+ return name_lower
33
+
34
+
35
+ def get_hs_download_links(shared_state, url, mirror, title, password):
36
+ """
37
+ KEEP THE SIGNATURE EVEN IF SOME PARAMETERS ARE UNUSED!
38
+
39
+ HS handler - extracts filecrypt download links from release pages.
40
+ The site structure pairs affiliate links (indicating mirror) with filecrypt links.
41
+ """
42
+ headers = {"User-Agent": shared_state.values["user_agent"]}
43
+
44
+ mirror_lower = mirror.lower() if mirror else None
45
+ links = []
46
+
47
+ try:
48
+ r = requests.get(url, headers=headers, timeout=30)
49
+ r.raise_for_status()
50
+ soup = BeautifulSoup(r.text, "html.parser")
51
+
52
+ # Find all links in the page
53
+ all_links = soup.find_all("a", href=True)
54
+
55
+ # Strategy: Build mirror detection from multiple sources
56
+ # 1. Text labels on filecrypt links (most reliable)
57
+ # 2. Affiliate links preceding filecrypt links (fallback)
58
+
59
+ # First pass: detect mirrors from link text labels
60
+ text_labeled_mirrors = {} # url -> mirror_name
61
+ for link in all_links:
62
+ href = link.get("href", "")
63
+ if FILECRYPT_REGEX.match(href):
64
+ link_text = link.get_text(strip=True).lower()
65
+ detected_mirror = None
66
+ if "ddownload" in link_text or "ddl" in link_text:
67
+ detected_mirror = "ddownload"
68
+ elif "rapidgator" in link_text:
69
+ detected_mirror = "rapidgator"
70
+ elif "katfile" in link_text:
71
+ detected_mirror = "katfile"
72
+ if detected_mirror:
73
+ text_labeled_mirrors[href] = detected_mirror
74
+
75
+ # Second pass: track affiliate links for fallback
76
+ affiliate_mirrors = {} # url -> mirror_name (from preceding affiliate)
77
+ current_mirror = None
78
+ for link in all_links:
79
+ href = link.get("href", "")
80
+
81
+ # Check if this is an affiliate link (indicates mirror name)
82
+ aff_match = AFFILIATE_REGEX.search(href)
83
+ if aff_match:
84
+ current_mirror = normalize_mirror_name(aff_match.group(1))
85
+ continue
86
+
87
+ # Check if this is a filecrypt link
88
+ if FILECRYPT_REGEX.match(href):
89
+ if current_mirror and href not in affiliate_mirrors:
90
+ affiliate_mirrors[href] = current_mirror
91
+ current_mirror = None # Reset for next pair
92
+
93
+ # Combine results: text labels take priority over affiliate tracking
94
+ filecrypt_mirrors = []
95
+ seen_urls = set()
96
+ for link in all_links:
97
+ href = link.get("href", "")
98
+ if FILECRYPT_REGEX.match(href) and href not in seen_urls:
99
+ seen_urls.add(href)
100
+ # Priority: text label > affiliate > "filecrypt"
101
+ mirror_name = (
102
+ text_labeled_mirrors.get(href)
103
+ or affiliate_mirrors.get(href)
104
+ or "filecrypt"
105
+ )
106
+ filecrypt_mirrors.append((href, mirror_name))
107
+
108
+ # Filter by requested mirror and deduplicate
109
+ seen_urls = set()
110
+ for fc_url, fc_mirror in filecrypt_mirrors:
111
+ if fc_url in seen_urls:
112
+ continue
113
+ seen_urls.add(fc_url)
114
+
115
+ # Filter by requested mirror if specified
116
+ if mirror_lower:
117
+ if mirror_lower != fc_mirror:
118
+ debug(f"Skipping {fc_mirror} link (requested mirror: {mirror})")
119
+ continue
120
+
121
+ # Store [url, mirror_name] - mirror_name is used by CAPTCHA page for filtering
122
+ links.append([fc_url, fc_mirror])
123
+
124
+ if not links:
125
+ debug(f"No filecrypt links found on {url} for {title}")
126
+
127
+ except Exception as e:
128
+ info(f"Error loading HS download links: {e}")
129
+ mark_hostname_issue(hostname, "download", str(e))
130
+
131
+ return {"links": links}
@@ -143,6 +143,7 @@ def render_centered_html(inner_content, footer_content=""):
143
143
  justify-content: center;
144
144
  margin-bottom: 0.5rem;
145
145
  font-size: 2rem;
146
+ cursor: pointer;
146
147
  }
147
148
  .logo {
148
149
  width: 48px;
@@ -333,6 +334,16 @@ def render_centered_html(inner_content, footer_content=""):
333
334
  justify-content: flex-end;
334
335
  }
335
336
  </style>
337
+ <script>
338
+ document.addEventListener('DOMContentLoaded', function() {
339
+ const h1 = document.querySelector('h1');
340
+ if (h1) {
341
+ h1.onclick = function() {
342
+ window.location.href = '/';
343
+ };
344
+ }
345
+ });
346
+ </script>
336
347
  </head>"""
337
348
  )
338
349
 
@@ -417,7 +428,7 @@ def render_button(text, button_type="primary", attributes=None):
417
428
 
418
429
  def render_form(header, form="", script="", footer_content=""):
419
430
  content = f'''
420
- <h1><img src="{images.logo}" type="image/png" alt="Quasarr logo" class="logo"/>Quasarr</h1>
431
+ <h1 onclick="window.location.href='/'"><img src="{images.logo}" type="image/png" alt="Quasarr logo" class="logo"/>Quasarr</h1>
421
432
  <h2>{header}</h2>
422
433
  {form}
423
434
  {script}
@@ -446,7 +457,7 @@ def render_success(message, timeout=10, optional_text=""):
446
457
  }}, 1000);
447
458
  </script>
448
459
  """
449
- content = f'''<h1><img src="{images.logo}" type="image/png" alt="Quasarr logo" class="logo"/>Quasarr</h1>
460
+ content = f'''<h1 onclick="window.location.href='/'"><img src="{images.logo}" type="image/png" alt="Quasarr logo" class="logo"/>Quasarr</h1>
450
461
  <h2>{message}</h2>
451
462
  {optional_text}
452
463
  {button_html}
@@ -459,7 +470,7 @@ def render_fail(message):
459
470
  button_html = render_button(
460
471
  "Back", "secondary", {"onclick": "window.location.href='/'"}
461
472
  )
462
- return render_centered_html(f"""<h1><img src="{images.logo}" type="image/png" alt="Quasarr logo" class="logo"/>Quasarr</h1>
473
+ return render_centered_html(f"""<h1 onclick="window.location.href='/'"><img src="{images.logo}" type="image/png" alt="Quasarr logo" class="logo"/>Quasarr</h1>
463
474
  <h2>{message}</h2>
464
475
  {button_html}
465
476
  """)
@@ -371,6 +371,7 @@ def fetch_via_requests_session(
371
371
  target_url: str,
372
372
  post_data: dict = None,
373
373
  timeout: int = 30,
374
+ year: int = None,
374
375
  ):
375
376
  """
376
377
  - method: "GET" or "POST"
@@ -383,6 +384,9 @@ def fetch_via_requests_session(
383
384
  f"{hostname}: site not usable (login skipped or no credentials)"
384
385
  )
385
386
 
387
+ if year:
388
+ sess.cookies["filter"] = f'{{"year":{{"from":{year},"to":{year}}}}}'
389
+
386
390
  # Execute request
387
391
  if method.upper() == "GET":
388
392
  r = sess.get(target_url, timeout=timeout)
@@ -171,19 +171,18 @@ def set_device_from_config():
171
171
 
172
172
  def check_device(device):
173
173
  try:
174
- valid = (
175
- isinstance(device, (type, Jddevice))
176
- and device.downloadcontroller.get_current_state()
177
- )
178
- except (
179
- AttributeError,
180
- KeyError,
181
- TokenExpiredException,
182
- RequestTimeoutException,
183
- MYJDException,
184
- ):
185
- valid = False
186
- return valid
174
+ if not isinstance(device, (type, Jddevice)):
175
+ return False
176
+
177
+ # Trigger a network request to verify connectivity
178
+ # get_current_state() performs an API call to JDownloader
179
+ state = device.downloadcontroller.get_current_state()
180
+
181
+ if state:
182
+ return True
183
+ return False
184
+ except Exception:
185
+ return False
187
186
 
188
187
 
189
188
  def connect_device():
@@ -627,11 +626,12 @@ def search_string_in_sanitized_title(search_string, title):
627
626
  sanitized_search_string = sanitize_string(search_string)
628
627
  sanitized_title = sanitize_string(title)
629
628
 
629
+ search_regex = r"\b.+\b".join(
630
+ [re.escape(s) for s in sanitized_search_string.split(" ")]
631
+ )
630
632
  # Use word boundaries to ensure full word/phrase match
631
- if re.search(rf"\b{re.escape(sanitized_search_string)}\b", sanitized_title):
632
- debug(
633
- f"Matched search string: {sanitized_search_string} with title: {sanitized_title}"
634
- )
633
+ if re.search(rf"\b{search_regex}\b", sanitized_title):
634
+ debug(f"Matched search string: {search_regex} with title: {sanitized_title}")
635
635
  return True
636
636
  else:
637
637
  debug(
@@ -5,7 +5,7 @@
5
5
  import re
6
6
  import sys
7
7
 
8
- __version__ = "2.4.11"
8
+ __version__ = "2.6.0"
9
9
 
10
10
 
11
11
  def get_version():