Skip to content

Commit 282d9d3

Browse files
CopilotZzackllack
andcommitted
Implement multi-site Torznab API support
- Update Torznab search and tvsearch endpoints to handle (site, slug) tuples - Add site parameter to probe_episode_quality - Update availability caching with site parameter - Site-specific language defaults (English Dub for s.to) - Site-aware GUID prefixes (aw: vs sto:) - Update tests to match new multi-site API - All 32 tests passing Co-authored-by: Zzackllack <149129501+Zzackllack@users.noreply.github.com>
1 parent 0fb05d0 commit 282d9d3

File tree

5 files changed

+94
-72
lines changed

5 files changed

+94
-72
lines changed

app/api/torznab/api.py

Lines changed: 53 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -85,29 +85,37 @@ def torznab_api(
8585
)
8686
elif q_str:
8787
# Preview search: S01E01 for requested series
88-
slug = tn._slug_from_query(q_str)
89-
if slug:
90-
display_title = tn.resolve_series_title(slug) or q_str
88+
result = tn._slug_from_query(q_str)
89+
if result:
90+
site_found, slug = result
91+
display_title = tn.resolve_series_title(slug, site_found) or q_str
9192
season_i, ep_i = 1, 1
9293
cached_langs = tn.list_available_languages_cached(
93-
session, slug=slug, season=season_i, episode=ep_i
94+
session, slug=slug, season=season_i, episode=ep_i, site=site_found
9495
)
96+
97+
# Default languages based on site
98+
if site_found == "s.to":
99+
default_langs = ["German Dub", "English Dub", "German Sub"]
100+
else:
101+
default_langs = ["German Dub", "German Sub", "English Sub"]
102+
95103
candidate_langs: List[str] = (
96104
cached_langs
97105
if cached_langs
98-
else ["German Dub", "German Sub", "English Sub"]
106+
else default_langs
99107
)
100108
now = datetime.now(timezone.utc)
101109
count = 0
102110
for lang in candidate_langs:
103111
try:
104112
available, h, vc, prov, _info = tn.probe_episode_quality(
105-
slug=slug, season=season_i, episode=ep_i, language=lang
113+
slug=slug, season=season_i, episode=ep_i, language=lang, site=site_found
106114
)
107115
except Exception as e:
108116
logger.error(
109-
"Error probing preview quality for slug={}, S{}E{}, lang={}: {}".format(
110-
slug, season_i, ep_i, lang, e
117+
"Error probing preview quality for slug={}, S{}E{}, lang={}, site={}: {}".format(
118+
slug, season_i, ep_i, lang, site_found, e
111119
)
112120
)
113121
continue
@@ -123,11 +131,12 @@ def torznab_api(
123131
vcodec=vc,
124132
provider=prov,
125133
extra=None,
134+
site=site_found,
126135
)
127136
except Exception as e:
128137
logger.error(
129-
"Error upserting preview availability for slug={}, S{}E{}, lang={}: {}".format(
130-
slug, season_i, ep_i, lang, e
138+
"Error upserting preview availability for slug={}, S{}E{}, lang={}, site={}: {}".format(
139+
slug, season_i, ep_i, lang, site_found, e
131140
)
132141
)
133142
if not available:
@@ -139,6 +148,7 @@ def torznab_api(
139148
height=h,
140149
vcodec=vc,
141150
language=lang,
151+
site=site_found,
142152
)
143153
try:
144154
magnet = tn.build_magnet(
@@ -148,14 +158,17 @@ def torznab_api(
148158
episode=ep_i,
149159
language=lang,
150160
provider=prov,
161+
site=site_found,
151162
)
152163
except Exception as e:
153164
logger.error(
154165
f"Error building magnet for release '{release_title}': {e}"
155166
)
156167
continue
157168

158-
guid = f"aw:{slug}:s{season_i}e{ep_i}:{lang}"
169+
# Use site-appropriate prefix for GUID
170+
prefix = "aw" if site_found == "aniworld.to" else "sto"
171+
guid = f"{prefix}:{slug}:s{season_i}e{ep_i}:{lang}"
159172
try:
160173
_build_item(
161174
channel=channel,
@@ -201,25 +214,33 @@ def torznab_api(
201214
logger.debug(
202215
f"Searching for slug for query '{q_str}' (season={season_i}, ep={ep_i})"
203216
)
204-
slug = tn._slug_from_query(q_str)
205-
if not slug:
217+
result = tn._slug_from_query(q_str)
218+
if not result:
206219
logger.warning(f"No slug found for query '{q_str}'. Returning empty RSS feed.")
207220
rss, _channel = _rss_root()
208221
xml = ET.tostring(rss, encoding="utf-8", xml_declaration=True).decode("utf-8")
209222
return Response(content=xml, media_type="application/rss+xml; charset=utf-8")
210223

211-
display_title = tn.resolve_series_title(slug) or q_str
212-
logger.debug(f"Resolved display title: '{display_title}' for slug '{slug}'")
224+
site_found, slug = result
225+
display_title = tn.resolve_series_title(slug, site_found) or q_str
226+
logger.debug(f"Resolved display title: '{display_title}' for slug '{slug}' on site '{site_found}'")
213227

214228
# language candidates from cache or defaults
215229
cached_langs = tn.list_available_languages_cached(
216-
session, slug=slug, season=season_i, episode=ep_i
230+
session, slug=slug, season=season_i, episode=ep_i, site=site_found
217231
)
232+
233+
# Default languages based on site
234+
if site_found == "s.to":
235+
default_langs = ["German Dub", "English Dub", "German Sub"]
236+
else:
237+
default_langs = ["German Dub", "German Sub", "English Sub"]
238+
218239
candidate_langs: List[str] = (
219-
cached_langs if cached_langs else ["German Dub", "German Sub", "English Sub"]
240+
cached_langs if cached_langs else default_langs
220241
)
221242
logger.debug(
222-
f"Candidate languages for slug '{slug}', season {season_i}, episode {ep_i}: {candidate_langs}"
243+
f"Candidate languages for slug '{slug}', season {season_i}, episode {ep_i}, site '{site_found}': {candidate_langs}"
223244
)
224245

225246
rss, channel = _rss_root()
@@ -231,12 +252,12 @@ def torznab_api(
231252
# check cache per language
232253
try:
233254
rec = tn.get_availability(
234-
session, slug=slug, season=season_i, episode=ep_i, language=lang
255+
session, slug=slug, season=season_i, episode=ep_i, language=lang, site=site_found
235256
)
236257
except Exception as e:
237258
logger.error(
238-
"Error reading availability cache for slug={}, S{}E{}, lang={}: {}".format(
239-
slug, season_i, ep_i, lang, e
259+
"Error reading availability cache for slug={}, S{}E{}, lang={}, site={}: {}".format(
260+
slug, season_i, ep_i, lang, site_found, e
240261
)
241262
)
242263
rec = None
@@ -252,16 +273,16 @@ def torznab_api(
252273
vcodec = rec.vcodec
253274
prov_used = rec.provider
254275
logger.debug(
255-
f"Using cached availability for {slug} S{season_i}E{ep_i} {lang}: h={height}, vcodec={vcodec}, prov={prov_used}"
276+
f"Using cached availability for {slug} S{season_i}E{ep_i} {lang} on {site_found}: h={height}, vcodec={vcodec}, prov={prov_used}"
256277
)
257278
else:
258279
try:
259280
available, height, vcodec, prov_used, _info = tn.probe_episode_quality(
260-
slug=slug, season=season_i, episode=ep_i, language=lang
281+
slug=slug, season=season_i, episode=ep_i, language=lang, site=site_found
261282
)
262283
except Exception as e:
263284
logger.error(
264-
f"Error probing quality for slug={slug}, S{season_i}E{ep_i}, lang={lang}: {e}"
285+
f"Error probing quality for slug={slug}, S{season_i}E{ep_i}, lang={lang}, site={site_found}: {e}"
265286
)
266287
available = False
267288

@@ -277,15 +298,16 @@ def torznab_api(
277298
vcodec=vcodec,
278299
provider=prov_used,
279300
extra=None,
301+
site=site_found,
280302
)
281303
except Exception as e:
282304
logger.error(
283-
f"Error upserting availability for slug={slug}, S{season_i}E{ep_i}, lang={lang}: {e}"
305+
f"Error upserting availability for slug={slug}, S{season_i}E{ep_i}, lang={lang}, site={site_found}: {e}"
284306
)
285307

286308
if not available:
287309
logger.debug(
288-
f"Language '{lang}' currently not available for {slug} S{season_i}E{ep_i}. Skipping."
310+
f"Language '{lang}' currently not available for {slug} S{season_i}E{ep_i} on {site_found}. Skipping."
289311
)
290312
continue
291313

@@ -297,6 +319,7 @@ def torznab_api(
297319
height=height,
298320
vcodec=vcodec,
299321
language=lang,
322+
site=site_found,
300323
)
301324
logger.debug(f"Built release title: '{release_title}'")
302325

@@ -308,12 +331,15 @@ def torznab_api(
308331
episode=ep_i,
309332
language=lang,
310333
provider=prov_used,
334+
site=site_found,
311335
)
312336
except Exception as e:
313337
logger.error(f"Error building magnet for release '{release_title}': {e}")
314338
continue
315339

316-
guid = f"aw:{slug}:s{season_i}e{ep_i}:{lang}"
340+
# Use site-appropriate prefix for GUID
341+
prefix = "aw" if site_found == "aniworld.to" else "sto"
342+
guid = f"{prefix}:{slug}:s{season_i}e{ep_i}:{lang}"
317343

318344
try:
319345
_build_item(

app/api/torznab/utils.py

Lines changed: 19 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -70,42 +70,28 @@ def _normalize_tokens(s: str) -> List[str]:
7070
return "".join(ch.lower() if ch.isalnum() else " " for ch in s).split()
7171

7272

73-
def _slug_from_query(q: str) -> Optional[str]:
74-
"""Map free-text query -> slug using main and alternative titles.
75-
76-
Import from the torznab package namespace so tests that monkeypatch
77-
`app.api.torznab.load_or_refresh_index` affect this function too.
73+
def _slug_from_query(q: str, site: Optional[str] = None) -> Optional[Tuple[str, str]]:
74+
"""Map free-text query -> (site, slug) using main and alternative titles.
75+
76+
If site is specified, only searches that site. Otherwise searches all enabled sites.
77+
Returns (site, slug) tuple or None.
78+
79+
Import from the title_resolver module for the actual implementation.
7880
"""
79-
logger.debug(f"Resolving slug from query: '{q}'")
80-
import app.api.torznab as tn
81-
82-
index = tn.load_or_refresh_index() # slug -> display title
83-
alts = tn.load_or_refresh_alternatives() # slug -> [titles]
84-
q_tokens = set(_normalize_tokens(q))
85-
best_slug: Optional[str] = None
86-
best_score = 0
87-
88-
for s, title in index.items():
89-
candidates: List[str] = [title]
90-
if s in alts and alts[s]:
91-
candidates.extend(alts[s])
92-
local_best = 0
93-
for cand in candidates:
94-
t_tokens = set(_normalize_tokens(cand))
95-
inter = len(q_tokens & t_tokens)
96-
if inter > local_best:
97-
local_best = inter
98-
if local_best > best_score:
99-
best_score = local_best
100-
best_slug = s
101-
102-
if not best_slug:
103-
logger.warning(f"No slug match found for query: '{q}'")
104-
else:
81+
logger.debug(f"Resolving slug from query: '{q}', site filter: {site}")
82+
from app.utils.title_resolver import slug_from_query
83+
84+
# Use the new multi-site slug_from_query
85+
result = slug_from_query(q, site)
86+
if result:
87+
site_found, slug_found = result
10588
logger.debug(
106-
f"Best slug match for '{q}' is '{best_slug}' with score {best_score}"
89+
f"Best match for '{q}' is slug '{slug_found}' on site '{site_found}'"
10790
)
108-
return best_slug
91+
return (site_found, slug_found)
92+
else:
93+
logger.warning(f"No slug match found for query: '{q}'")
94+
return None
10995

11096

11197
def _add_torznab_attr(item: ET.Element, name: str, value: str) -> None:

app/utils/probe_quality.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,16 +58,17 @@ def probe_episode_quality(
5858
language: str,
5959
preferred_provider: Optional[str] = None,
6060
timeout: float = 6.0,
61+
site: str = "aniworld.to",
6162
) -> tuple[bool, Optional[int], Optional[str], Optional[str], Dict[str, Any] | None]:
6263
"""
6364
Gibt zurück: (available, height, vcodec, provider_used, raw_info)
6465
- available=False, wenn kein Provider/Language funktioniert.
6566
"""
6667
logger.info(
6768
f"Probing episode quality for slug={slug}, season={season}, episode={episode}, language={language}, "
68-
f"preferred_provider={preferred_provider}, timeout={timeout}"
69+
f"preferred_provider={preferred_provider}, timeout={timeout}, site={site}"
6970
)
70-
ep = build_episode(slug=slug, season=season, episode=episode)
71+
ep = build_episode(slug=slug, season=season, episode=episode, site=site)
7172
logger.debug(f"Built episode object: {ep}")
7273
candidates: List[str] = []
7374
if preferred_provider:

tests/test_torznab.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,27 +24,28 @@ class Rec:
2424
vcodec = "h264"
2525
provider = "prov"
2626

27-
monkeypatch.setattr(tn, "_slug_from_query", lambda q: "slug")
28-
monkeypatch.setattr(tn, "resolve_series_title", lambda slug: "Series")
27+
# Return (site, slug) tuple for new multi-site API
28+
monkeypatch.setattr(tn, "_slug_from_query", lambda q, site=None: ("aniworld.to", "slug"))
29+
monkeypatch.setattr(tn, "resolve_series_title", lambda slug, site="aniworld.to": "Series")
2930
monkeypatch.setattr(
3031
tn,
3132
"list_available_languages_cached",
32-
lambda session, slug, season, episode: ["German Dub"],
33+
lambda session, slug, season, episode, site="aniworld.to": ["German Dub"],
3334
)
3435
monkeypatch.setattr(
3536
tn,
3637
"get_availability",
37-
lambda session, slug, season, episode, language: Rec(),
38+
lambda session, slug, season, episode, language, site="aniworld.to": Rec(),
3839
)
3940
monkeypatch.setattr(
4041
tn,
4142
"build_release_name",
42-
lambda series_title, season, episode, height, vcodec, language: "Title",
43+
lambda series_title, season, episode, height, vcodec, language, site="aniworld.to": "Title",
4344
)
4445
monkeypatch.setattr(
4546
tn,
4647
"build_magnet",
47-
lambda title, slug, season, episode, language, provider: "magnet:?xt=urn:btih:test&dn=Title&aw_slug=slug&aw_s=1&aw_e=1&aw_lang=German+Dub",
48+
lambda title, slug, season, episode, language, provider, site="aniworld.to": "magnet:?xt=urn:btih:test&dn=Title&aw_slug=slug&aw_s=1&aw_e=1&aw_lang=German+Dub&aw_site=aniworld.to",
4849
)
4950

5051
resp = client.get(

tests/test_torznab_utils.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,16 @@ def test_caps_xml_parses():
88

99

1010
def test_slug_from_query_basic(monkeypatch):
11-
from app.api import torznab as tn
11+
from app.api.torznab import utils as torznab_utils
12+
from app import utils as app_utils
1213

13-
monkeypatch.setattr(tn, "load_or_refresh_index", lambda: {"slug": "My Title"})
14-
assert tn._slug_from_query("My Title") == "slug"
15-
assert tn._slug_from_query("Unknown") is None
14+
# Mock the title_resolver slug_from_query
15+
def mock_slug_from_query(q, site=None):
16+
if "My Title" in q:
17+
return ("aniworld.to", "slug")
18+
return None
19+
20+
monkeypatch.setattr(app_utils.title_resolver, "slug_from_query", mock_slug_from_query)
21+
result = torznab_utils._slug_from_query("My Title")
22+
assert result == ("aniworld.to", "slug")
23+
assert torznab_utils._slug_from_query("Unknown") is None

0 commit comments

Comments
 (0)