quasarr 1.20.6__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of quasarr might be problematic. Click here for more details.

Files changed (72) hide show
  1. quasarr/__init__.py +460 -0
  2. quasarr/api/__init__.py +187 -0
  3. quasarr/api/arr/__init__.py +373 -0
  4. quasarr/api/captcha/__init__.py +1075 -0
  5. quasarr/api/config/__init__.py +23 -0
  6. quasarr/api/sponsors_helper/__init__.py +166 -0
  7. quasarr/api/statistics/__init__.py +196 -0
  8. quasarr/downloads/__init__.py +267 -0
  9. quasarr/downloads/linkcrypters/__init__.py +0 -0
  10. quasarr/downloads/linkcrypters/al.py +237 -0
  11. quasarr/downloads/linkcrypters/filecrypt.py +444 -0
  12. quasarr/downloads/linkcrypters/hide.py +123 -0
  13. quasarr/downloads/packages/__init__.py +467 -0
  14. quasarr/downloads/sources/__init__.py +0 -0
  15. quasarr/downloads/sources/al.py +697 -0
  16. quasarr/downloads/sources/by.py +106 -0
  17. quasarr/downloads/sources/dd.py +76 -0
  18. quasarr/downloads/sources/dj.py +7 -0
  19. quasarr/downloads/sources/dt.py +66 -0
  20. quasarr/downloads/sources/dw.py +65 -0
  21. quasarr/downloads/sources/he.py +112 -0
  22. quasarr/downloads/sources/mb.py +47 -0
  23. quasarr/downloads/sources/nk.py +51 -0
  24. quasarr/downloads/sources/nx.py +105 -0
  25. quasarr/downloads/sources/sf.py +159 -0
  26. quasarr/downloads/sources/sj.py +7 -0
  27. quasarr/downloads/sources/sl.py +90 -0
  28. quasarr/downloads/sources/wd.py +110 -0
  29. quasarr/providers/__init__.py +0 -0
  30. quasarr/providers/cloudflare.py +204 -0
  31. quasarr/providers/html_images.py +20 -0
  32. quasarr/providers/html_templates.py +241 -0
  33. quasarr/providers/imdb_metadata.py +142 -0
  34. quasarr/providers/log.py +19 -0
  35. quasarr/providers/myjd_api.py +917 -0
  36. quasarr/providers/notifications.py +124 -0
  37. quasarr/providers/obfuscated.py +51 -0
  38. quasarr/providers/sessions/__init__.py +0 -0
  39. quasarr/providers/sessions/al.py +286 -0
  40. quasarr/providers/sessions/dd.py +78 -0
  41. quasarr/providers/sessions/nx.py +76 -0
  42. quasarr/providers/shared_state.py +826 -0
  43. quasarr/providers/statistics.py +154 -0
  44. quasarr/providers/version.py +118 -0
  45. quasarr/providers/web_server.py +49 -0
  46. quasarr/search/__init__.py +153 -0
  47. quasarr/search/sources/__init__.py +0 -0
  48. quasarr/search/sources/al.py +448 -0
  49. quasarr/search/sources/by.py +203 -0
  50. quasarr/search/sources/dd.py +135 -0
  51. quasarr/search/sources/dj.py +213 -0
  52. quasarr/search/sources/dt.py +265 -0
  53. quasarr/search/sources/dw.py +214 -0
  54. quasarr/search/sources/fx.py +223 -0
  55. quasarr/search/sources/he.py +196 -0
  56. quasarr/search/sources/mb.py +195 -0
  57. quasarr/search/sources/nk.py +188 -0
  58. quasarr/search/sources/nx.py +197 -0
  59. quasarr/search/sources/sf.py +374 -0
  60. quasarr/search/sources/sj.py +213 -0
  61. quasarr/search/sources/sl.py +246 -0
  62. quasarr/search/sources/wd.py +208 -0
  63. quasarr/storage/__init__.py +0 -0
  64. quasarr/storage/config.py +163 -0
  65. quasarr/storage/setup.py +458 -0
  66. quasarr/storage/sqlite_database.py +80 -0
  67. quasarr-1.20.6.dist-info/METADATA +304 -0
  68. quasarr-1.20.6.dist-info/RECORD +72 -0
  69. quasarr-1.20.6.dist-info/WHEEL +5 -0
  70. quasarr-1.20.6.dist-info/entry_points.txt +2 -0
  71. quasarr-1.20.6.dist-info/licenses/LICENSE +21 -0
  72. quasarr-1.20.6.dist-info/top_level.txt +1 -0
