Files
MoviePilot/app/domain/torrent.py
jxxghp 6ca7b294a0 fix(download): 共享站点流控并保留频控错误原因 (#7246)
* fix(download): share site limits and preserve frequency errors

* test(startup): record shared site throttle module
2026-10-30 14:53:32 +08:00

227 lines
9.2 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
种子身份与下载链接的纯领域判断规则。
"""
import base64
import binascii
import hashlib
import re
from typing import TYPE_CHECKING, Optional, Union
from urllib.parse import SplitResult, parse_qs, urlsplit
if TYPE_CHECKING:
from app.domain.context import TorrentInfo
# TTG 详情页为 /t/{id}/,下载链接为 /dl/{id}/{auth};auth 由每次搜索生成,几分钟后或多次请求后失效
_TTG_HOST = "totheglory.im"
_TTG_DETAIL_PATH = re.compile(r"/t/(\d+)/?")
_TTG_DOWNLOAD_PATH = re.compile(r"/dl/(\d+)/[^/]+/?")
def is_magnet_link(content: Union[str, bytes]) -> bool:
"""
判断字符串或字节内容是否为磁力链接。
"""
if not content:
return False
if isinstance(content, str):
return content.startswith("magnet:")
if isinstance(content, bytes):
return content.startswith(b"magnet:")
return False
def _bencoded_value_end(content: bytes, offset: int) -> int:
"""
跳过一个完整 bencode 值,保留 info 原始字节以避免重编码改变哈希。
"""
marker = content[offset:offset + 1]
if marker == b"i":
end = content.index(b"e", offset + 1)
if not re.fullmatch(rb"0|-?[1-9][0-9]*", content[offset + 1:end]):
raise ValueError("bencode 整数无效")
return end + 1
if marker in (b"d", b"l"):
offset += 1
keys: set[bytes] = set()
while content[offset:offset + 1] != b"e":
if marker == b"d":
if not content[offset:offset + 1].isdigit():
raise ValueError("bencode 字典键无效")
separator = content.index(b":", offset)
key_end = _bencoded_value_end(content, offset)
key = content[separator + 1:key_end]
if key in keys:
raise ValueError("bencode 字典键重复")
keys.add(key)
offset = key_end
offset = _bencoded_value_end(content, offset)
return offset + 1
if marker.isdigit():
separator = content.index(b":", offset)
if not re.fullmatch(rb"0|[1-9][0-9]*", content[offset:separator]):
raise ValueError("bencode 字符串长度无效")
length = int(content[offset:separator])
end = separator + 1 + length
if end > len(content):
raise ValueError("bencode 字符串不完整")
return end
raise ValueError("bencode 值无效")
def _bencoded_dictionary(content: bytes) -> dict[bytes, bytes]:
"""
提取字典直属键值的原始切片,不解码二进制文件树或 pieces root。
"""
if not content.startswith(b"d") or _bencoded_value_end(content, 0) != len(content):
raise ValueError("bencode 字典无效")
result: dict[bytes, bytes] = {}
offset = 1
while content[offset:offset + 1] != b"e":
if not content[offset:offset + 1].isdigit():
raise ValueError("bencode 字典键无效")
separator = content.index(b":", offset)
key_end = _bencoded_value_end(content, offset)
key = content[separator + 1:key_end]
value_end = _bencoded_value_end(content, key_end)
if key in result:
raise ValueError("bencode 字典键重复")
result[key] = content[key_end:value_end]
offset = value_end
return result
def download_hashes(content: Union[str, bytes]) -> frozenset[str]:
"""
读取实际种子或磁力的 v1/v2 身份;v2 同时接受下载器使用的 40 位截断哈希。
哈希只由原始 info 字节或明确的磁力标识构造,名称和大小不构成身份。
解析失败返回空集合,调用方不得据此接管其它已有任务。
"""
try:
hashes: set[str] = set()
if is_magnet_link(content):
url = content.decode("utf-8") if isinstance(content, bytes) else content
for value in parse_qs(urlsplit(url).query).get("xt", []):
if match := re.fullmatch(r"urn:btih:([a-fA-F0-9]{40})", value):
hashes.add(match.group(1).lower())
elif match := re.fullmatch(r"urn:btih:([a-zA-Z2-7]{32})", value):
hashes.add(base64.b32decode(match.group(1).upper()).hex())
elif match := re.fullmatch(r"urn:btmh:1220([a-fA-F0-9]{64})", value):
digest = match.group(1).lower()
hashes.update((digest, digest[:40]))
elif isinstance(content, bytes):
info = _bencoded_dictionary(content).get(b"info", b"")
fields = _bencoded_dictionary(info)
if b"pieces" in fields:
hashes.add(hashlib.sha1(info).hexdigest())
if fields.get(b"meta version") == b"i2e":
digest = hashlib.sha256(info).hexdigest()
hashes.update((digest, digest[:40]))
return frozenset(hashes)
except (ValueError, TypeError, UnicodeError, binascii.Error, RecursionError):
return frozenset()
def resource_identity(torrent: "TorrentInfo") -> Optional[tuple[str, str]]:
"""
按站点和稳定种子标识匹配资源,不把临时签名或标题当成种子 ID。
"""
site = str(torrent.site or torrent.site_name or "")
for name, prefix in (("torrent_id", "id"), ("info_hash", "hash")):
value = getattr(torrent, name, None)
if site and isinstance(value, (str, int)) and value:
return site, f"{prefix}={value}"
for url in (torrent.page_url, torrent.enclosure):
if not isinstance(url, str) or not url.startswith(("http://", "https://")):
continue
try:
parsed = urlsplit(url)
except ValueError:
continue
params = parse_qs(parsed.query)
# Gazelle 详情页的 id 是分组 ID,torrentid 才对应可下载的具体资源。
for name in ("torrentid", "torrent_id", "tid", "id", "hash"):
values = params.get(name)
if values and len(values) == 1:
prefix = "hash" if name == "hash" else "id"
return site or parsed.netloc, f"{prefix}={values[0]}"
resource_id = _path_resource_id(parsed)
if resource_id:
return site or parsed.netloc, f"id={resource_id}"
return None
def _is_ttg_url(parsed: SplitResult) -> bool:
"""
判断地址是否属于 TTG 站点(含子域名)。
"""
host = parsed.hostname or ""
return host == _TTG_HOST or host.endswith(f".{_TTG_HOST}")
def _path_resource_id(parsed: SplitResult) -> Optional[str]:
"""
从已知站点的详情页或下载链接路径中提取稳定种子 ID。
馒头详情页为 /detail/{id},换票请求参数不参与资源身份;
TTG 详情页为 /t/{id}/,下载链接为 /dl/{id}/{auth},末段 auth 是临时签名,不参与资源身份。
:param parsed: 已拆分的 http(s) 地址
:return: 站内种子 ID;不是已知路径形态时返回 None
"""
match = re.fullmatch(r"/detail/(\d+)/?", parsed.path)
if match is None and _is_ttg_url(parsed):
match = _TTG_DETAIL_PATH.fullmatch(parsed.path) or _TTG_DOWNLOAD_PATH.fullmatch(parsed.path)
return match[1] if match else None
def is_expiring_download_url(url: Optional[str]) -> bool:
"""
识别已知的短时签名下载链接;不推断签名的具体有效期。
支持查询参数中的时间戳加签名(t、sign),以及 TTG 放在下载路径末段的 auth 签名。
"""
if not url or not url.startswith(("http://", "https://")):
return False
try:
parsed = urlsplit(url)
params = parse_qs(parsed.query)
except ValueError:
return False
if params.get("t") and params.get("sign"):
return True
return _is_ttg_url(parsed) and _TTG_DOWNLOAD_PATH.fullmatch(parsed.path) is not None
def rate_limit_cooldown(message: Optional[str]) -> Optional[int]:
"""
识别种子下载限流语义并返回冷却秒数;未知错误返回空。
每日下载配额采用完整 24 小时以覆盖站点时区;明确的频控秒数按原值冷却。
其他短期限流沿用至少一小时的订阅冷却,不在下载调用内立即重试。
"""
text = str(message or "").lower()
daily_quota = (
re.search(r"[当當]天|每天|每日|今日|daily", text)
and re.search(r"下载|下載|download", text)
and re.search(r"最多|上限|限[额額]|次[数數]|limit|quota", text)
)
if daily_quota:
return 86400
frequency_limit = re.search(r"request frequency limit,?\s*please try in\s+(\d+)\s+seconds?", text)
if frequency_limit:
return max(1, int(frequency_limit.group(1)))
keywords = ("限流", "流控", "请求太多", "请求过多", "请求频繁", "请求过于频繁",
"請求太多", "請求過多", "請求頻繁", "請求過於頻繁", "too many requests", "rate limit")
if not any(keyword in text for keyword in keywords):
return None
wait = re.search(r"(\d+)\s*(秒|分[钟鐘]?|小[时時]|seconds?|minutes?|hours?)", text)
if not wait:
return 3600
unit = wait.group(2)
multiplier = 3600 if unit.startswith(("小", "hour")) else (
60 if unit.startswith(("分", "minute")) else 1
)
return max(3600, int(wait.group(1)) * multiplier)