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.

Files changed (77) hide show
  1. quasarr/__init__.py +316 -42
  2. quasarr/api/__init__.py +187 -0
  3. quasarr/api/arr/__init__.py +387 -0
  4. quasarr/api/captcha/__init__.py +1189 -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 +319 -256
  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 +476 -0
  14. quasarr/downloads/sources/al.py +697 -0
  15. quasarr/downloads/sources/by.py +106 -0
  16. quasarr/downloads/sources/dd.py +76 -0
  17. quasarr/downloads/sources/dj.py +7 -0
  18. quasarr/downloads/sources/dl.py +199 -0
  19. quasarr/downloads/sources/dt.py +66 -0
  20. quasarr/downloads/sources/dw.py +14 -7
  21. quasarr/downloads/sources/he.py +112 -0
  22. quasarr/downloads/sources/mb.py +47 -0
  23. quasarr/downloads/sources/nk.py +54 -0
  24. quasarr/downloads/sources/nx.py +42 -83
  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/downloads/sources/wx.py +127 -0
  30. quasarr/providers/cloudflare.py +204 -0
  31. quasarr/providers/html_images.py +22 -0
  32. quasarr/providers/html_templates.py +211 -104
  33. quasarr/providers/imdb_metadata.py +108 -3
  34. quasarr/providers/log.py +19 -0
  35. quasarr/providers/myjd_api.py +201 -40
  36. quasarr/providers/notifications.py +99 -11
  37. quasarr/providers/obfuscated.py +65 -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/dl.py +175 -0
  42. quasarr/providers/sessions/nx.py +76 -0
  43. quasarr/providers/shared_state.py +656 -79
  44. quasarr/providers/statistics.py +154 -0
  45. quasarr/providers/version.py +60 -1
  46. quasarr/providers/web_server.py +1 -1
  47. quasarr/search/__init__.py +144 -15
  48. quasarr/search/sources/al.py +448 -0
  49. quasarr/search/sources/by.py +204 -0
  50. quasarr/search/sources/dd.py +135 -0
  51. quasarr/search/sources/dj.py +213 -0
  52. quasarr/search/sources/dl.py +354 -0
  53. quasarr/search/sources/dt.py +265 -0
  54. quasarr/search/sources/dw.py +94 -67
  55. quasarr/search/sources/fx.py +89 -33
  56. quasarr/search/sources/he.py +196 -0
  57. quasarr/search/sources/mb.py +195 -0
  58. quasarr/search/sources/nk.py +188 -0
  59. quasarr/search/sources/nx.py +75 -21
  60. quasarr/search/sources/sf.py +374 -0
  61. quasarr/search/sources/sj.py +213 -0
  62. quasarr/search/sources/sl.py +246 -0
  63. quasarr/search/sources/wd.py +208 -0
  64. quasarr/search/sources/wx.py +337 -0
  65. quasarr/storage/config.py +39 -10
  66. quasarr/storage/setup.py +269 -97
  67. quasarr/storage/sqlite_database.py +6 -1
  68. quasarr-1.23.0.dist-info/METADATA +306 -0
  69. quasarr-1.23.0.dist-info/RECORD +77 -0
  70. {quasarr-0.1.6.dist-info → quasarr-1.23.0.dist-info}/WHEEL +1 -1
  71. quasarr/arr/__init__.py +0 -423
  72. quasarr/captcha_solver/__init__.py +0 -284
  73. quasarr-0.1.6.dist-info/METADATA +0 -81
  74. quasarr-0.1.6.dist-info/RECORD +0 -31
  75. {quasarr-0.1.6.dist-info → quasarr-1.23.0.dist-info}/entry_points.txt +0 -0
  76. {quasarr-0.1.6.dist-info → quasarr-1.23.0.dist-info/licenses}/LICENSE +0 -0
  77. {quasarr-0.1.6.dist-info → quasarr-1.23.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,54 @@
1
+ # -*- coding: utf-8 -*-
2
+ # Quasarr
3
+ # Project by https://github.com/rix1337
4
+
5
+ import requests
6
+ from bs4 import BeautifulSoup
7
+
8
+ from quasarr.providers.log import info
9
+
10
+ hostname = "nk"
11
+ supported_mirrors = ["rapidgator", "ddownload"]
12
+
13
+
14
+ def get_nk_download_links(shared_state, url, mirror, title):
15
+ host = shared_state.values["config"]("Hostnames").get(hostname)
16
+ headers = {
17
+ 'User-Agent': shared_state.values["user_agent"],
18
+ }
19
+
20
+ session = requests.Session()
21
+
22
+ try:
23
+ resp = session.get(url, headers=headers, timeout=20)
24
+ soup = BeautifulSoup(resp.text, 'html.parser')
25
+ except Exception as e:
26
+ info(f"{hostname}: could not fetch release page for {title}: {e}")
27
+ return False
28
+
29
+ anchors = soup.select('a.btn-orange')
30
+ candidates = []
31
+ for a in anchors:
32
+ mirror = a.text.strip().lower()
33
+ if mirror == 'ddl.to':
34
+ mirror = 'ddownload'
35
+
36
+ if mirror not in supported_mirrors:
37
+ continue
38
+
39
+ href = a.get('href', '').strip()
40
+ if not href.lower().startswith(('http://', 'https://')):
41
+ href = 'https://' + host + href
42
+
43
+ try:
44
+ href = requests.head(href, headers=headers, allow_redirects=True, timeout=20).url
45
+ except Exception as e:
46
+ info(f"{hostname}: could not resolve download link for {title}: {e}")
47
+ continue
48
+
49
+ candidates.append([href, mirror])
50
+
51
+ if not candidates:
52
+ info(f"No external download links found on {hostname} page for {title}")
53
+
54
+ return candidates
@@ -2,109 +2,61 @@
2
2
  # Quasarr
3
3
  # Project by https://github.com/rix1337
4
4
 
5
- import base64
6
- import pickle
7
5
  import re
8
6
 
9
7
  import requests
10
8
  from bs4 import BeautifulSoup
11
9
 
10
+ from quasarr.providers.log import info
11
+ from quasarr.providers.sessions.nx import retrieve_and_validate_session
12
12
 
13
- def create_and_persist_session(shared_state):
14
- nx = shared_state.values["config"]("Hostnames").get("nx")
15
13
 
16
- nx_session = requests.Session()
14
+ def get_filer_folder_links_via_api(shared_state, url):
15
+ try:
16
+ headers = {
17
+ 'User-Agent': shared_state.values["user_agent"],
18
+ 'Referer': url
19
+ }
17
20
 
18
- cookies = {}
19
- headers = {
20
- 'User-Agent': shared_state.values["user_agent"],
21
- }
21
+ m = re.search(r"/folder/([A-Za-z0-9]+)", url)
22
+ if not m:
23
+ return url # not a folder URL
22
24
 
23
- json_data = {
24
- 'username': shared_state.values["config"]("NX").get("user"),
25
- 'password': shared_state.values["config"]("NX").get("password")
26
- }
25
+ folder_hash = m.group(1)
26
+ api_url = f"https://filer.net/api/folder/{folder_hash}"
27
27
 
28
- nx_response = nx_session.post(f'https://{nx}/api/user/auth', cookies=cookies, headers=headers, json=json_data)
28
+ response = requests.get(api_url, headers=headers, timeout=10)
29
+ if not response or response.status_code != 200:
30
+ return url
29
31
 
30
- error = False
31
- if nx_response.status_code == 200:
32
- try:
33
- response_data = nx_response.json()
34
- if response_data.get('err', {}).get('status') == 403:
35
- print("Invalid NX credentials provided.")
36
- error = True
37
- elif response_data.get('user').get('username') != shared_state.values["config"]("NX").get("user"):
38
- print("Invalid NX response on login.")
39
- error = True
40
- else:
41
- sessiontoken = response_data.get('user').get('sessiontoken')
42
- nx_session.cookies.set('sessiontoken', sessiontoken, domain=nx)
43
- except ValueError:
44
- print("Could not parse response.")
45
- error = True
46
-
47
- if error:
48
- shared_state.values["config"]("NX").save("user", "")
49
- shared_state.values["config"]("NX").save("password", "")
50
- return None
51
-
52
- serialized_session = pickle.dumps(nx_session)
53
- session_string = base64.b64encode(serialized_session).decode('utf-8')
54
- shared_state.values["database"]("sessions").update_store("nx", session_string)
55
- return nx_session
56
- else:
57
- print("Could not create NX session")
58
- return None
59
-
60
-
61
- def retrieve_and_validate_session(shared_state):
62
- session_string = shared_state.values["database"]("sessions").retrieve("nx")
63
- if not session_string:
64
- nx_session = create_and_persist_session(shared_state)
65
- else:
66
- try:
67
- serialized_session = base64.b64decode(session_string.encode('utf-8'))
68
- nx_session = pickle.loads(serialized_session)
69
- if not isinstance(nx_session, requests.Session):
70
- raise ValueError("Retrieved object is not a valid requests.Session instance.")
71
- except Exception as e:
72
- print(f"Session retrieval failed: {e}")
73
- nx_session = create_and_persist_session(shared_state)
32
+ data = response.json()
33
+ files = data.get("files", [])
34
+ links = []
74
35
 
75
- return nx_session
36
+ # Build download URLs from their file hashes
37
+ for f in files:
38
+ file_hash = f.get("hash")
39
+ if not file_hash:
40
+ continue
41
+ dl_url = f"https://filer.net/get/{file_hash}"
42
+ links.append(dl_url)
76
43
 
44
+ # Return extracted links or fallback
45
+ return links if links else url
77
46
 
78
- def get_filer_folder_links(shared_state, url):
79
- try:
80
- headers = {
81
- 'User-Agent': shared_state.values["user_agent"],
82
- 'Referer': url
83
- }
84
- response = requests.get(url, headers=headers)
85
- links = []
86
- if response:
87
- soup = BeautifulSoup(response.content, 'html.parser')
88
- folder_links = soup.find_all('a', href=re.compile("/get/"))
89
- for link in folder_links:
90
- link = "https://filer.net" + link.get('href')
91
- if link not in links:
92
- links.append(link)
93
- return links
94
47
  except:
95
- pass
96
- return url
48
+ return url
97
49
 
98
50
 
99
- def get_nx_download_links(shared_state, url, title):
51
+ def get_nx_download_links(shared_state, url, mirror, title): # signature must align with other download link functions!
100
52
  nx = shared_state.values["config"]("Hostnames").get("nx")
101
53
 
102
54
  if f"{nx}/release/" not in url:
103
- print("Link is not a Release link, could not proceed:" + url)
55
+ info("Link is not a Release link, could not proceed:" + url)
104
56
 
105
57
  nx_session = retrieve_and_validate_session(shared_state)
106
58
  if not nx_session:
107
- print(f"Could not retrieve valid session for {nx}")
59
+ info(f"Could not retrieve valid session for {nx}")
108
60
  return []
109
61
 
110
62
  headers = {
@@ -120,27 +72,34 @@ def get_nx_download_links(shared_state, url, title):
120
72
  payload = nx_session.post(payload_url,
121
73
  headers=headers,
122
74
  json=json_data,
75
+ timeout=10
123
76
  )
124
77
 
125
78
  if payload.status_code == 200:
126
79
  try:
127
80
  payload = payload.json()
128
81
  except:
129
- print("Invalid response decrypting " + str(title) + " URL: " + str(url))
82
+ info("Invalid response decrypting " + str(title) + " URL: " + str(url))
130
83
  shared_state.values["database"]("sessions").delete("nx")
131
84
  return []
132
85
 
86
+ if payload and any(key in payload for key in ("err", "error")):
87
+ error_msg = payload.get("err") or payload.get("error")
88
+ info(f"Error decrypting {title!r} URL: {url!r} - {error_msg}")
89
+ shared_state.values["database"]("sessions").delete("nx")
90
+ return []
91
+
133
92
  try:
134
93
  decrypted_url = payload['link'][0]['url']
135
94
  if decrypted_url:
136
95
  if "filer.net/folder/" in decrypted_url:
137
- urls = get_filer_folder_links(shared_state, decrypted_url)
96
+ urls = get_filer_folder_links_via_api(shared_state, decrypted_url)
138
97
  else:
139
98
  urls = [decrypted_url]
140
99
  return urls
141
100
  except:
142
101
  pass
143
102
 
144
- print("Something went wrong decrypting " + str(title) + " URL: " + str(url))
103
+ info("Something went wrong decrypting " + str(title) + " URL: " + str(url))
145
104
  shared_state.values["database"]("sessions").delete("nx")
146
105
  return []
@@ -0,0 +1,159 @@
1
+ # -*- coding: utf-8 -*-
2
+ # Quasarr
3
+ # Project by https://github.com/rix1337
4
+
5
+ import re
6
+ from datetime import datetime
7
+
8
+ import requests
9
+ from bs4 import BeautifulSoup
10
+
11
+ from quasarr.providers.log import info, debug
12
+ from quasarr.search.sources.sf import parse_mirrors
13
+
14
+
15
+ def is_last_section_integer(url):
16
+ last_section = url.rstrip('/').split('/')[-1]
17
+ if last_section.isdigit() and len(last_section) <= 3:
18
+ return int(last_section)
19
+ return None
20
+
21
+
22
+ def get_sf_download_links(shared_state, url, mirror, title): # signature must align with other download link functions!
23
+ release_pattern = re.compile(
24
+ r'''
25
+ ^ # start of string
26
+ (?P<name>.+?)\. # show name (dots in name) up to the dot before “S”
27
+ S(?P<season>\d+) # “S” + season number
28
+ (?:E\d+(?:-E\d+)?)? # optional “E##” or “E##-E##”
29
+ \. # literal dot
30
+ .*?\. # anything (e.g. language/codec) up to next dot
31
+ (?P<resolution>\d+p) # resolution “720p”, “1080p”, etc.
32
+ \..+? # dot + more junk (e.g. “.WEB.h264”)
33
+ -(?P<group>\w+) # dash + release group at end
34
+ $ # end of string
35
+ ''',
36
+ re.IGNORECASE | re.VERBOSE
37
+ )
38
+
39
+ release_match = release_pattern.match(title)
40
+
41
+ if not release_match:
42
+ return {
43
+ "real_url": None,
44
+ "imdb_id": None,
45
+ }
46
+
47
+ release_parts = release_match.groupdict()
48
+
49
+ season = is_last_section_integer(url)
50
+ try:
51
+ if not season:
52
+ season = "ALL"
53
+
54
+ sf = shared_state.values["config"]("Hostnames").get("sf")
55
+ headers = {
56
+ 'User-Agent': shared_state.values["user_agent"],
57
+ }
58
+
59
+ series_page = requests.get(url, headers=headers, timeout=10).text
60
+
61
+ soup = BeautifulSoup(series_page, "html.parser")
62
+ # extract IMDb id if present
63
+ imdb_id = None
64
+ a_imdb = soup.find("a", href=re.compile(r"imdb\.com/title/tt\d+"))
65
+ if a_imdb:
66
+ m = re.search(r"(tt\d+)", a_imdb["href"])
67
+ if m:
68
+ imdb_id = m.group(1)
69
+ debug(f"Found IMDb id: {imdb_id}")
70
+
71
+ season_id = re.findall(r"initSeason\('(.+?)\',", series_page)[0]
72
+ epoch = str(datetime.now().timestamp()).replace('.', '')[:-3]
73
+ api_url = 'https://' + sf + '/api/v1/' + season_id + f'/season/{season}?lang=ALL&_=' + epoch
74
+
75
+ response = requests.get(api_url, headers=headers, timeout=10)
76
+ try:
77
+ data = response.json()["html"]
78
+ except ValueError:
79
+ epoch = str(datetime.now().timestamp()).replace('.', '')[:-3]
80
+ api_url = 'https://' + sf + '/api/v1/' + season_id + f'/season/ALL?lang=ALL&_=' + epoch
81
+ response = requests.get(api_url, headers=headers, timeout=10)
82
+ data = response.json()["html"]
83
+
84
+ content = BeautifulSoup(data, "html.parser")
85
+
86
+ items = content.find_all("h3")
87
+
88
+ for item in items:
89
+ try:
90
+ details = item.parent.parent.parent
91
+ name = details.find("small").text.strip()
92
+
93
+ result_pattern = re.compile(
94
+ r'^(?P<name>.+?)\.S(?P<season>\d+)(?:E\d+)?\..*?(?P<resolution>\d+p)\..+?-(?P<group>[\w/-]+)$',
95
+ re.IGNORECASE
96
+ )
97
+ result_match = result_pattern.match(name)
98
+
99
+ if not result_match:
100
+ continue
101
+
102
+ result_parts = result_match.groupdict()
103
+
104
+ # Normalize all relevant fields for case-insensitive comparison
105
+ name_match = release_parts['name'].lower() == result_parts['name'].lower()
106
+ season_match = release_parts['season'] == result_parts['season'] # Numbers are case-insensitive
107
+ resolution_match = release_parts['resolution'].lower() == result_parts['resolution'].lower()
108
+
109
+ # Handle multiple groups and case-insensitive matching
110
+ result_groups = {g.lower() for g in result_parts['group'].split('/')}
111
+ release_groups = {g.lower() for g in release_parts['group'].split('/')}
112
+ group_match = not result_groups.isdisjoint(release_groups) # Checks if any group matches
113
+
114
+ if name_match and season_match and resolution_match and group_match:
115
+ info(f'Release "{name}" found on SF at: {url}')
116
+
117
+ mirrors = parse_mirrors(f"https://{sf}", details)
118
+
119
+ if mirror:
120
+ if mirror not in mirrors["season"]:
121
+ continue
122
+ release_url = mirrors["season"][mirror]
123
+ if not release_url:
124
+ info(f"Could not find mirror '{mirror}' for '{title}'")
125
+ else:
126
+ release_url = next(iter(mirrors["season"].values()))
127
+
128
+ real_url = resolve_sf_redirect(release_url, shared_state.values["user_agent"])
129
+ return {
130
+ "real_url": real_url,
131
+ "imdb_id": imdb_id,
132
+ }
133
+ except:
134
+ continue
135
+ except:
136
+ pass
137
+
138
+ return {
139
+ "real_url": None,
140
+ "imdb_id": None,
141
+ }
142
+
143
+
144
+ def resolve_sf_redirect(url, user_agent):
145
+ try:
146
+ response = requests.get(url, allow_redirects=True, timeout=10,
147
+ headers={'User-Agent': user_agent})
148
+ if response.history:
149
+ for resp in response.history:
150
+ debug(f"Redirected from {resp.url} to {response.url}")
151
+ if "/404.html" in response.url:
152
+ info(f"SF link redirected to 404 page: {response.url}")
153
+ return None
154
+ return response.url
155
+ else:
156
+ info(f"SF blocked attempt to resolve {url}. Your IP may be banned. Try again later.")
157
+ except Exception as e:
158
+ info(f"Error fetching redirected URL for {url}: {e}")
159
+ return None
@@ -0,0 +1,7 @@
1
+ # -*- coding: utf-8 -*-
2
+ # Quasarr
3
+ # Project by https://github.com/rix1337
4
+
5
+
6
+ def get_sj_download_links(shared_state, url, mirror, title): # signature must align with other download link functions!
7
+ return [url]
@@ -0,0 +1,90 @@
1
+ # -*- coding: utf-8 -*-
2
+ # Quasarr
3
+ # Project by https://github.com/rix1337
4
+
5
+ import re
6
+ from urllib.parse import urlparse
7
+
8
+ import requests
9
+ from bs4 import BeautifulSoup
10
+
11
+ from quasarr.providers.log import info, debug
12
+
13
+ supported_mirrors = ["nitroflare", "ddownload"] # ignoring captcha-protected multiup/mirrorace for now
14
+
15
+
16
+ def get_sl_download_links(shared_state, url, mirror, title): # signature must align with other download link functions!
17
+ headers = {"User-Agent": shared_state.values["user_agent"]}
18
+ session = requests.Session()
19
+
20
+ try:
21
+ resp = session.get(url, headers=headers, timeout=10)
22
+ soup = BeautifulSoup(resp.text, "html.parser")
23
+
24
+ entry = soup.find("div", class_="entry")
25
+ if not entry:
26
+ info(f"Could not find main content section for {title}")
27
+ return False
28
+
29
+ # extract IMDb id if present
30
+ imdb_id = None
31
+ a_imdb = soup.find("a", href=re.compile(r"imdb\.com/title/tt\d+"))
32
+ if a_imdb:
33
+ m = re.search(r"(tt\d+)", a_imdb["href"])
34
+ if m:
35
+ imdb_id = m.group(1)
36
+ debug(f"Found IMDb id: {imdb_id}")
37
+
38
+ download_h2 = entry.find(
39
+ lambda t: t.name == "h2" and "download" in t.get_text(strip=True).lower()
40
+ )
41
+ if download_h2:
42
+ anchors = []
43
+ for sib in download_h2.next_siblings:
44
+ if getattr(sib, "name", None) == "h2":
45
+ break
46
+ if hasattr(sib, "find_all"):
47
+ anchors += sib.find_all("a", href=True)
48
+ else:
49
+ anchors = entry.find_all("a", href=True)
50
+
51
+ except Exception as e:
52
+ info(f"SL site has been updated. Grabbing download links for {title} not possible! ({e})")
53
+ return False
54
+
55
+ filtered = []
56
+ for a in anchors:
57
+ href = a["href"].strip()
58
+ if not href.lower().startswith(("http://", "https://")):
59
+ continue
60
+
61
+ host = (urlparse(href).hostname or "").lower()
62
+ # require host to start with one of supported_mirrors + "."
63
+ if not any(host.startswith(m + ".") for m in supported_mirrors):
64
+ continue
65
+
66
+ if not mirror or mirror in href:
67
+ filtered.append(href)
68
+
69
+ # regex‐fallback if still empty
70
+ if not filtered:
71
+ text = "".join(str(x) for x in anchors)
72
+ urls = re.findall(r"https?://[^\s<>'\"]+", text)
73
+ seen = set()
74
+ for u in urls:
75
+ u = u.strip()
76
+ if u in seen:
77
+ continue
78
+ seen.add(u)
79
+
80
+ host = (urlparse(u).hostname or "").lower()
81
+ if not any(host.startswith(m + ".") for m in supported_mirrors):
82
+ continue
83
+
84
+ if not mirror or mirror in u:
85
+ filtered.append(u)
86
+
87
+ return {
88
+ "links": filtered,
89
+ "imdb_id": imdb_id,
90
+ }
@@ -0,0 +1,110 @@
1
+ # -*- coding: utf-8 -*-
2
+ # Quasarr
3
+ # Project by https://github.com/rix1337
4
+
5
+ import re
6
+ from urllib.parse import urljoin
7
+
8
+ import requests
9
+ from bs4 import BeautifulSoup
10
+
11
+ from quasarr.providers.cloudflare import flaresolverr_get, is_cloudflare_challenge
12
+ from quasarr.providers.log import info, debug
13
+
14
+
15
+ def resolve_wd_redirect(url, user_agent):
16
+ """
17
+ Follow redirects for a WD mirror URL and return the final destination.
18
+ """
19
+ try:
20
+ response = requests.get(
21
+ url,
22
+ allow_redirects=True,
23
+ timeout=10,
24
+ headers={"User-Agent": user_agent},
25
+ )
26
+ if response.history:
27
+ for resp in response.history:
28
+ debug(f"Redirected from {resp.url} to {response.url}")
29
+ return response.url
30
+ else:
31
+ info(f"WD blocked attempt to resolve {url}. Your IP may be banned. Try again later.")
32
+ except Exception as e:
33
+ info(f"Error fetching redirected URL for {url}: {e}")
34
+ return None
35
+
36
+
37
+ def get_wd_download_links(shared_state, url, mirror, title): # signature must align with other download link functions!
38
+ wd = shared_state.values["config"]("Hostnames").get("wd")
39
+ user_agent = shared_state.values["user_agent"]
40
+
41
+ try:
42
+ output = requests.get(url)
43
+ if output.status_code == 403 or is_cloudflare_challenge(output.text):
44
+ info("WD is protected by Cloudflare. Using FlareSolverr to bypass protection.")
45
+ output = flaresolverr_get(shared_state, url)
46
+
47
+ soup = BeautifulSoup(output.text, "html.parser")
48
+
49
+ # extract IMDb id if present
50
+ imdb_id = None
51
+ a_imdb = soup.find("a", href=re.compile(r"imdb\.com/title/tt\d+"))
52
+ if a_imdb:
53
+ m = re.search(r"(tt\d+)", a_imdb["href"])
54
+ if m:
55
+ imdb_id = m.group(1)
56
+ debug(f"Found IMDb id: {imdb_id}")
57
+
58
+ # find Downloads card
59
+ header = soup.find(
60
+ "div",
61
+ class_="card-header",
62
+ string=re.compile(r"^\s*Downloads\s*$", re.IGNORECASE),
63
+ )
64
+ if not header:
65
+ info(f"WD Downloads section not found. Grabbing download links for {title} not possible!")
66
+ return False
67
+
68
+ card = header.find_parent("div", class_="card")
69
+ body = card.find("div", class_="card-body")
70
+ link_tags = body.find_all(
71
+ "a", href=True, class_=lambda c: c and "background-" in c
72
+ )
73
+ except Exception:
74
+ info(f"WD site has been updated. Grabbing download links for {title} not possible!")
75
+ return False
76
+
77
+ results = []
78
+ try:
79
+ for a in link_tags:
80
+ raw_href = a["href"]
81
+ full_link = urljoin(f"https://{wd}", raw_href)
82
+
83
+ # resolve any redirects
84
+ resolved = resolve_wd_redirect(full_link, user_agent)
85
+
86
+ if resolved:
87
+ if resolved.endswith("/404.html"):
88
+ info(f"Link {resolved} is dead!")
89
+ continue
90
+
91
+ # determine hoster
92
+ hoster = a.get_text(strip=True) or None
93
+ if not hoster:
94
+ for cls in a.get("class", []):
95
+ if cls.startswith("background-"):
96
+ hoster = cls.split("-", 1)[1]
97
+ break
98
+
99
+ if mirror and mirror.lower() not in hoster.lower():
100
+ debug(f'Skipping link from "{hoster}" (not the desired mirror "{mirror}")!')
101
+ continue
102
+
103
+ results.append([resolved, hoster])
104
+ except Exception:
105
+ info(f"WD site has been updated. Parsing download links for {title} not possible!")
106
+
107
+ return {
108
+ "links": results,
109
+ "imdb_id": imdb_id,
110
+ }