quasarr 0.1.6__py3-none-any.whl → 1.23.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of quasarr might be problematic. Click here for more details.
- quasarr/__init__.py +316 -42
- quasarr/api/__init__.py +187 -0
- quasarr/api/arr/__init__.py +387 -0
- quasarr/api/captcha/__init__.py +1189 -0
- quasarr/api/config/__init__.py +23 -0
- quasarr/api/sponsors_helper/__init__.py +166 -0
- quasarr/api/statistics/__init__.py +196 -0
- quasarr/downloads/__init__.py +319 -256
- quasarr/downloads/linkcrypters/__init__.py +0 -0
- quasarr/downloads/linkcrypters/al.py +237 -0
- quasarr/downloads/linkcrypters/filecrypt.py +444 -0
- quasarr/downloads/linkcrypters/hide.py +123 -0
- quasarr/downloads/packages/__init__.py +476 -0
- quasarr/downloads/sources/al.py +697 -0
- quasarr/downloads/sources/by.py +106 -0
- quasarr/downloads/sources/dd.py +76 -0
- quasarr/downloads/sources/dj.py +7 -0
- quasarr/downloads/sources/dl.py +199 -0
- quasarr/downloads/sources/dt.py +66 -0
- quasarr/downloads/sources/dw.py +14 -7
- quasarr/downloads/sources/he.py +112 -0
- quasarr/downloads/sources/mb.py +47 -0
- quasarr/downloads/sources/nk.py +54 -0
- quasarr/downloads/sources/nx.py +42 -83
- quasarr/downloads/sources/sf.py +159 -0
- quasarr/downloads/sources/sj.py +7 -0
- quasarr/downloads/sources/sl.py +90 -0
- quasarr/downloads/sources/wd.py +110 -0
- quasarr/downloads/sources/wx.py +127 -0
- quasarr/providers/cloudflare.py +204 -0
- quasarr/providers/html_images.py +22 -0
- quasarr/providers/html_templates.py +211 -104
- quasarr/providers/imdb_metadata.py +108 -3
- quasarr/providers/log.py +19 -0
- quasarr/providers/myjd_api.py +201 -40
- quasarr/providers/notifications.py +99 -11
- quasarr/providers/obfuscated.py +65 -0
- quasarr/providers/sessions/__init__.py +0 -0
- quasarr/providers/sessions/al.py +286 -0
- quasarr/providers/sessions/dd.py +78 -0
- quasarr/providers/sessions/dl.py +175 -0
- quasarr/providers/sessions/nx.py +76 -0
- quasarr/providers/shared_state.py +656 -79
- quasarr/providers/statistics.py +154 -0
- quasarr/providers/version.py +60 -1
- quasarr/providers/web_server.py +1 -1
- quasarr/search/__init__.py +144 -15
- quasarr/search/sources/al.py +448 -0
- quasarr/search/sources/by.py +204 -0
- quasarr/search/sources/dd.py +135 -0
- quasarr/search/sources/dj.py +213 -0
- quasarr/search/sources/dl.py +354 -0
- quasarr/search/sources/dt.py +265 -0
- quasarr/search/sources/dw.py +94 -67
- quasarr/search/sources/fx.py +89 -33
- quasarr/search/sources/he.py +196 -0
- quasarr/search/sources/mb.py +195 -0
- quasarr/search/sources/nk.py +188 -0
- quasarr/search/sources/nx.py +75 -21
- quasarr/search/sources/sf.py +374 -0
- quasarr/search/sources/sj.py +213 -0
- quasarr/search/sources/sl.py +246 -0
- quasarr/search/sources/wd.py +208 -0
- quasarr/search/sources/wx.py +337 -0
- quasarr/storage/config.py +39 -10
- quasarr/storage/setup.py +269 -97
- quasarr/storage/sqlite_database.py +6 -1
- quasarr-1.23.0.dist-info/METADATA +306 -0
- quasarr-1.23.0.dist-info/RECORD +77 -0
- {quasarr-0.1.6.dist-info → quasarr-1.23.0.dist-info}/WHEEL +1 -1
- quasarr/arr/__init__.py +0 -423
- quasarr/captcha_solver/__init__.py +0 -284
- quasarr-0.1.6.dist-info/METADATA +0 -81
- quasarr-0.1.6.dist-info/RECORD +0 -31
- {quasarr-0.1.6.dist-info → quasarr-1.23.0.dist-info}/entry_points.txt +0 -0
- {quasarr-0.1.6.dist-info → quasarr-1.23.0.dist-info/licenses}/LICENSE +0 -0
- {quasarr-0.1.6.dist-info → quasarr-1.23.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
# Quasarr
|
|
3
|
+
# Project by https://github.com/rix1337
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import time
|
|
7
|
+
from base64 import urlsafe_b64encode
|
|
8
|
+
from datetime import datetime, timedelta
|
|
9
|
+
from html import unescape
|
|
10
|
+
from urllib.parse import urljoin, quote_plus
|
|
11
|
+
|
|
12
|
+
from bs4 import BeautifulSoup
|
|
13
|
+
|
|
14
|
+
from quasarr.downloads.sources.al import (guess_title,
|
|
15
|
+
parse_info_from_feed_entry, parse_info_from_download_item)
|
|
16
|
+
from quasarr.providers.imdb_metadata import get_localized_title
|
|
17
|
+
from quasarr.providers.log import info, debug
|
|
18
|
+
from quasarr.providers.sessions.al import invalidate_session, fetch_via_requests_session
|
|
19
|
+
|
|
20
|
+
hostname = "al"
|
|
21
|
+
supported_mirrors = ["rapidgator", "ddownload"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def convert_to_rss_date(date_str: str) -> str:
|
|
25
|
+
parsed = datetime.strptime(date_str, "%d.%m.%Y - %H:%M")
|
|
26
|
+
return parsed.strftime("%a, %d %b %Y %H:%M:%S +0000")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
import re
|
|
30
|
+
from datetime import datetime, timedelta
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def convert_to_rss_date(date_str: str) -> str:
|
|
34
|
+
# First try to parse relative dates (German and English)
|
|
35
|
+
parsed_date = parse_relative_date(date_str)
|
|
36
|
+
if parsed_date:
|
|
37
|
+
return parsed_date.strftime("%a, %d %b %Y %H:%M:%S +0000")
|
|
38
|
+
|
|
39
|
+
# Fall back to absolute date parsing
|
|
40
|
+
try:
|
|
41
|
+
parsed = datetime.strptime(date_str, "%d.%m.%Y - %H:%M")
|
|
42
|
+
return parsed.strftime("%a, %d %b %Y %H:%M:%S +0000")
|
|
43
|
+
except ValueError:
|
|
44
|
+
# If parsing fails, return the original string or handle as needed
|
|
45
|
+
raise ValueError(f"Could not parse date: {date_str}")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def parse_relative_date(raw: str) -> datetime | None:
|
|
49
|
+
# German pattern: "vor X Einheit(en)"
|
|
50
|
+
german_match = re.match(r"vor\s+(\d+)\s+(\w+)", raw, re.IGNORECASE)
|
|
51
|
+
if german_match:
|
|
52
|
+
num = int(german_match.group(1))
|
|
53
|
+
unit = german_match.group(2).lower()
|
|
54
|
+
|
|
55
|
+
if unit.startswith("sekunde"):
|
|
56
|
+
delta = timedelta(seconds=num)
|
|
57
|
+
elif unit.startswith("minute"):
|
|
58
|
+
delta = timedelta(minutes=num)
|
|
59
|
+
elif unit.startswith("stunde"):
|
|
60
|
+
delta = timedelta(hours=num)
|
|
61
|
+
elif unit.startswith("tag"):
|
|
62
|
+
delta = timedelta(days=num)
|
|
63
|
+
elif unit.startswith("woche"):
|
|
64
|
+
delta = timedelta(weeks=num)
|
|
65
|
+
elif unit.startswith("monat"):
|
|
66
|
+
delta = timedelta(days=30 * num)
|
|
67
|
+
elif unit.startswith("jahr"):
|
|
68
|
+
delta = timedelta(days=365 * num)
|
|
69
|
+
else:
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
return datetime.utcnow() - delta
|
|
73
|
+
|
|
74
|
+
# English pattern: "X Unit(s) ago"
|
|
75
|
+
english_match = re.match(r"(\d+)\s+(\w+)\s+ago", raw, re.IGNORECASE)
|
|
76
|
+
if english_match:
|
|
77
|
+
num = int(english_match.group(1))
|
|
78
|
+
unit = english_match.group(2).lower()
|
|
79
|
+
|
|
80
|
+
# Remove plural 's' if present
|
|
81
|
+
if unit.endswith('s'):
|
|
82
|
+
unit = unit[:-1]
|
|
83
|
+
|
|
84
|
+
if unit.startswith("second"):
|
|
85
|
+
delta = timedelta(seconds=num)
|
|
86
|
+
elif unit.startswith("minute"):
|
|
87
|
+
delta = timedelta(minutes=num)
|
|
88
|
+
elif unit.startswith("hour"):
|
|
89
|
+
delta = timedelta(hours=num)
|
|
90
|
+
elif unit.startswith("day"):
|
|
91
|
+
delta = timedelta(days=num)
|
|
92
|
+
elif unit.startswith("week"):
|
|
93
|
+
delta = timedelta(weeks=num)
|
|
94
|
+
elif unit.startswith("month"):
|
|
95
|
+
delta = timedelta(days=30 * num)
|
|
96
|
+
elif unit.startswith("year"):
|
|
97
|
+
delta = timedelta(days=365 * num)
|
|
98
|
+
else:
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
return datetime.utcnow() - delta
|
|
102
|
+
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def extract_size(text):
|
|
107
|
+
match = re.match(r"(\d+(\.\d+)?) ([A-Za-z]+)", text)
|
|
108
|
+
if match:
|
|
109
|
+
size = match.group(1)
|
|
110
|
+
unit = match.group(3)
|
|
111
|
+
return {"size": size, "sizeunit": unit}
|
|
112
|
+
else:
|
|
113
|
+
raise ValueError(f"Invalid size format: {text}")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def get_release_id(tag):
|
|
117
|
+
match = re.search(r"release\s+(\d+):", tag.get_text(strip=True), re.IGNORECASE)
|
|
118
|
+
if match:
|
|
119
|
+
return int(match.group(1))
|
|
120
|
+
return 0
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def al_feed(shared_state, start_time, request_from, mirror=None):
|
|
124
|
+
releases = []
|
|
125
|
+
host = shared_state.values["config"]("Hostnames").get(hostname)
|
|
126
|
+
|
|
127
|
+
if not "arr" in request_from.lower():
|
|
128
|
+
debug(f'Skipping {request_from} search on "{hostname.upper()}" (unsupported media type)!')
|
|
129
|
+
return releases
|
|
130
|
+
|
|
131
|
+
if "Radarr" in request_from:
|
|
132
|
+
wanted_type = "movie"
|
|
133
|
+
else:
|
|
134
|
+
wanted_type = "series"
|
|
135
|
+
|
|
136
|
+
if mirror and mirror not in supported_mirrors:
|
|
137
|
+
debug(f'Mirror "{mirror}" not supported by {hostname}.')
|
|
138
|
+
return releases
|
|
139
|
+
|
|
140
|
+
try:
|
|
141
|
+
r = fetch_via_requests_session(shared_state, method="GET", target_url=f'https://www.{host}/', timeout=10)
|
|
142
|
+
r.raise_for_status()
|
|
143
|
+
except Exception as e:
|
|
144
|
+
info(f"{hostname}: could not fetch feed: {e}")
|
|
145
|
+
invalidate_session(shared_state)
|
|
146
|
+
return releases
|
|
147
|
+
|
|
148
|
+
soup = BeautifulSoup(r.content, 'html.parser')
|
|
149
|
+
|
|
150
|
+
# 1) New “Releases”
|
|
151
|
+
release_rows = soup.select("#releases_updates_list table tbody tr")
|
|
152
|
+
# 2) New “Episodes”
|
|
153
|
+
episode_rows = soup.select("#episodes_updates_list table tbody tr")
|
|
154
|
+
# 3) “Upgrades” Releases
|
|
155
|
+
upgrade_rows = soup.select("#releases_modified_updates_list table tbody tr")
|
|
156
|
+
|
|
157
|
+
for tr in release_rows + episode_rows + upgrade_rows:
|
|
158
|
+
try:
|
|
159
|
+
p_tag = tr.find("p")
|
|
160
|
+
if not p_tag:
|
|
161
|
+
continue
|
|
162
|
+
a_tag = p_tag.find("a", href=True)
|
|
163
|
+
if not a_tag:
|
|
164
|
+
continue
|
|
165
|
+
|
|
166
|
+
url = a_tag["href"].strip()
|
|
167
|
+
# Prefer data-original-title, fall back to title, then to inner text
|
|
168
|
+
if a_tag.get("data-original-title"):
|
|
169
|
+
raw_base_title = a_tag["data-original-title"]
|
|
170
|
+
elif a_tag.get("title"):
|
|
171
|
+
raw_base_title = a_tag["title"]
|
|
172
|
+
else:
|
|
173
|
+
raw_base_title = a_tag.get_text(strip=True)
|
|
174
|
+
|
|
175
|
+
release_type = None
|
|
176
|
+
label_div = tr.find("div", class_="label-group")
|
|
177
|
+
if label_div:
|
|
178
|
+
for lbl in label_div.find_all("a", href=True):
|
|
179
|
+
href = lbl["href"].rstrip("/").lower()
|
|
180
|
+
if href.endswith("/anime-series"):
|
|
181
|
+
release_type = "series"
|
|
182
|
+
break
|
|
183
|
+
elif href.endswith("/anime-movies"):
|
|
184
|
+
release_type = "movie"
|
|
185
|
+
break
|
|
186
|
+
|
|
187
|
+
if release_type is None or release_type != wanted_type:
|
|
188
|
+
continue
|
|
189
|
+
|
|
190
|
+
date_converted = ""
|
|
191
|
+
small_tag = tr.find("small", class_="text-muted")
|
|
192
|
+
if small_tag:
|
|
193
|
+
raw_date_str = small_tag.get_text(strip=True)
|
|
194
|
+
if raw_date_str.startswith("vor"):
|
|
195
|
+
dt = parse_relative_date(raw_date_str)
|
|
196
|
+
if dt:
|
|
197
|
+
date_converted = dt.strftime("%a, %d %b %Y %H:%M:%S +0000")
|
|
198
|
+
else:
|
|
199
|
+
try:
|
|
200
|
+
date_converted = convert_to_rss_date(raw_date_str)
|
|
201
|
+
except Exception as e:
|
|
202
|
+
debug(f"{hostname}: could not parse date '{raw_date_str}': {e}")
|
|
203
|
+
|
|
204
|
+
# Each of these signifies an individual release block
|
|
205
|
+
mt_blocks = tr.find_all("div", class_="mt10")
|
|
206
|
+
for block in mt_blocks:
|
|
207
|
+
release_id = get_release_id(block)
|
|
208
|
+
release_info = parse_info_from_feed_entry(block, raw_base_title, release_type)
|
|
209
|
+
final_title = guess_title(shared_state, raw_base_title, release_info)
|
|
210
|
+
|
|
211
|
+
# Build payload using final_title
|
|
212
|
+
mb = 0 # size not available in feed
|
|
213
|
+
raw = f"{final_title}|{url}|{mirror}|{mb}|{release_id}|".encode("utf-8")
|
|
214
|
+
payload = urlsafe_b64encode(raw).decode("utf-8")
|
|
215
|
+
link = f"{shared_state.values['internal_address']}/download/?payload={payload}"
|
|
216
|
+
|
|
217
|
+
# Append only unique releases
|
|
218
|
+
if final_title not in [r["details"]["title"] for r in releases]:
|
|
219
|
+
releases.append({
|
|
220
|
+
"details": {
|
|
221
|
+
"title": final_title,
|
|
222
|
+
"hostname": hostname,
|
|
223
|
+
"imdb_id": None,
|
|
224
|
+
"link": link,
|
|
225
|
+
"mirror": mirror,
|
|
226
|
+
"size": mb * 1024 * 1024,
|
|
227
|
+
"date": date_converted,
|
|
228
|
+
"source": url
|
|
229
|
+
},
|
|
230
|
+
"type": "protected"
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
except Exception as e:
|
|
234
|
+
info(f"{hostname}: error parsing feed item: {e}")
|
|
235
|
+
|
|
236
|
+
elapsed = time.time() - start_time
|
|
237
|
+
debug(f"Time taken: {elapsed:.2f}s ({hostname})")
|
|
238
|
+
return releases
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def extract_season(title: str) -> int | None:
|
|
242
|
+
match = re.search(r'(?i)(?:^|[^a-zA-Z0-9])S(\d{1,4})(?!\d)', title)
|
|
243
|
+
if match:
|
|
244
|
+
return int(match.group(1))
|
|
245
|
+
return None
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def al_search(shared_state, start_time, request_from, search_string,
|
|
249
|
+
mirror=None, season=None, episode=None):
|
|
250
|
+
releases = []
|
|
251
|
+
host = shared_state.values["config"]("Hostnames").get(hostname)
|
|
252
|
+
|
|
253
|
+
if not "arr" in request_from.lower():
|
|
254
|
+
debug(f'Skipping {request_from} search on "{hostname.upper()}" (unsupported media type)!')
|
|
255
|
+
return releases
|
|
256
|
+
|
|
257
|
+
if "Radarr" in request_from:
|
|
258
|
+
valid_type = "movie"
|
|
259
|
+
else:
|
|
260
|
+
valid_type = "series"
|
|
261
|
+
|
|
262
|
+
if mirror and mirror not in supported_mirrors:
|
|
263
|
+
debug(f'Mirror "{mirror}" not supported by {hostname}.')
|
|
264
|
+
return releases
|
|
265
|
+
|
|
266
|
+
imdb_id = shared_state.is_imdb_id(search_string)
|
|
267
|
+
if imdb_id:
|
|
268
|
+
title = get_localized_title(shared_state, imdb_id, 'de')
|
|
269
|
+
if not title:
|
|
270
|
+
info(f"{hostname}: no title for IMDb {imdb_id}")
|
|
271
|
+
return releases
|
|
272
|
+
search_string = title
|
|
273
|
+
|
|
274
|
+
search_string = unescape(search_string)
|
|
275
|
+
|
|
276
|
+
encoded_search_string = quote_plus(search_string)
|
|
277
|
+
|
|
278
|
+
try:
|
|
279
|
+
url = f'https://www.{host}/search?q={encoded_search_string}'
|
|
280
|
+
r = fetch_via_requests_session(shared_state, method="GET", target_url=url, timeout=10)
|
|
281
|
+
r.raise_for_status()
|
|
282
|
+
except Exception as e:
|
|
283
|
+
info(f"{hostname}: search load error: {e}")
|
|
284
|
+
invalidate_session(shared_state)
|
|
285
|
+
return releases
|
|
286
|
+
|
|
287
|
+
if r.history:
|
|
288
|
+
# If just one valid search result exists, AL skips the search result page
|
|
289
|
+
last_redirect = r.history[-1]
|
|
290
|
+
redirect_location = last_redirect.headers['Location']
|
|
291
|
+
absolute_redirect_url = urljoin(last_redirect.url, redirect_location) # in case of relative URL
|
|
292
|
+
debug(f"{search_string} redirected to {absolute_redirect_url} instead of search results page")
|
|
293
|
+
|
|
294
|
+
try:
|
|
295
|
+
soup = BeautifulSoup(r.text, "html.parser")
|
|
296
|
+
page_title = soup.title.string
|
|
297
|
+
except:
|
|
298
|
+
page_title = ""
|
|
299
|
+
|
|
300
|
+
results = [{"url": absolute_redirect_url, "title": page_title}]
|
|
301
|
+
else:
|
|
302
|
+
soup = BeautifulSoup(r.text, 'html.parser')
|
|
303
|
+
results = []
|
|
304
|
+
|
|
305
|
+
for panel in soup.select('div.panel.panel-default'):
|
|
306
|
+
body = panel.find('div', class_='panel-body')
|
|
307
|
+
if not body:
|
|
308
|
+
continue
|
|
309
|
+
|
|
310
|
+
title_tag = body.select_one('h4.title-list a[href]')
|
|
311
|
+
if not title_tag:
|
|
312
|
+
continue
|
|
313
|
+
url = title_tag['href'].strip()
|
|
314
|
+
name = title_tag.get_text(strip=True)
|
|
315
|
+
|
|
316
|
+
sanitized_search_string = shared_state.sanitize_string(search_string)
|
|
317
|
+
sanitized_title = shared_state.sanitize_string(name)
|
|
318
|
+
if not sanitized_search_string in sanitized_title:
|
|
319
|
+
debug(f"Search string '{search_string}' doesn't match '{name}'")
|
|
320
|
+
continue
|
|
321
|
+
debug(f"Matched search string '{search_string}' with result '{name}'")
|
|
322
|
+
|
|
323
|
+
type_label = None
|
|
324
|
+
for lbl in body.select('div.label-group a[href]'):
|
|
325
|
+
href = lbl['href']
|
|
326
|
+
if '/anime-series' in href:
|
|
327
|
+
type_label = 'series'
|
|
328
|
+
break
|
|
329
|
+
if '/anime-movies' in href:
|
|
330
|
+
type_label = 'movie'
|
|
331
|
+
break
|
|
332
|
+
|
|
333
|
+
if not type_label or type_label != valid_type:
|
|
334
|
+
continue
|
|
335
|
+
|
|
336
|
+
results.append({"url": url, "title": name})
|
|
337
|
+
|
|
338
|
+
for result in results:
|
|
339
|
+
try:
|
|
340
|
+
url = result["url"]
|
|
341
|
+
title = result.get("title") or ""
|
|
342
|
+
|
|
343
|
+
context = "recents_al"
|
|
344
|
+
threshold = 60
|
|
345
|
+
recently_searched = shared_state.get_recently_searched(shared_state, context, threshold)
|
|
346
|
+
entry = recently_searched.get(url, {})
|
|
347
|
+
ts = entry.get("timestamp")
|
|
348
|
+
use_cache = ts and ts > datetime.now() - timedelta(seconds=threshold)
|
|
349
|
+
|
|
350
|
+
if use_cache and entry.get("html"):
|
|
351
|
+
debug(f"Using cached content for '{url}'")
|
|
352
|
+
data_html = entry["html"]
|
|
353
|
+
else:
|
|
354
|
+
entry = {"timestamp": datetime.now()}
|
|
355
|
+
data_html = fetch_via_requests_session(shared_state, method="GET", target_url=url, timeout=10).text
|
|
356
|
+
|
|
357
|
+
entry["html"] = data_html
|
|
358
|
+
recently_searched[url] = entry
|
|
359
|
+
shared_state.update(context, recently_searched)
|
|
360
|
+
|
|
361
|
+
content = BeautifulSoup(data_html, "html.parser")
|
|
362
|
+
|
|
363
|
+
# Find each download‐table and process it
|
|
364
|
+
release_id = 0
|
|
365
|
+
download_tabs = content.select("div[id^=download_]")
|
|
366
|
+
for tab in download_tabs:
|
|
367
|
+
release_id += 1
|
|
368
|
+
|
|
369
|
+
release_info = parse_info_from_download_item(tab, content, page_title=title,
|
|
370
|
+
release_type=valid_type, requested_episode=episode)
|
|
371
|
+
|
|
372
|
+
# Parse date
|
|
373
|
+
date_td = tab.select_one("tr:has(th>i.fa-calendar-alt) td.modified")
|
|
374
|
+
if date_td:
|
|
375
|
+
raw_date = date_td.get_text(strip=True)
|
|
376
|
+
try:
|
|
377
|
+
dt = datetime.strptime(raw_date, "%d.%m.%Y %H:%M")
|
|
378
|
+
date_str = dt.strftime("%a, %d %b %Y %H:%M:%S +0000")
|
|
379
|
+
except Exception:
|
|
380
|
+
date_str = ""
|
|
381
|
+
else:
|
|
382
|
+
date_str = (datetime.utcnow() - timedelta(hours=1)) \
|
|
383
|
+
.strftime("%a, %d %b %Y %H:%M:%S +0000")
|
|
384
|
+
|
|
385
|
+
# Parse filesize from the <tr> with <i class="fa-hdd">
|
|
386
|
+
size_td = tab.select_one("tr:has(th>i.fa-hdd) td")
|
|
387
|
+
mb = 0
|
|
388
|
+
if size_td:
|
|
389
|
+
size_text = size_td.get_text(strip=True)
|
|
390
|
+
candidates = re.findall(r'(\d+(\.\d+)?\s*[A-Za-z]+)', size_text)
|
|
391
|
+
if candidates:
|
|
392
|
+
size_string = candidates[-1][0]
|
|
393
|
+
try:
|
|
394
|
+
size_item = extract_size(size_string)
|
|
395
|
+
mb = shared_state.convert_to_mb(size_item)
|
|
396
|
+
except Exception as e:
|
|
397
|
+
debug(f"Error extracting size for {title}: {e}")
|
|
398
|
+
|
|
399
|
+
if episode:
|
|
400
|
+
try:
|
|
401
|
+
total_episodes = release_info.episode_max
|
|
402
|
+
if total_episodes:
|
|
403
|
+
if mb > 0:
|
|
404
|
+
mb = int(mb / total_episodes)
|
|
405
|
+
# Overwrite values so guessing the title only applies the requested episode
|
|
406
|
+
release_info.episode_min = int(episode)
|
|
407
|
+
release_info.episode_max = int(episode)
|
|
408
|
+
else: # if no total episode count - assume the requested episode is missing in the release
|
|
409
|
+
continue
|
|
410
|
+
except ValueError:
|
|
411
|
+
pass
|
|
412
|
+
|
|
413
|
+
# If no valid title was grabbed from Release Notes, guess the title
|
|
414
|
+
if release_info.release_title:
|
|
415
|
+
release_title = release_info.release_title
|
|
416
|
+
else:
|
|
417
|
+
release_title = guess_title(shared_state, title, release_info)
|
|
418
|
+
|
|
419
|
+
if season and release_info.season != int(season):
|
|
420
|
+
debug(f"Excluding {release_title} due to season mismatch: {release_info.season} != {season}")
|
|
421
|
+
continue
|
|
422
|
+
|
|
423
|
+
payload = urlsafe_b64encode(
|
|
424
|
+
f"{release_title}|{url}|{mirror}|{mb}|{release_id}|{imdb_id or ''}"
|
|
425
|
+
.encode("utf-8")
|
|
426
|
+
).decode("utf-8")
|
|
427
|
+
link = f"{shared_state.values['internal_address']}/download/?payload={payload}"
|
|
428
|
+
|
|
429
|
+
releases.append({
|
|
430
|
+
"details": {
|
|
431
|
+
"title": release_title,
|
|
432
|
+
"hostname": hostname,
|
|
433
|
+
"imdb_id": imdb_id,
|
|
434
|
+
"link": link,
|
|
435
|
+
"mirror": mirror,
|
|
436
|
+
"size": mb * 1024 * 1024,
|
|
437
|
+
"date": date_str,
|
|
438
|
+
"source": f"{url}#download_{release_id}"
|
|
439
|
+
},
|
|
440
|
+
"type": "protected"
|
|
441
|
+
})
|
|
442
|
+
|
|
443
|
+
except Exception as e:
|
|
444
|
+
info(f"{hostname}: error parsing search item: {e}")
|
|
445
|
+
|
|
446
|
+
elapsed = time.time() - start_time
|
|
447
|
+
debug(f"Time taken: {elapsed:.2f}s ({hostname})")
|
|
448
|
+
return releases
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
# Quasarr
|
|
3
|
+
# Project by https://github.com/rix1337
|
|
4
|
+
|
|
5
|
+
import html
|
|
6
|
+
import re
|
|
7
|
+
import time
|
|
8
|
+
from base64 import urlsafe_b64encode
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from urllib.parse import quote_plus
|
|
11
|
+
|
|
12
|
+
import requests
|
|
13
|
+
from bs4 import BeautifulSoup
|
|
14
|
+
|
|
15
|
+
from quasarr.providers.imdb_metadata import get_localized_title
|
|
16
|
+
from quasarr.providers.log import info, debug
|
|
17
|
+
|
|
18
|
+
hostname = "by"
|
|
19
|
+
supported_mirrors = ["rapidgator", "ddownload", "nitroflare"]
|
|
20
|
+
|
|
21
|
+
RESOLUTION_REGEX = re.compile(r"\d{3,4}p", re.I)
|
|
22
|
+
CODEC_REGEX = re.compile(r"x264|x265|h264|h265|hevc|avc", re.I)
|
|
23
|
+
XXX_REGEX = re.compile(r"\.xxx\.", re.I)
|
|
24
|
+
IMDB_REGEX = re.compile(r"imdb\.com/title/(tt\d+)")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def convert_to_rss_date(date_str):
|
|
28
|
+
"""
|
|
29
|
+
BY date format: 'dd.mm.yy HH:MM', e.g. '20.07.25 17:48'
|
|
30
|
+
"""
|
|
31
|
+
dt_obj = datetime.strptime(date_str, "%d.%m.%y %H:%M")
|
|
32
|
+
return dt_obj.strftime("%a, %d %b %Y %H:%M:%S +0000")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def extract_size(text):
|
|
36
|
+
m = re.match(r"(\d+(?:[.,]\d+)?)\s*([A-Za-z]+)", text)
|
|
37
|
+
if not m:
|
|
38
|
+
raise ValueError(f"Invalid size format: {text!r}")
|
|
39
|
+
size_str = m.group(1).replace(',', '.')
|
|
40
|
+
sizeunit = m.group(2)
|
|
41
|
+
size_float = float(size_str) # convert to float here
|
|
42
|
+
return {"size": size_float, "sizeunit": sizeunit}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _parse_posts(soup, shared_state, base_url, password, mirror_filter,
|
|
46
|
+
is_search=False, request_from=None, search_string=None,
|
|
47
|
+
season=None, episode=None):
|
|
48
|
+
releases = []
|
|
49
|
+
if not is_search:
|
|
50
|
+
feed_container = soup.find('table', class_='AUDIO_ITEMLIST') # it is actually called this way
|
|
51
|
+
candidates = []
|
|
52
|
+
if feed_container:
|
|
53
|
+
for tbl in feed_container.find_all('table'):
|
|
54
|
+
if tbl.find(string=re.compile(r"Erstellt am:")):
|
|
55
|
+
candidates.append(tbl)
|
|
56
|
+
items = candidates
|
|
57
|
+
else:
|
|
58
|
+
search_table = soup.find('table', class_='SEARCH_ITEMLIST')
|
|
59
|
+
items = []
|
|
60
|
+
if search_table:
|
|
61
|
+
items = [
|
|
62
|
+
tr for tr in search_table.find_all('tr')
|
|
63
|
+
if tr.find('p', class_='TITLE') and tr.find('p', class_='TITLE').find('a', href=True)
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
for entry in items:
|
|
67
|
+
if entry.find('table'):
|
|
68
|
+
continue # Skip header rows
|
|
69
|
+
try:
|
|
70
|
+
if not is_search:
|
|
71
|
+
table = entry
|
|
72
|
+
# title & source
|
|
73
|
+
try:
|
|
74
|
+
link_tag = table.find('th').find('a')
|
|
75
|
+
except AttributeError:
|
|
76
|
+
link_tag = table.find('a')
|
|
77
|
+
title = link_tag.get_text(strip=True)
|
|
78
|
+
if 'lazylibrarian' in request_from.lower():
|
|
79
|
+
# lazylibrarian can only detect specific date formats / issue numbering for magazines
|
|
80
|
+
title = shared_state.normalize_magazine_title(title)
|
|
81
|
+
else:
|
|
82
|
+
title = title.replace(" ", ".")
|
|
83
|
+
|
|
84
|
+
source = base_url + link_tag['href']
|
|
85
|
+
# extract date and size
|
|
86
|
+
date_str = size_str = None
|
|
87
|
+
for row in table.find_all('tr', height=True):
|
|
88
|
+
cols = row.find_all('td')
|
|
89
|
+
if len(cols) == 2:
|
|
90
|
+
label = cols[0].get_text(strip=True)
|
|
91
|
+
val = cols[1].get_text(strip=True)
|
|
92
|
+
if label.startswith('Erstellt am'):
|
|
93
|
+
date_str = val
|
|
94
|
+
elif label.startswith('Größe'):
|
|
95
|
+
size_str = val
|
|
96
|
+
published = convert_to_rss_date(date_str) if date_str else ''
|
|
97
|
+
size_info = extract_size(size_str) if size_str else {'size': '0', 'sizeunit': 'MB'}
|
|
98
|
+
mb = float(size_info['size'])
|
|
99
|
+
size_bytes = int(mb * 1024 * 1024)
|
|
100
|
+
imdb_id = None
|
|
101
|
+
else:
|
|
102
|
+
row = entry
|
|
103
|
+
title_tag = row.find('p', class_='TITLE').find('a')
|
|
104
|
+
title = title_tag.get_text(strip=True)
|
|
105
|
+
if 'lazylibrarian' in request_from.lower():
|
|
106
|
+
# lazylibrarian can only detect specific date formats / issue numbering for magazines
|
|
107
|
+
title = shared_state.normalize_magazine_title(title)
|
|
108
|
+
else:
|
|
109
|
+
title = title.replace(" ", ".")
|
|
110
|
+
if not (RESOLUTION_REGEX.search(title) or CODEC_REGEX.search(title)):
|
|
111
|
+
continue
|
|
112
|
+
|
|
113
|
+
if not shared_state.is_valid_release(title, request_from, search_string, season, episode):
|
|
114
|
+
continue
|
|
115
|
+
if XXX_REGEX.search(title) and 'xxx' not in search_string.lower():
|
|
116
|
+
continue
|
|
117
|
+
|
|
118
|
+
source = base_url + title_tag['href']
|
|
119
|
+
date_cell = row.find_all('td')[2]
|
|
120
|
+
date_str = date_cell.get_text(strip=True)
|
|
121
|
+
published = convert_to_rss_date(date_str)
|
|
122
|
+
size_bytes = 0
|
|
123
|
+
mb = 0
|
|
124
|
+
imdb_id = None
|
|
125
|
+
|
|
126
|
+
payload = urlsafe_b64encode(
|
|
127
|
+
f"{title}|{source}|{mirror_filter}|{mb}|{password}|{imdb_id}".encode()
|
|
128
|
+
).decode()
|
|
129
|
+
link = f"{shared_state.values['internal_address']}/download/?payload={payload}"
|
|
130
|
+
|
|
131
|
+
releases.append({
|
|
132
|
+
'details': {
|
|
133
|
+
'title': title,
|
|
134
|
+
'hostname': hostname,
|
|
135
|
+
'imdb_id': imdb_id,
|
|
136
|
+
'link': link,
|
|
137
|
+
'mirror': mirror_filter,
|
|
138
|
+
'size': size_bytes,
|
|
139
|
+
'date': published,
|
|
140
|
+
'source': source
|
|
141
|
+
},
|
|
142
|
+
'type': 'protected'
|
|
143
|
+
})
|
|
144
|
+
except Exception as e:
|
|
145
|
+
debug(f"Error parsing {hostname.upper()}: {e}")
|
|
146
|
+
continue
|
|
147
|
+
|
|
148
|
+
return releases
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def by_feed(shared_state, start_time, request_from, mirror=None):
|
|
152
|
+
by = shared_state.values['config']('Hostnames').get(hostname)
|
|
153
|
+
password = by
|
|
154
|
+
|
|
155
|
+
if "lazylibrarian" in request_from.lower():
|
|
156
|
+
feed_type = "?cat=71"
|
|
157
|
+
elif "radarr" in request_from.lower():
|
|
158
|
+
feed_type = "?cat=1"
|
|
159
|
+
else:
|
|
160
|
+
feed_type = "?cat=2"
|
|
161
|
+
|
|
162
|
+
base_url = f"https://{by}"
|
|
163
|
+
url = f"{base_url}/{feed_type}"
|
|
164
|
+
headers = {'User-Agent': shared_state.values['user_agent']}
|
|
165
|
+
try:
|
|
166
|
+
html_doc = requests.get(url, headers=headers, timeout=10).content
|
|
167
|
+
soup = BeautifulSoup(html_doc, 'html.parser')
|
|
168
|
+
releases = _parse_posts(soup, shared_state, base_url, password, request_from=request_from, mirror_filter=mirror)
|
|
169
|
+
except Exception as e:
|
|
170
|
+
info(f"Error loading {hostname.upper()} feed: {e}")
|
|
171
|
+
releases = []
|
|
172
|
+
debug(f"Time taken: {time.time() - start_time:.2f}s ({hostname})")
|
|
173
|
+
return releases
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def by_search(shared_state, start_time, request_from, search_string, mirror=None, season=None, episode=None):
|
|
177
|
+
by = shared_state.values['config']('Hostnames').get(hostname)
|
|
178
|
+
password = by
|
|
179
|
+
|
|
180
|
+
imdb_id = shared_state.is_imdb_id(search_string)
|
|
181
|
+
if imdb_id:
|
|
182
|
+
title = get_localized_title(shared_state, imdb_id, 'de')
|
|
183
|
+
if not title:
|
|
184
|
+
info(f"Could not extract title from IMDb-ID {imdb_id}")
|
|
185
|
+
return []
|
|
186
|
+
search_string = html.unescape(title)
|
|
187
|
+
|
|
188
|
+
base_url = f"https://{by}"
|
|
189
|
+
q = quote_plus(search_string)
|
|
190
|
+
url = f"{base_url}/?q={q}"
|
|
191
|
+
headers = {'User-Agent': shared_state.values['user_agent']}
|
|
192
|
+
try:
|
|
193
|
+
html_doc = requests.get(url, headers=headers, timeout=10).content
|
|
194
|
+
soup = BeautifulSoup(html_doc, 'html.parser')
|
|
195
|
+
releases = _parse_posts(
|
|
196
|
+
soup, shared_state, base_url, password, mirror_filter=mirror,
|
|
197
|
+
is_search=True, request_from=request_from,
|
|
198
|
+
search_string=search_string, season=season, episode=episode
|
|
199
|
+
)
|
|
200
|
+
except Exception as e:
|
|
201
|
+
info(f"Error loading {hostname.upper()} search: {e}")
|
|
202
|
+
releases = []
|
|
203
|
+
debug(f"Time taken: {time.time() - start_time:.2f}s ({hostname})")
|
|
204
|
+
return releases
|