quasarr 1.3.5__py3-none-any.whl → 1.20.4__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 +157 -56
- quasarr/api/__init__.py +141 -36
- quasarr/api/arr/__init__.py +197 -78
- quasarr/api/captcha/__init__.py +897 -42
- quasarr/api/config/__init__.py +23 -0
- quasarr/api/sponsors_helper/__init__.py +84 -22
- quasarr/api/statistics/__init__.py +196 -0
- quasarr/downloads/__init__.py +237 -434
- quasarr/downloads/linkcrypters/al.py +237 -0
- quasarr/downloads/linkcrypters/filecrypt.py +178 -31
- quasarr/downloads/linkcrypters/hide.py +123 -0
- quasarr/downloads/packages/__init__.py +461 -0
- quasarr/downloads/sources/al.py +697 -0
- quasarr/downloads/sources/by.py +106 -0
- quasarr/downloads/sources/dd.py +6 -78
- quasarr/downloads/sources/dj.py +7 -0
- quasarr/downloads/sources/dt.py +1 -1
- quasarr/downloads/sources/dw.py +2 -2
- quasarr/downloads/sources/he.py +112 -0
- quasarr/downloads/sources/mb.py +47 -0
- quasarr/downloads/sources/nk.py +51 -0
- quasarr/downloads/sources/nx.py +36 -81
- quasarr/downloads/sources/sf.py +27 -4
- quasarr/downloads/sources/sj.py +7 -0
- quasarr/downloads/sources/sl.py +90 -0
- quasarr/downloads/sources/wd.py +110 -0
- quasarr/providers/cloudflare.py +204 -0
- quasarr/providers/html_images.py +20 -0
- quasarr/providers/html_templates.py +210 -108
- quasarr/providers/imdb_metadata.py +15 -2
- quasarr/providers/myjd_api.py +36 -5
- quasarr/providers/notifications.py +30 -5
- quasarr/providers/obfuscated.py +35 -0
- quasarr/providers/sessions/__init__.py +0 -0
- quasarr/providers/sessions/al.py +286 -0
- quasarr/providers/sessions/dd.py +78 -0
- quasarr/providers/sessions/nx.py +76 -0
- quasarr/providers/shared_state.py +368 -23
- quasarr/providers/statistics.py +154 -0
- quasarr/providers/version.py +60 -1
- quasarr/search/__init__.py +112 -36
- quasarr/search/sources/al.py +448 -0
- quasarr/search/sources/by.py +203 -0
- quasarr/search/sources/dd.py +17 -6
- quasarr/search/sources/dj.py +213 -0
- quasarr/search/sources/dt.py +37 -7
- quasarr/search/sources/dw.py +27 -47
- quasarr/search/sources/fx.py +27 -29
- 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 +22 -6
- quasarr/search/sources/sf.py +143 -151
- quasarr/search/sources/sj.py +213 -0
- quasarr/search/sources/sl.py +246 -0
- quasarr/search/sources/wd.py +208 -0
- quasarr/storage/config.py +20 -4
- quasarr/storage/setup.py +224 -56
- quasarr-1.20.4.dist-info/METADATA +304 -0
- quasarr-1.20.4.dist-info/RECORD +72 -0
- {quasarr-1.3.5.dist-info → quasarr-1.20.4.dist-info}/WHEEL +1 -1
- quasarr/providers/tvmaze_metadata.py +0 -23
- quasarr-1.3.5.dist-info/METADATA +0 -174
- quasarr-1.3.5.dist-info/RECORD +0 -43
- {quasarr-1.3.5.dist-info → quasarr-1.20.4.dist-info}/entry_points.txt +0 -0
- {quasarr-1.3.5.dist-info → quasarr-1.20.4.dist-info}/licenses/LICENSE +0 -0
- {quasarr-1.3.5.dist-info → quasarr-1.20.4.dist-info}/top_level.txt +0 -0
quasarr/providers/version.py
CHANGED
|
@@ -4,9 +4,68 @@
|
|
|
4
4
|
|
|
5
5
|
import re
|
|
6
6
|
|
|
7
|
+
import requests
|
|
8
|
+
|
|
7
9
|
|
|
8
10
|
def get_version():
|
|
9
|
-
return "1.
|
|
11
|
+
return "1.20.4"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def get_latest_version():
|
|
15
|
+
"""
|
|
16
|
+
Query GitHub API for the latest release of the Quasarr repository.
|
|
17
|
+
Returns the tag name string (e.g. "1.5.0" or "1.4.2a1").
|
|
18
|
+
Raises RuntimeError on HTTP errors.
|
|
19
|
+
"""
|
|
20
|
+
api_url = "https://api.github.com/repos/rix1337/Quasarr/releases/latest"
|
|
21
|
+
resp = requests.get(api_url, headers={"Accept": "application/vnd.github.v3+json"})
|
|
22
|
+
if resp.status_code != 200:
|
|
23
|
+
raise RuntimeError(f"GitHub API error: {resp.status_code} {resp.text}")
|
|
24
|
+
data = resp.json()
|
|
25
|
+
tag = data.get("tag_name") or data.get("name")
|
|
26
|
+
if not tag:
|
|
27
|
+
raise RuntimeError("Could not find tag_name in GitHub response")
|
|
28
|
+
return tag
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _version_key(v):
|
|
32
|
+
"""
|
|
33
|
+
Normalize a version string into a tuple for comparisons.
|
|
34
|
+
E.g. "1.4.2a3" -> (1, 4, 2, 'a', 3), "1.4.2" -> (1, 4, 2, '', 0)
|
|
35
|
+
"""
|
|
36
|
+
m = re.match(r"^([0-9]+(?:\.[0-9]+)*)([a-z]?)([0-9]*)$", v)
|
|
37
|
+
if not m:
|
|
38
|
+
clean = re.sub(r"[^\d.]", "", v)
|
|
39
|
+
parts = clean.split(".")
|
|
40
|
+
nums = tuple(int(x) for x in parts if x.isdigit())
|
|
41
|
+
return nums + ("", 0)
|
|
42
|
+
base, alpha, num = m.groups()
|
|
43
|
+
nums = tuple(int(x) for x in base.split("."))
|
|
44
|
+
suffix_num = int(num) if num.isdigit() else 0
|
|
45
|
+
return nums + (alpha or "", suffix_num)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def is_newer(latest, current):
|
|
49
|
+
"""
|
|
50
|
+
Return True if latest > current using semantic+alpha comparison.
|
|
51
|
+
"""
|
|
52
|
+
return _version_key(latest) > _version_key(current)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def newer_version_available():
|
|
56
|
+
"""
|
|
57
|
+
Check local vs. GitHub latest version.
|
|
58
|
+
Returns the latest version string if a newer release is available,
|
|
59
|
+
otherwise returns None.
|
|
60
|
+
"""
|
|
61
|
+
try:
|
|
62
|
+
current = get_version()
|
|
63
|
+
latest = get_latest_version()
|
|
64
|
+
except:
|
|
65
|
+
raise
|
|
66
|
+
if is_newer(latest, current):
|
|
67
|
+
return latest
|
|
68
|
+
return None
|
|
10
69
|
|
|
11
70
|
|
|
12
71
|
def create_version_file():
|
quasarr/search/__init__.py
CHANGED
|
@@ -5,61 +5,137 @@
|
|
|
5
5
|
import time
|
|
6
6
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
7
7
|
|
|
8
|
-
from quasarr.providers.log import info
|
|
9
|
-
from quasarr.search.sources.
|
|
10
|
-
from quasarr.search.sources.
|
|
8
|
+
from quasarr.providers.log import info, debug
|
|
9
|
+
from quasarr.search.sources.al import al_feed, al_search
|
|
10
|
+
from quasarr.search.sources.by import by_feed, by_search
|
|
11
|
+
from quasarr.search.sources.dd import dd_search, dd_feed
|
|
12
|
+
from quasarr.search.sources.dj import dj_search, dj_feed
|
|
11
13
|
from quasarr.search.sources.dt import dt_feed, dt_search
|
|
14
|
+
from quasarr.search.sources.dw import dw_feed, dw_search
|
|
12
15
|
from quasarr.search.sources.fx import fx_feed, fx_search
|
|
16
|
+
from quasarr.search.sources.he import he_feed, he_search
|
|
17
|
+
from quasarr.search.sources.mb import mb_feed, mb_search
|
|
18
|
+
from quasarr.search.sources.nk import nk_feed, nk_search
|
|
13
19
|
from quasarr.search.sources.nx import nx_feed, nx_search
|
|
14
20
|
from quasarr.search.sources.sf import sf_feed, sf_search
|
|
21
|
+
from quasarr.search.sources.sj import sj_search, sj_feed
|
|
22
|
+
from quasarr.search.sources.sl import sl_feed, sl_search
|
|
23
|
+
from quasarr.search.sources.wd import wd_feed, wd_search
|
|
15
24
|
|
|
16
25
|
|
|
17
|
-
def get_search_results(shared_state, request_from,
|
|
26
|
+
def get_search_results(shared_state, request_from, imdb_id="", search_phrase="", mirror=None, season="", episode=""):
|
|
18
27
|
results = []
|
|
19
28
|
|
|
29
|
+
if imdb_id and not imdb_id.startswith('tt'):
|
|
30
|
+
imdb_id = f'tt{imdb_id}'
|
|
31
|
+
|
|
32
|
+
docs_search = "lazylibrarian" in request_from.lower()
|
|
33
|
+
|
|
34
|
+
al = shared_state.values["config"]("Hostnames").get("al")
|
|
35
|
+
by = shared_state.values["config"]("Hostnames").get("by")
|
|
20
36
|
dd = shared_state.values["config"]("Hostnames").get("dd")
|
|
21
37
|
dt = shared_state.values["config"]("Hostnames").get("dt")
|
|
38
|
+
dj = shared_state.values["config"]("Hostnames").get("dj")
|
|
22
39
|
dw = shared_state.values["config"]("Hostnames").get("dw")
|
|
23
40
|
fx = shared_state.values["config"]("Hostnames").get("fx")
|
|
41
|
+
he = shared_state.values["config"]("Hostnames").get("he")
|
|
42
|
+
mb = shared_state.values["config"]("Hostnames").get("mb")
|
|
43
|
+
nk = shared_state.values["config"]("Hostnames").get("nk")
|
|
24
44
|
nx = shared_state.values["config"]("Hostnames").get("nx")
|
|
25
45
|
sf = shared_state.values["config"]("Hostnames").get("sf")
|
|
46
|
+
sj = shared_state.values["config"]("Hostnames").get("sj")
|
|
47
|
+
sl = shared_state.values["config"]("Hostnames").get("sl")
|
|
48
|
+
wd = shared_state.values["config"]("Hostnames").get("wd")
|
|
26
49
|
|
|
27
50
|
start_time = time.time()
|
|
28
51
|
|
|
29
52
|
functions = []
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
53
|
+
|
|
54
|
+
# Radarr/Sonarr use imdb_id for searches
|
|
55
|
+
imdb_map = [
|
|
56
|
+
(al, al_search),
|
|
57
|
+
(by, by_search),
|
|
58
|
+
(dd, dd_search),
|
|
59
|
+
(dt, dt_search),
|
|
60
|
+
(dj, dj_search),
|
|
61
|
+
(dw, dw_search),
|
|
62
|
+
(fx, fx_search),
|
|
63
|
+
(he, he_search),
|
|
64
|
+
(mb, mb_search),
|
|
65
|
+
(nk, nk_search),
|
|
66
|
+
(nx, nx_search),
|
|
67
|
+
(sf, sf_search),
|
|
68
|
+
(sj, sj_search),
|
|
69
|
+
(sl, sl_search),
|
|
70
|
+
(wd, wd_search),
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
# LazyLibrarian uses search_phrase for searches
|
|
74
|
+
phrase_map = [
|
|
75
|
+
(by, by_search),
|
|
76
|
+
(dt, dt_search),
|
|
77
|
+
(nx, nx_search),
|
|
78
|
+
(sl, sl_search),
|
|
79
|
+
(wd, wd_search),
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
# Feed searches omit imdb_id and search_phrase
|
|
83
|
+
feed_map = [
|
|
84
|
+
(al, al_feed),
|
|
85
|
+
(by, by_feed),
|
|
86
|
+
(dd, dd_feed),
|
|
87
|
+
(dj, dj_feed),
|
|
88
|
+
(dt, dt_feed),
|
|
89
|
+
(dw, dw_feed),
|
|
90
|
+
(fx, fx_feed),
|
|
91
|
+
(he, he_feed),
|
|
92
|
+
(mb, mb_feed),
|
|
93
|
+
(nk, nk_feed),
|
|
94
|
+
(nx, nx_feed),
|
|
95
|
+
(sf, sf_feed),
|
|
96
|
+
(sj, sj_feed),
|
|
97
|
+
(sl, sl_feed),
|
|
98
|
+
(wd, wd_feed),
|
|
99
|
+
]
|
|
100
|
+
|
|
101
|
+
if imdb_id: # only Radarr/Sonarr are using imdb_id
|
|
102
|
+
args, kwargs = (
|
|
103
|
+
(shared_state, start_time, request_from, imdb_id),
|
|
104
|
+
{'mirror': mirror, 'season': season, 'episode': episode}
|
|
105
|
+
)
|
|
106
|
+
for flag, func in imdb_map:
|
|
107
|
+
if flag:
|
|
108
|
+
functions.append(lambda f=func, a=args, kw=kwargs: f(*a, **kw))
|
|
109
|
+
|
|
110
|
+
elif search_phrase and docs_search: # only LazyLibrarian is allowed to use search_phrase
|
|
111
|
+
args, kwargs = (
|
|
112
|
+
(shared_state, start_time, request_from, search_phrase),
|
|
113
|
+
{'mirror': mirror, 'season': season, 'episode': episode}
|
|
114
|
+
)
|
|
115
|
+
for flag, func in phrase_map:
|
|
116
|
+
if flag:
|
|
117
|
+
functions.append(lambda f=func, a=args, kw=kwargs: f(*a, **kw))
|
|
118
|
+
|
|
119
|
+
elif search_phrase:
|
|
120
|
+
debug(
|
|
121
|
+
f"Search phrase '{search_phrase}' is not supported for {request_from}. Only LazyLibrarian can use search phrases.")
|
|
122
|
+
|
|
48
123
|
else:
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
124
|
+
args, kwargs = (
|
|
125
|
+
(shared_state, start_time, request_from),
|
|
126
|
+
{'mirror': mirror}
|
|
127
|
+
)
|
|
128
|
+
for flag, func in feed_map:
|
|
129
|
+
if flag:
|
|
130
|
+
functions.append(lambda f=func, a=args, kw=kwargs: f(*a, **kw))
|
|
131
|
+
|
|
132
|
+
if imdb_id:
|
|
133
|
+
stype = f'IMDb-ID "{imdb_id}"'
|
|
134
|
+
elif search_phrase:
|
|
135
|
+
stype = f'Search-Phrase "{search_phrase}"'
|
|
136
|
+
else:
|
|
137
|
+
stype = "feed search"
|
|
138
|
+
|
|
63
139
|
info(f'Starting {len(functions)} search functions for {stype}... This may take some time.')
|
|
64
140
|
|
|
65
141
|
with ThreadPoolExecutor() as executor:
|
|
@@ -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
|