Skip to content

Commit c991d77

Browse files
📝 Add docstrings to copilot/add-s-to-catalogue-support
Docstrings generation was requested by @Zzackllack. * #8 (comment) The following files were modified: * `app/api/qbittorrent/torrents.py` * `app/api/torznab/api.py` * `app/api/torznab/utils.py` * `app/core/downloader.py` * `app/core/scheduler.py` * `app/db/models.py` * `app/utils/magnet.py` * `app/utils/naming.py` * `app/utils/probe_quality.py` * `app/utils/title_resolver.py` * `tests/test_torznab_utils.py`
1 parent 282d9d3 commit c991d77

File tree

11 files changed

+390
-33
lines changed

11 files changed

+390
-33
lines changed

app/api/qbittorrent/torrents.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,24 @@ def torrents_add(
3737
paused: Optional[bool] = Form(default=False),
3838
tags: Optional[str] = Form(default=None),
3939
):
40-
"""Sonarr posts magnet URL(s) here. We accept the first one."""
40+
"""
41+
Accept a Sonarr POST of magnet URL(s), schedule the download for the first magnet, and record a ClientTask.
42+
43+
Parses the first magnet line from `urls`, extracts metadata (slug, season, episode, language, site, name, and torrent hash), schedules a download job, and upserts a ClientTask with the job and save path. If `savepath` is not provided, the configured download directory is used; if a public save path is configured it is stored as the task's save path.
44+
45+
Parameters:
46+
urls (str): One or more magnet URLs separated by newlines; only the first line is processed.
47+
savepath (Optional[str]): Optional explicit save directory for the torrent. If omitted, the configured DOWNLOAD_DIR is used; a configured QBIT_PUBLIC_SAVE_PATH will be preferred when stored.
48+
category (Optional[str]): Optional category to record with the task.
49+
paused (Optional[bool]): If true, the created task is marked as queued instead of downloading.
50+
tags (Optional[str]): Optional tags provided by the caller (accepted but not otherwise interpreted here).
51+
52+
Returns:
53+
PlainTextResponse: A plain-text response with the body "Ok." on success.
54+
55+
Raises:
56+
HTTPException: Raised with status 400 when `urls` is empty or missing.
57+
"""
4158
logger.info(f"Received request to add torrent(s): {urls}")
4259
if not urls:
4360
logger.warning("No URLs provided in torrents_add.")
@@ -333,4 +350,4 @@ def torrents_delete(
333350
logger.warning(f"Exception during file deletion for hash {h}: {e}")
334351
delete_client_task(session, h)
335352
logger.success(f"Deleted client task for hash {h}")
336-
return PlainTextResponse("Ok.")
353+
return PlainTextResponse("Ok.")

app/api/torznab/api.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,32 @@ def torznab_api(
3737
limit: int = Query(default=50),
3838
session: Session = Depends(get_session),
3939
) -> Response:
40+
"""
41+
Handle torznab-compatible API requests and return the corresponding XML response.
42+
43+
Supports three request modes determined by `t`:
44+
- "caps": return the server capabilities XML.
45+
- "search": perform a generic/preview search and return an RSS feed with zero or more synthetic or discovered items.
46+
- "tvsearch": search for a specific TV episode (requires `q` and `season`; `ep` defaults to 1 if omitted) and return an RSS feed of available releases.
47+
48+
Parameters:
49+
request: FastAPI Request object for the incoming HTTP request.
50+
t (str): One of "caps", "search", or "tvsearch", selecting the API mode.
51+
apikey (Optional[str]): API key for access control; presence/validity is required.
52+
q (Optional[str]): Query string identifying a series or search terms.
53+
season (Optional[int]): Season number for TV episode searches; required for "tvsearch".
54+
ep (Optional[int]): Episode number for TV episode searches; defaults to 1 for previews when omitted.
55+
cat (Optional[str]): Category filter (passed through but not required).
56+
offset (int): Result offset for paging.
57+
limit (int): Maximum number of RSS items to include.
58+
session: Database session (provided via dependency injection; omitted from docs for common DI services).
59+
60+
Returns:
61+
Response: A FastAPI Response containing XML:
62+
- application/xml; charset=utf-8 for "caps"
63+
- application/rss+xml; charset=utf-8 for "search" and "tvsearch"
64+
- HTTP 400 is raised for unknown `t` values; empty RSS feeds are returned when required parameters or slug resolution are missing.
65+
"""
4066
logger.info(
4167
"Torznab request: t={}, q={}, season={}, ep={}, cat={}, offset={}, limit={}, apikey={}".format(
4268
t, q, season, ep, cat, offset, limit, "<set>" if apikey else "<none>"
@@ -362,4 +388,4 @@ def torznab_api(
362388

363389
xml = ET.tostring(rss, encoding="utf-8", xml_declaration=True).decode("utf-8")
364390
logger.info(f"Returning RSS feed with {count} items.")
365-
return Response(content=xml, media_type="application/rss+xml; charset=utf-8")
391+
return Response(content=xml, media_type="application/rss+xml; charset=utf-8")

app/api/torznab/utils.py

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -66,17 +66,31 @@ def _caps_xml() -> str:
6666

6767

6868
def _normalize_tokens(s: str) -> List[str]:
69+
"""
70+
Split a string into lowercase alphanumeric tokens.
71+
72+
Non-alphanumeric characters are treated as token separators; letters are lowercased and digits are preserved.
73+
74+
Parameters:
75+
s (str): Input string to tokenize.
76+
77+
Returns:
78+
List[str]: A list of lowercase alphanumeric tokens extracted from the input.
79+
"""
6980
logger.debug(f"Normalizing tokens for string: '{s}'")
7081
return "".join(ch.lower() if ch.isalnum() else " " for ch in s).split()
7182

7283

7384
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.
85+
"""
86+
Resolve a free-text query to the best-matching site and canonical slug.
7587
76-
If site is specified, only searches that site. Otherwise searches all enabled sites.
77-
Returns (site, slug) tuple or None.
88+
Parameters:
89+
q (str): The free-text query to resolve (e.g., a title).
90+
site (Optional[str]): If provided, restrict resolution to the specified site identifier.
7891
79-
Import from the title_resolver module for the actual implementation.
92+
Returns:
93+
Optional[Tuple[str, str]]: A tuple (site, slug) with the site identifier and resolved slug when a match is found, `None` if no match exists.
8094
"""
8195
logger.debug(f"Resolving slug from query: '{q}', site filter: {site}")
8296
from app.utils.title_resolver import slug_from_query
@@ -95,6 +109,16 @@ def _slug_from_query(q: str, site: Optional[str] = None) -> Optional[Tuple[str,
95109

96110

97111
def _add_torznab_attr(item: ET.Element, name: str, value: str) -> None:
112+
"""
113+
Add a torznab `attr` subelement to an RSS item.
114+
115+
Creates a `torznab:attr` element (namespace http://torznab.com/schemas/2015/feed) as a child of `item` and sets its `name` and `value` attributes.
116+
117+
Parameters:
118+
item (xml.etree.ElementTree.Element): The RSS `<item>` element to which the torznab attribute will be added.
119+
name (str): The `name` attribute to set on the torznab `attr` element.
120+
value (str): The `value` attribute to set on the torznab `attr` element.
121+
"""
98122
attr = ET.SubElement(item, "{http://torznab.com/schemas/2015/feed}attr")
99123
attr.set("name", name)
100124
attr.set("value", value)
@@ -177,4 +201,4 @@ def _build_item(
177201

178202
_add_torznab_attr(item, "seeders", str(seeders))
179203
_add_torznab_attr(item, "peers", str(peers))
180-
_add_torznab_attr(item, "leechers", str(leechers))
204+
_add_torznab_attr(item, "leechers", str(leechers))

app/core/downloader.py

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,22 @@ def build_episode(
9292
episode: Optional[int] = None,
9393
site: str = "aniworld.to",
9494
) -> Episode:
95+
"""
96+
Construct an Episode from a direct link or from slug, season, and episode with the given site.
97+
98+
Parameters:
99+
link (Optional[str]): Direct episode URL. If provided, it is used and `slug/season/episode` are ignored.
100+
slug (Optional[str]): Series identifier used with `season` and `episode` to build the Episode when `link` is not provided.
101+
season (Optional[int]): Season number used with `slug` and `episode`.
102+
episode (Optional[int]): Episode number used with `slug` and `season`.
103+
site (str): Host site identifier included in the created Episode (default: "aniworld.to").
104+
105+
Returns:
106+
Episode: An Episode constructed from `link` (if present) or from `slug`, `season`, and `episode`.
107+
108+
Raises:
109+
ValueError: If neither `link` nor the combination of `slug`, `season`, and `episode` are provided.
110+
"""
95111
logger.info(
96112
f"Building episode: link={link}, slug={slug}, season={season}, episode={episode}, site={site}"
97113
)
@@ -307,6 +323,31 @@ def download_episode(
307323
stop_event: Optional[threading.Event] = None,
308324
site: str = "aniworld.to",
309325
) -> Path:
326+
"""
327+
Download an episode to the specified directory, resolving a direct stream URL with provider fallback and proxy-aware retry logic.
328+
329+
This function builds an Episode from the provided identifiers, attempts to resolve a direct download URL (optionally preferring a provider), downloads the media via yt-dlp with progress callbacks and cancellation support, and renames the downloaded file into the repository's release naming schema. If extraction or download fails, controlled fallback attempts are performed (no-proxy re-resolution and alternate providers) before failing.
330+
331+
Parameters:
332+
link (Optional[str]): Direct episode page URL; if provided, used instead of slug/season/episode.
333+
slug (Optional[str]): Series identifier used to construct an Episode when `link` is not given.
334+
season (Optional[int]): Season number to construct an Episode when `link` is not given.
335+
episode (Optional[int]): Episode number to construct an Episode when `link` is not given.
336+
provider (Optional[Provider]): Preferred provider name to try first when resolving a direct URL.
337+
language (str): Desired language label (will be normalized); used when resolving available streams.
338+
dest_dir (Path): Destination directory where the temporary download will be written.
339+
title_hint (Optional[str]): Hint for the temporary output filename; if omitted and slug/season/episode are given, a default is generated.
340+
cookiefile (Optional[Path]): Path to a cookies file passed to yt-dlp, if required by the provider/site.
341+
progress_cb (Optional[ProgressCb]): Optional callback that receives yt-dlp progress dictionaries.
342+
stop_event (Optional[threading.Event]): Optional event that, when set, requests download cancellation.
343+
site (str): Site identifier to use when constructing the Episode (defaults to "aniworld.to").
344+
345+
Returns:
346+
Path: Final path to the renamed release file.
347+
348+
Raises:
349+
DownloadError: When URL resolution or download ultimately fails after all fallback attempts.
350+
"""
310351
language = _normalize_language(language)
311352
logger.info(
312353
f"Starting download_episode: link={link}, slug={slug}, season={season}, episode={episode}, provider={provider}, language={language}, dest_dir={dest_dir}, site={site}"
@@ -448,4 +489,4 @@ def download_episode(
448489
site=site,
449490
)
450491
logger.success(f"Final file path: {final_path}")
451-
return final_path
492+
return final_path

app/core/scheduler.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,24 @@ def _cb(d: dict):
113113

114114

115115
def _run_download(job_id: str, req: dict, stop_event: threading.Event):
116+
"""
117+
Execute a download job: start the episode download, update the job record in the database with progress/result, and handle errors or cancellation.
118+
119+
Parameters:
120+
job_id (str): Identifier of the job being run.
121+
req (dict): Download request containing keys used by the downloader:
122+
- 'slug', 'season', 'episode' (identifiers for the episode)
123+
- 'language' (optional)
124+
- 'provider' (optional)
125+
- 'title_hint' (optional)
126+
- 'link' (optional)
127+
- 'site' (optional, defaults to "aniworld.to")
128+
stop_event (threading.Event): Event that, when set, requests cancellation of the download.
129+
130+
Side effects:
131+
- Updates the job row in the database with status, progress, messages, source site, and result path.
132+
- Removes the job from the RUNNING registry when finished.
133+
"""
116134
try:
117135
with Session(engine) as s:
118136
site = req.get("site", "aniworld.to")
@@ -188,4 +206,4 @@ def cancel_job(job_id: str) -> None:
188206
return
189207
fut, ev = item
190208
ev.set()
191-
fut.cancel()
209+
fut.cancel()

app/db/models.py

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,25 @@ def upsert_availability(
253253
extra: Optional[dict] = None,
254254
site: str = "aniworld.to",
255255
) -> EpisodeAvailability:
256+
"""
257+
Create or update the cached availability record for a specific episode/language on a site and persist the result.
258+
259+
Parameters:
260+
session (Session): Database session used to read and persist the record.
261+
slug (str): Series identifier.
262+
season (int): Season number.
263+
episode (int): Episode number.
264+
language (str): Language code for the availability entry.
265+
available (bool): Whether the episode is available.
266+
height (Optional[int]): Video height in pixels, or `None` if unknown.
267+
vcodec (Optional[str]): Video codec identifier, or `None` if unknown.
268+
provider (Optional[str]): Provider name/source, or `None` if unknown.
269+
extra (Optional[dict]): Optional auxiliary metadata for the record.
270+
site (str): Site identifier to scope the availability entry.
271+
272+
Returns:
273+
EpisodeAvailability: The persisted availability record reflecting the created or updated state.
274+
"""
256275
logger.debug(f"Upserting availability for {slug} S{season}E{episode} {language} on {site}")
257276
rec = session.get(EpisodeAvailability, (slug, season, episode, language, site))
258277
if rec is None:
@@ -295,6 +314,15 @@ def upsert_availability(
295314
def get_availability(
296315
session: Session, *, slug: str, season: int, episode: int, language: str, site: str = "aniworld.to"
297316
) -> Optional[EpisodeAvailability]:
317+
"""
318+
Retrieve the cached availability record for a specific episode identified by slug, season, episode, language, and site.
319+
320+
Parameters:
321+
site (str): Site identifier to query for (default "aniworld.to").
322+
323+
Returns:
324+
EpisodeAvailability | None: The matching EpisodeAvailability if found, `None` otherwise.
325+
"""
298326
logger.debug(f"Fetching availability for {slug} S{season}E{episode} {language} on {site}")
299327
rec = session.get(EpisodeAvailability, (slug, season, episode, language, site))
300328
if rec:
@@ -307,6 +335,18 @@ def get_availability(
307335
def list_available_languages_cached(
308336
session: Session, *, slug: str, season: int, episode: int, site: str = "aniworld.to"
309337
) -> List[str]:
338+
"""
339+
List languages with fresh cached availability for a specific episode on a site.
340+
341+
Parameters:
342+
slug (str): Episode/series identifier used to look up availability.
343+
season (int): Season number of the episode.
344+
episode (int): Episode number within the season.
345+
site (str): Site identifier to scope the availability records (defaults to "aniworld.to").
346+
347+
Returns:
348+
List[str]: Languages that have a cached availability record considered fresh.
349+
"""
310350
logger.debug(f"Listing available cached languages for {slug} S{season}E{episode} on {site}")
311351
rows = session.exec(
312352
select(EpisodeAvailability).where(
@@ -349,6 +389,16 @@ def upsert_client_task(
349389
state: str = "queued",
350390
site: str = "aniworld.to",
351391
) -> ClientTask:
392+
"""
393+
Create or update a ClientTask record for the given torrent/file hash.
394+
395+
Parameters:
396+
hash (str): Unique identifier for the client task (primary key).
397+
site (str): Site identifier to store on the record; defaults to "aniworld.to".
398+
399+
Returns:
400+
ClientTask: The inserted or updated ClientTask instance refreshed from the database.
401+
"""
352402
logger.debug(f"Upserting client task for hash {hash} on site {site}")
353403
rec = session.get(ClientTask, hash)
354404
if rec is None:
@@ -408,4 +458,4 @@ def delete_client_task(session: Session, hash: str) -> None:
408458
session.commit()
409459
logger.success(f"Deleted client task for hash {hash}")
410460
else:
411-
logger.warning(f"Client task for hash {hash} not found, nothing to delete.")
461+
logger.warning(f"Client task for hash {hash} not found, nothing to delete.")

app/utils/magnet.py

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,19 @@ def build_magnet(
3232
site: str = "aniworld.to",
3333
) -> str:
3434
"""
35-
Synthetischer Magnet mit notwendiger Payload für den Shim.
36-
Now includes site parameter to distinguish between aniworld.to and s.to content.
35+
Constructs a site-aware magnet URI containing metadata required by the Shim.
36+
37+
Parameters:
38+
title (str): Display name to include as the magnet's `dn` parameter.
39+
slug (str): Content identifier used in the site-prefixed slug parameter.
40+
season (int): Season number included as the site-prefixed `s` parameter.
41+
episode (int): Episode number included as the site-prefixed `e` parameter.
42+
language (str): Language code included as the site-prefixed `lang` parameter.
43+
provider (str | None): Optional provider identifier included as the site-prefixed `provider` parameter when provided.
44+
site (str): Source site; selects the parameter prefix — `"aw"` when `"aniworld.to"`, `"sto"` otherwise.
45+
46+
Returns:
47+
magnet_uri (str): A magnet URI that includes `xt`, `dn`, and site-prefixed metadata (slug, s, e, lang, site, and optionally provider).
3748
"""
3849
logger.debug(
3950
f"Building magnet for title='{title}', slug='{slug}', season={season}, episode={episode}, language='{language}', provider='{provider}', site='{site}'"
@@ -75,8 +86,21 @@ def build_magnet(
7586

7687
def parse_magnet(magnet: str) -> Dict[str, str]:
7788
"""
78-
Extrahiert unsere Payload (aw_* or sto_*), dn, xt.
79-
Now supports both aniworld (aw_*) and s.to (sto_*) prefixes.
89+
Parse a magnet URI and extract its payload parameters.
90+
91+
Supports payloads using either the "aw_" (aniworld) or "sto_" (s.to) prefix; defaults to "aw_" if no prefix is detected.
92+
93+
Parameters:
94+
magnet (str): A magnet URI beginning with "magnet:?". Query parameters are parsed and flattened to single string values.
95+
96+
Returns:
97+
Dict[str, str]: A mapping of parameter names to their single string value. The returned dict will include at minimum:
98+
- "dn" (display name)
99+
- "xt" (exact topic, e.g., "urn:btih:...")
100+
- "{prefix}_slug", "{prefix}_s", "{prefix}_e", "{prefix}_lang" where "{prefix}" is "aw" or "sto".
101+
102+
Raises:
103+
ValueError: If the input does not start with "magnet:?" or if any required parameter is missing.
80104
"""
81105
logger.debug(f"Parsing magnet URI: {magnet}")
82106
if not magnet.startswith("magnet:?"):
@@ -112,4 +136,4 @@ def parse_magnet(magnet: str) -> Dict[str, str]:
112136
raise ValueError(f"missing magnet param: {req}")
113137

114138
logger.success(f"Magnet parsed successfully: {flat}")
115-
return flat
139+
return flat

0 commit comments

Comments
 (0)