@@ -0,0 +1,23 @@
1
+ # -*- coding: utf-8 -*-
2
+ # Quasarr
3
+ # Project by https://github.com/rix1337
4
+
5
+ from quasarr.providers.html_templates import render_form
6
+ from quasarr.providers.html_templates import render_button
7
+ from quasarr.storage.setup import hostname_form_html, save_hostnames
8
+
9
+
10
+ def setup_config(app, shared_state):
11
+ @app.get('/hostnames')
12
+ def hostnames_ui():
13
+ message = """<p>
14
+ At least one hostname must be kept.
15
+ </p>"""
16
+ back_button = f'''<p>
17
+ {render_button("Back", "secondary", {"onclick": "location.href='/'"})}
18
+ </p>'''
19
+ return render_form("Hostnames", hostname_form_html(shared_state, message) + back_button)
20
+
21
+ @app.post("/api/hostnames")
22
+ def hostnames_api():
23
+ return save_hostnames(shared_state, timeout=1, first_run=False)
@@ -0,0 +1,166 @@
1
+ # -*- coding: utf-8 -*-
2
+ # Quasarr
3
+ # Project by https://github.com/rix1337
4
+
5
+ import json
6
+
7
+ from bottle import request, abort
8
+
9
+ from quasarr.downloads import fail
10
+ from quasarr.providers import shared_state
11
+ from quasarr.providers.log import info
12
+ from quasarr.providers.notifications import send_discord_message
13
+ from quasarr.providers.statistics import StatsHelper
14
+
15
+
16
+ def setup_sponsors_helper_routes(app):
17
+ @app.get("/sponsors_helper/api/to_decrypt/")
18
+ def to_decrypt_api():
19
+ try:
20
+ if not shared_state.values["helper_active"]:
21
+ shared_state.update("helper_active", True)
22
+ info(f"Sponsor status activated successfully")
23
+
24
+ protected = shared_state.get_db("protected").retrieve_all_titles()
25
+ if not protected:
26
+ return abort(404, "No encrypted packages found")
27
+
28
+ # Find the first package without a "session" key
29
+ selected_package = None
30
+ for package in protected:
31
+ data = json.loads(package[1])
32
+ if "session" not in data:
33
+ selected_package = (package[0], data)
34
+ break
35
+
36
+ if not selected_package:
37
+ return abort(404, "No valid packages without session found")
38
+
39
+ package_id, data = selected_package
40
+ title = data["title"]
41
+ links = data["links"]
42
+ mirror = None if (mirror := data.get('mirror')) == "None" else mirror
43
+ password = data["password"]
44
+
45
+ rapid = [ln for ln in links if "rapidgator" in ln[1].lower()]
46
+ others = [ln for ln in links if "rapidgator" not in ln[1].lower()]
47
+ prioritized_links = rapid + others
48
+
49
+ return {
50
+ "to_decrypt": {
51
+ "name": title,
52
+ "id": package_id,
53
+ "url": prioritized_links,
54
+ "mirror": mirror,
55
+ "password": password,
56
+ "max_attempts": 3
57
+ }
58
+ }
59
+ except Exception as e:
60
+ return abort(500, str(e))
61
+
62
+ @app.post("/sponsors_helper/api/to_download/")
63
+ def to_download_api():
64
+ try:
65
+ data = request.json
66
+ title = data.get('name')
67
+ package_id = data.get('package_id')
68
+ download_links = data.get('urls')
69
+ password = data.get('password')
70
+
71
+ info(f"Received {len(download_links)} download links for {title}")
72
+
73
+ if download_links:
74
+ downloaded = shared_state.download_package(download_links, title, password, package_id)
75
+ if downloaded:
76
+ StatsHelper(shared_state).increment_package_with_links(download_links)
77
+ StatsHelper(shared_state).increment_captcha_decryptions_automatic()
78
+ shared_state.get_db("protected").delete(package_id)
79
+ send_discord_message(shared_state, title=title, case="solved")
80
+ info(f"Download successfully started for {title}")
81
+ return f"Downloaded {len(download_links)} download links for {title}"
82
+ else:
83
+ info(f"Download failed for {title}")
84
+
85
+ except Exception as e:
86
+ info(f"Error decrypting: {e}")
87
+
88
+ StatsHelper(shared_state).increment_failed_decryptions_automatic()
89
+ return abort(500, "Failed") #
90
+
91
+ @app.post("/sponsors_helper/api/to_replace/")
92
+ def to_replace_api():
93
+ try:
94
+ data = request.json
95
+ name = data.get('name')
96
+ package_id = data.get('package_id')
97
+ password = data.get('password')
98
+ replace_url = data.get('replace_url')
99
+ mirror = data.get('mirror')
100
+ session = data.get('session')
101
+
102
+ if not all([name, package_id, replace_url, mirror, session]):
103
+ info("Missing required replacement data")
104
+ return {"error": "Missing required replacement data"}, 400
105
+
106
+ if password is None:
107
+ password = ""
108
+
109
+ blob = json.dumps(
110
+ {
111
+ "title": name,
112
+ "links": [replace_url, mirror],
113
+ "size_mb": 0,
114
+ "password": password,
115
+ "mirror": mirror,
116
+ "session": session
117
+ })
118
+
119
+ shared_state.get_db("protected").update_store(package_id, blob)
120
+
121
+ info(f"Another CAPTCHA solution is required for {mirror} link: {replace_url}")
122
+
123
+ StatsHelper(shared_state).increment_captcha_decryptions_automatic()
124
+
125
+ return f"Replacement link stored for {name}"
126
+
127
+ except Exception as e:
128
+ StatsHelper(shared_state).increment_failed_decryptions_automatic()
129
+ info(f"Error handling replacement: {e}")
130
+ return {"error": str(e)}, 500
131
+
132
+ @app.delete("/sponsors_helper/api/to_failed/")
133
+ def move_to_failed_api():
134
+ try:
135
+ StatsHelper(shared_state).increment_failed_decryptions_automatic()
136
+
137
+ data = request.json
138
+ package_id = data.get('package_id')
139
+
140
+ data = json.loads(shared_state.get_db("protected").retrieve(package_id))
141
+ title = data.get('title')
142
+
143
+ if package_id:
144
+ info(f'Marking package "{title}" with ID "{package_id}" as failed')
145
+ failed = fail(title, package_id, shared_state, reason="Too many failed attempts by SponsorsHelper")
146
+ if failed:
147
+ shared_state.get_db("protected").delete(package_id)
148
+ send_discord_message(shared_state, title=title, case="failed")
149
+ return f'Package "{title}" with ID "{package_id} marked as failed!"'
150
+ except Exception as e:
151
+ info(f"Error moving to failed: {e}")
152
+
153
+ return abort(500, "Failed")
154
+
155
+ @app.put("/sponsors_helper/api/set_sponsor_status/")
156
+ def activate_sponsor_status():
157
+ try:
158
+ data = request.body.read().decode("utf-8")
159
+ payload = json.loads(data)
160
+ if payload["activate"]:
161
+ shared_state.update("helper_active", True)
162
+ info(f"Sponsor status activated successfully")
163
+ return "Sponsor status activated successfully!"
164
+ except:
165
+ pass
166
+ return abort(500, "Failed")
@@ -0,0 +1,196 @@
1
+ # -*- coding: utf-8 -*-
2
+ # Quasarr
3
+ # Project by https://github.com/rix1337
4
+
5
+ import quasarr.providers.html_images as images
6
+ from quasarr.providers.html_templates import render_button, render_centered_html
7
+ from quasarr.providers.statistics import StatsHelper
8
+
9
+
10
+ def setup_statistics(app, shared_state):
11
+ @app.get('/statistics')
12
+ def statistics():
13
+ stats_helper = StatsHelper(shared_state)
14
+ stats = stats_helper.get_stats()
15
+
16
+ stats_html = f"""
17
+ <h1><img src="{images.logo}" type="image/png" alt="Quasarr logo" class="logo"/>Quasarr</h1>
18
+ <h2>Statistics</h2>
19
+ <div class="stats-container">
20
+ <h3>📊 Overview</h3>
21
+ <div class="stats-grid compact">
22
+ <div class="stat-card highlight">
23
+ <h3>📦 Total Download Attempts</h3>
24
+ <div class="stat-value">{stats['total_download_attempts']}</div>
25
+ <div class="stat-subtitle">Success Rate: {stats['download_success_rate']:.1f}%</div>
26
+ </div>
27
+ <div class="stat-card highlight">
28
+ <h3>🔐 Total CAPTCHA Decryptions</h3>
29
+ <div class="stat-value">{stats['total_captcha_decryptions']}</div>
30
+ <div class="stat-subtitle">Success Rate: {stats['decryption_success_rate']:.1f}%</div>
31
+ </div>
32
+ </div>
33
+
34
+ <h3>📥 Downloads</h3>
35
+ <div class="stats-grid compact">
36
+ <div class="stat-card">
37
+ <h3>✅ Packages Downloaded</h3>
38
+ <div class="stat-value">{stats['packages_downloaded']}</div>
39
+ </div>
40
+ <div class="stat-card">
41
+ <h3>⚙️ Links Processed</h3>
42
+ <div class="stat-value">{stats['links_processed']}</div>
43
+ </div>
44
+ <div class="stat-card">
45
+ <h3>❌ Failed Downloads</h3>
46
+ <div class="stat-value">{stats['failed_downloads']}</div>
47
+ </div>
48
+ <div class="stat-card">
49
+ <h3>🔗 Average Links per Package</h3>
50
+ <div class="stat-value">{stats['average_links_per_package']:.1f}</div>
51
+ </div>
52
+ </div>
53
+
54
+ <h3>🧩 CAPTCHAs</h3>
55
+ <div class="stats-grid compact">
56
+ <div class="stat-card">
57
+ <h3>🤖 Automatic Decryptions</h3>
58
+ <div class="stat-value">{stats['captcha_decryptions_automatic']}</div>
59
+ <div class="stat-subtitle">Success Rate: {stats['automatic_decryption_success_rate']:.1f}%</div>
60
+ </div>
61
+ <div class="stat-card">
62
+ <h3>👤 Manual Decryptions</h3>
63
+ <div class="stat-value">{stats['captcha_decryptions_manual']}</div>
64
+ <div class="stat-subtitle">Success Rate: {stats['manual_decryption_success_rate']:.1f}%</div>
65
+ </div>
66
+ <div class="stat-card">
67
+ <h3>⛔ Failed Auto Decryptions</h3>
68
+ <div class="stat-value">{stats['failed_decryptions_automatic']}</div>
69
+ </div>
70
+ <div class="stat-card">
71
+ <h3>🚫 Failed Manual Decryptions</h3>
72
+ <div class="stat-value">{stats['failed_decryptions_manual']}</div>
73
+ </div>
74
+ </div>
75
+ </div>
76
+
77
+ <p>
78
+ {render_button("Back", "secondary", {"onclick": "location.href='/'"})}
79
+ </p>
80
+
81
+ <style>
82
+ .stats-container {{
83
+ max-width: 1000px;
84
+ margin: 0 auto;
85
+ }}
86
+
87
+ .stats-grid {{
88
+ display: grid;
89
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
90
+ gap: 15px;
91
+ margin: 15px 0;
92
+ }}
93
+
94
+ .stats-grid.compact {{
95
+ gap: 12px;
96
+ margin: 12px 0;
97
+ }}
98
+
99
+ .stat-card {{
100
+ background: var(--card-bg, #f8f9fa);
101
+ border: 1px solid var(--card-border, #dee2e6);
102
+ border-radius: 8px;
103
+ padding: 15px;
104
+ text-align: center;
105
+ transition: transform 0.2s, box-shadow 0.2s;
106
+ }}
107
+
108
+ .stat-card:hover {{
109
+ transform: translateY(-2px);
110
+ box-shadow: 0 4px 12px var(--card-shadow, rgba(0,0,0,0.1));
111
+ }}
112
+
113
+ .stat-card.highlight {{
114
+ background: var(--highlight-bg, #e3f2fd);
115
+ border-color: var(--highlight-border, #2196f3);
116
+ }}
117
+
118
+ .stat-card h3 {{
119
+ margin: 0 0 8px 0;
120
+ font-size: 13px;
121
+ color: var(--text-muted, #666);
122
+ text-transform: uppercase;
123
+ letter-spacing: 0.5px;
124
+ }}
125
+
126
+ .stat-value {{
127
+ font-size: 24px;
128
+ font-weight: bold;
129
+ color: var(--text-primary, #333);
130
+ margin: 8px 0;
131
+ }}
132
+
133
+ .stat-subtitle {{
134
+ font-size: 11px;
135
+ color: var(--text-secondary, #888);
136
+ margin-top: 4px;
137
+ }}
138
+
139
+ h3 {{
140
+ color: var(--heading-color, #444);
141
+ padding-bottom: 8px;
142
+ margin-top: 25px;
143
+ margin-bottom: 15px;
144
+ }}
145
+
146
+ /* Dark mode styles */
147
+ @media (prefers-color-scheme: dark) {{
148
+ :root {{
149
+ --card-border: #4a5568;
150
+ --card-shadow: rgba(0,0,0,0.3);
151
+ --highlight-bg: #1a365d;
152
+ --highlight-border: #3182ce;
153
+ --text-muted: #a0aec0;
154
+ --text-primary: #f7fafc;
155
+ --text-secondary: #cbd5e0;
156
+ --heading-color: #e2e8f0;
157
+ --border-color: #4a5568;
158
+ }}
159
+ }}
160
+
161
+ /* Force dark mode styles for applications that don't support prefers-color-scheme */
162
+ body.dark-mode .stat-card {{
163
+ background: #2d3748;
164
+ border-color: #4a5568;
165
+ color: #f7fafc;
166
+ }}
167
+
168
+ body.dark-mode .stat-card:hover {{
169
+ box-shadow: 0 4px 12px rgba(0,0,0,0.3);
170
+ }}
171
+
172
+ body.dark-mode .stat-card.highlight {{
173
+ background: #1a365d;
174
+ border-color: #3182ce;
175
+ }}
176
+
177
+ body.dark-mode .stat-card h3 {{
178
+ color: #a0aec0;
179
+ }}
180
+
181
+ body.dark-mode .stat-value {{
182
+ color: #f7fafc;
183
+ }}
184
+
185
+ body.dark-mode .stat-subtitle {{
186
+ color: #cbd5e0;
187
+ }}
188
+
189
+ body.dark-mode h2 {{
190
+ color: #e2e8f0;
191
+ border-bottom-color: #4a5568;
192
+ }}
193
+ </style>
194
+ """
195
+
196
+ return render_centered_html(stats_html)
@@ -0,0 +1,267 @@
1
+ # -*- coding: utf-8 -*-
2
+ # Quasarr
3
+ # Project by https://github.com/rix1337
4
+ #
5
+ # Special note: The signatures of all handlers must stay the same so we can neatly call them in download()
6
+ # Same is true for every get_xx_download_links() function in sources/xx.py
7
+
8
+ import json
9
+
10
+ from quasarr.downloads.linkcrypters.hide import decrypt_links_if_hide
11
+ from quasarr.downloads.sources.al import get_al_download_links
12
+ from quasarr.downloads.sources.by import get_by_download_links
13
+ from quasarr.downloads.sources.dd import get_dd_download_links
14
+ from quasarr.downloads.sources.dj import get_dj_download_links
15
+ from quasarr.downloads.sources.dt import get_dt_download_links
16
+ from quasarr.downloads.sources.dw import get_dw_download_links
17
+ from quasarr.downloads.sources.he import get_he_download_links
18
+ from quasarr.downloads.sources.mb import get_mb_download_links
19
+ from quasarr.downloads.sources.nk import get_nk_download_links
20
+ from quasarr.downloads.sources.nx import get_nx_download_links
21
+ from quasarr.downloads.sources.sf import get_sf_download_links, resolve_sf_redirect
22
+ from quasarr.downloads.sources.sj import get_sj_download_links
23
+ from quasarr.downloads.sources.sl import get_sl_download_links
24
+ from quasarr.downloads.sources.wd import get_wd_download_links
25
+ from quasarr.providers.log import info
26
+ from quasarr.providers.notifications import send_discord_message
27
+ from quasarr.providers.statistics import StatsHelper
28
+
29
+
30
+ def handle_unprotected(shared_state, title, password, package_id, imdb_id, url,
31
+ mirror=None, size_mb=None, links=None, func=None, label=""):
32
+ if func:
33
+ links = func(shared_state, url, mirror, title)
34
+
35
+ if links:
36
+ info(f"Decrypted {len(links)} download links for {title}")
37
+ send_discord_message(shared_state, title=title, case="unprotected", imdb_id=imdb_id, source=url)
38
+ added = shared_state.download_package(links, title, password, package_id)
39
+ if not added:
40
+ fail(title, package_id, shared_state,
41
+ reason=f'Failed to add {len(links)} links for "{title}" to linkgrabber')
42
+ return {"success": False, "title": title}
43
+ else:
44
+ fail(title, package_id, shared_state,
45
+ reason=f'Offline / no links found for "{title}" on {label} - "{url}"')
46
+ return {"success": False, "title": title}
47
+
48
+ StatsHelper(shared_state).increment_package_with_links(links)
49
+ return {"success": True, "title": title}
50
+
51
+
52
+ def handle_protected(shared_state, title, password, package_id, imdb_id, url,
53
+ mirror=None, size_mb=None, func=None, label=""):
54
+ links = func(shared_state, url, mirror, title)
55
+ if links:
56
+ valid_links = [pair for pair in links if "/404.html" not in pair[0]]
57
+
58
+ # If none left, IP was banned
59
+ if not valid_links:
60
+ fail(
61
+ title,
62
+ package_id,
63
+ shared_state,
64
+ reason=f'IP was banned during download of "{title}" on {label} - "{url}"'
65
+ )
66
+ return {"success": False, "title": title}
67
+ links = valid_links
68
+
69
+ info(f'CAPTCHA-Solution required for "{title}" at: "{shared_state.values['external_address']}/captcha"')
70
+ send_discord_message(shared_state, title=title, case="captcha", imdb_id=imdb_id, source=url)
71
+ blob = json.dumps({"title": title, "links": links, "size_mb": size_mb, "password": password})
72
+ shared_state.values["database"]("protected").update_store(package_id, blob)
73
+ else:
74
+ fail(title, package_id, shared_state,
75
+ reason=f'No protected links found for "{title}" on {label} - "{url}"')
76
+ return {"success": False, "title": title}
77
+ return {"success": True, "title": title}
78
+
79
+
80
+ def handle_al(shared_state, title, password, package_id, imdb_id, url, mirror, size_mb):
81
+ data = get_al_download_links(shared_state, url, mirror, title, password)
82
+ links = data.get("links", [])
83
+ title = data.get("title", title)
84
+ password = data.get("password", "")
85
+ return handle_unprotected(
86
+ shared_state, title, password, package_id, imdb_id, url,
87
+ links=links,
88
+ label='AL'
89
+ )
90
+
91
+
92
+ def handle_by(shared_state, title, password, package_id, imdb_id, url, mirror, size_mb):
93
+ links = get_by_download_links(shared_state, url, mirror, title)
94
+ if not links:
95
+ fail(title, package_id, shared_state,
96
+ reason=f'Offline / no links found for "{title}" on BY - "{url}"')
97
+ return {"success": False, "title": title}
98
+
99
+ decrypted = decrypt_links_if_hide(shared_state, links)
100
+ if decrypted and decrypted.get("status") != "none":
101
+ status = decrypted.get("status", "error")
102
+ links = decrypted.get("results", [])
103
+ if status == "success":
104
+ return handle_unprotected(
105
+ shared_state, title, password, package_id, imdb_id, url,
106
+ links=links, label='BY'
107
+ )
108
+ else:
109
+ fail(title, package_id, shared_state,
110
+ reason=f'Error decrypting hide.cx links for "{title}" on BY - "{url}"')
111
+ return {"success": False, "title": title}
112
+
113
+ return handle_protected(
114
+ shared_state, title, password, package_id, imdb_id, url,
115
+ mirror=mirror,
116
+ size_mb=size_mb,
117
+ func=lambda ss, u, m, t: links,
118
+ label='BY'
119
+ )
120
+
121
+
122
+ def handle_sf(shared_state, title, password, package_id, imdb_id, url, mirror, size_mb):
123
+ if url.startswith(f"https://{shared_state.values['config']('Hostnames').get('sf')}/external"):
124
+ url = resolve_sf_redirect(url, shared_state.values["user_agent"])
125
+ elif url.startswith(f"https://{shared_state.values['config']('Hostnames').get('sf')}/"):
126
+ data = get_sf_download_links(shared_state, url, mirror, title)
127
+ url = data.get("real_url")
128
+ if not imdb_id:
129
+ imdb_id = data.get("imdb_id")
130
+
131
+ if not url:
132
+ fail(title, package_id, shared_state,
133
+ reason=f'Failed to get download link from SF for "{title}" - "{url}"')
134
+ return {"success": False, "title": title}
135
+
136
+ return handle_protected(
137
+ shared_state, title, password, package_id, imdb_id, url,
138
+ mirror=mirror,
139
+ size_mb=size_mb,
140
+ func=lambda ss, u, m, t: [[url, "filecrypt"]],
141
+ label='SF'
142
+ )
143
+
144
+
145
+ def handle_sl(shared_state, title, password, package_id, imdb_id, url, mirror, size_mb):
146
+ data = get_sl_download_links(shared_state, url, mirror, title)
147
+ links = data.get("links")
148
+ if not imdb_id:
149
+ imdb_id = data.get("imdb_id")
150
+ return handle_unprotected(
151
+ shared_state, title, password, package_id, imdb_id, url,
152
+ links=links,
153
+ label='SL'
154
+ )
155
+
156
+
157
+ def handle_wd(shared_state, title, password, package_id, imdb_id, url, mirror, size_mb):
158
+ data = get_wd_download_links(shared_state, url, mirror, title)
159
+ links = data.get("links", []) if data else []
160
+ if not links:
161
+ fail(title, package_id, shared_state,
162
+ reason=f'Offline / no links found for "{title}" on WD - "{url}"')
163
+ return {"success": False, "title": title}
164
+
165
+ decrypted = decrypt_links_if_hide(shared_state, links)
166
+ if decrypted and decrypted.get("status") != "none":
167
+ status = decrypted.get("status", "error")
168
+ links = decrypted.get("results", [])
169
+ if status == "success":
170
+ return handle_unprotected(
171
+ shared_state, title, password, package_id, imdb_id, url,
172
+ links=links, label='WD'
173
+ )
174
+ else:
175
+ fail(title, package_id, shared_state,
176
+ reason=f'Error decrypting hide.cx links for "{title}" on WD - "{url}"')
177
+ return {"success": False, "title": title}
178
+
179
+ return handle_protected(
180
+ shared_state, title, password, package_id, imdb_id, url,
181
+ mirror=mirror,
182
+ size_mb=size_mb,
183
+ func=lambda ss, u, m, t: links,
184
+ label='WD'
185
+ )
186
+
187
+
188
+ def download(shared_state, request_from, title, url, mirror, size_mb, password, imdb_id=None):
189
+ if "lazylibrarian" in request_from.lower():
190
+ category = "docs"
191
+ elif "radarr" in request_from.lower():
192
+ category = "movies"
193
+ else:
194
+ category = "tv"
195
+
196
+ package_id = f"Quasarr_{category}_{str(hash(title + url)).replace('-', '')}"
197
+
198
+ if imdb_id is not None and imdb_id.lower() == "none":
199
+ imdb_id = None
200
+
201
+ config = shared_state.values["config"]("Hostnames")
202
+ flags = {
203
+ 'AL': config.get("al"),
204
+ 'BY': config.get("by"),
205
+ 'DD': config.get("dd"),
206
+ 'DJ': config.get("dj"),
207
+ 'DT': config.get("dt"),
208
+ 'DW': config.get("dw"),
209
+ 'HE': config.get("he"),
210
+ 'MB': config.get("mb"),
211
+ 'NK': config.get("nk"),
212
+ 'NX': config.get("nx"),
213
+ 'SF': config.get("sf"),
214
+ 'SJ': config.get("sj"),
215
+ 'SL': config.get("sl"),
216
+ 'WD': config.get("wd")
217
+ }
218
+
219
+ handlers = [
220
+ (flags['AL'], handle_al),
221
+ (flags['BY'], handle_by),
222
+ (flags['DD'], lambda *a: handle_unprotected(*a, func=get_dd_download_links, label='DD')),
223
+ (flags['DJ'], lambda *a: handle_protected(*a, func=get_dj_download_links, label='DJ')),
224
+ (flags['DT'], lambda *a: handle_unprotected(*a, func=get_dt_download_links, label='DT')),
225
+ (flags['DW'], lambda *a: handle_protected(*a, func=get_dw_download_links, label='DW')),
226
+ (flags['HE'], lambda *a: handle_unprotected(*a, func=get_he_download_links, label='HE')),
227
+ (flags['MB'], lambda *a: handle_protected(*a, func=get_mb_download_links, label='MB')),
228
+ (flags['NK'], lambda *a: handle_protected(*a, func=get_nk_download_links, label='NK')),
229
+ (flags['NX'], lambda *a: handle_unprotected(*a, func=get_nx_download_links, label='NX')),
230
+ (flags['SF'], handle_sf),
231
+ (flags['SJ'], lambda *a: handle_protected(*a, func=get_sj_download_links, label='SJ')),
232
+ (flags['SL'], handle_sl),
233
+ (flags['WD'], handle_wd),
234
+ ]
235
+
236
+ for flag, fn in handlers:
237
+ if flag and flag.lower() in url.lower():
238
+ return {"package_id": package_id,
239
+ **fn(shared_state, title, password, package_id, imdb_id, url, mirror, size_mb)}
240
+
241
+ if "filecrypt" in url.lower():
242
+ return {"package_id": package_id, **handle_protected(
243
+ shared_state, title, password, package_id, imdb_id, url, mirror, size_mb,
244
+ func=lambda ss, u, m, t: [[u, "filecrypt"]],
245
+ label='filecrypt'
246
+ )}
247
+
248
+ info(f'Could not parse URL for "{title}" - "{url}"')
249
+ StatsHelper(shared_state).increment_failed_downloads()
250
+ return {"success": False, "package_id": package_id, "title": title}
251
+
252
+
253
+ def fail(title, package_id, shared_state, reason="Offline / no links found"):
254
+ try:
255
+ info(f"Reason for failure: {reason}")
256
+ StatsHelper(shared_state).increment_failed_downloads()
257
+ blob = json.dumps({"title": title, "error": reason})
258
+ stored = shared_state.get_db("failed").store(package_id, json.dumps(blob))
259
+ if stored:
260
+ info(f'Package "{title}" marked as failed!"')
261
+ return True
262
+ else:
263
+ info(f'Failed to mark package "{title}" as failed!"')
264
+ return False
265
+ except Exception as e:
266
+ info(f'Error marking package "{package_id}" as failed: {e}')
267
+ return False
File without changes