fix(app): исправление скачивания торрентов

- generate_clean_magnet: убраны мёртвые трекеры (coppersurfer.tk, leechers-paradise.org),
  добавлены рабочие (tamersunion.org, exodus.desync.com, moeking.me),
  включено &dn= с URL-кодированием кириллицы
- extract_hash_from_result: новая единая функция извлечения хэша из 5 источников
  (Hash, InfoHash, Magnet, btih: в URL, Id)
- /api/add-torrent: убран ложный success — после Ok. от qBittorrent идёт реальная
  верификация (торрент появился в списке по хэшу или названию). Если не появился — error.
- /api/proxy-torrent-download: новый endpoint для скачивания .torrent файлов
  через NL-прокси (обходит DPI-блокировку)
- torrents.html: кнопка Копировать magnet (Clipboard API + fallback),
  proxy-ссылки для .torrent, disabled-состояния для пустых magnet/torrent_url
- tmdb-proxy: добавлен /proxy-torrent endpoint
- urlencode filter для Jinja2
- test_app.py: 47 тестов на чистые функции
This commit is contained in:
vrubelroman 2026-06-03 19:27:14 +00:00
parent fb2aa5a60a
commit a5497eef26
4 changed files with 598 additions and 130 deletions

View file

@ -4,11 +4,12 @@ import asyncio
import httpx
import requests
from bs4 import BeautifulSoup
from fastapi import FastAPI, Request, Form, HTTPException
from fastapi.responses import HTMLResponse
from fastapi import FastAPI, Request, Form, HTTPException, Query
from fastapi.responses import HTMLResponse, Response
from fastapi.templating import Jinja2Templates
from fastapi.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware
from urllib.parse import quote
app = FastAPI(title="Movie Search API", version="1.0.0")
@ -24,6 +25,13 @@ app.add_middleware(
# Настройка шаблонов
templates = Jinja2Templates(directory="templates")
# Регистрируем фильтр urlencode для шаблонов
def urlencode_filter(s):
if s:
return quote(s, safe='')
return ''
templates.env.filters['urlencode'] = urlencode_filter
# URL прокси-сервиса TMDB
TMDB_PROXY_URL = os.getenv("TMDB_PROXY_URL", "http://localhost:8001")
@ -418,33 +426,22 @@ async def search_torrent_by_id(torrent_id: str) -> dict:
torrent_name = result.get('Name', '') or result.get('Original_Name', '')
print(f"Found torrent by ID on {provider_name}: {torrent_name[:100]}...")
# Получаем хэш и создаем чистую magnet-ссылку с публичными трекерами
hash_value = result.get('Hash', '')
torrent_title = torrent_name
# Если хэша нет в Hash, пытаемся извлечь из Magnet ссылки
if not hash_value:
original_magnet = result.get('Magnet', '')
if original_magnet:
# Извлекаем хэш из magnet ссылки
hash_match = re.search(r'urn:btih:([a-fA-F0-9]{40}|[a-zA-Z0-9]{32})', original_magnet)
if hash_match:
hash_value = hash_match.group(1)
print(f"Extracted hash from magnet link: {hash_value[:10]}...")
# Получаем хэш — используем единую функцию извлечения
hash_value = extract_hash_from_result(result)
if hash_value:
# Генерируем чистую magnet-ссылку с публичными трекерами
magnet = generate_clean_magnet(hash_value, torrent_title)
print(f"Generated clean magnet with public trackers: {magnet[:100]}...")
magnet = generate_clean_magnet(hash_value, torrent_name)
print(f"Generated clean magnet with hash {hash_value[:10]}...: {magnet[:100]}...")
else:
# Fallback на оригинальную magnet-ссылку если нет хэша
magnet = result.get('Magnet', '')
if not magnet or not magnet.startswith('magnet:'):
print(f"Warning: No hash found and no valid magnet link. Hash: {hash_value}, Magnet: {result.get('Magnet', 'None')[:50]}")
print(f"Warning: No hash found and no valid magnet link. Hash fields: Hash={result.get('Hash', 'None')[:20]}, Magnet={result.get('Magnet', 'None')[:50]}")
magnet = ""
# Пробуем локальный torapi-qbit если хэш пустой или битый
if not hash_value or not re.search(r'urn:btih:([a-fA-F0-9]{40}|[a-zA-Z0-9]{32})', magnet):
if not magnet or not re.search(r'urn:btih:([a-fA-F0-9]{40}|[a-zA-Z0-9]{32})', magnet):
try:
torapi_add_url = os.getenv("TORAPI_ADD_URL", "http://localhost:8444")
fb_resp = await client.get(
@ -456,16 +453,10 @@ async def search_torrent_by_id(torrent_id: str) -> dict:
fb_data = fb_resp.json()
if isinstance(fb_data, list) and len(fb_data) > 0:
fb_result = fb_data[0]
fb_hash = fb_result.get('Hash', '')
if not fb_hash:
import re as re2
fb_magnet = fb_result.get('Magnet', '')
hm2 = re2.search(r'urn:btih:([a-fA-F0-9]{40})', fb_magnet)
if hm2:
fb_hash = hm2.group(1)
fb_hash = extract_hash_from_result(fb_result)
if fb_hash:
magnet = generate_clean_magnet(fb_hash, torrent_name)
print(f"torapi-qbit fallback: got magnet hash {fb_hash[:10]}...")
print(f"torapi-qbit fallback: got hash {fb_hash[:10]}... via extract_hash_from_result")
except Exception as fbe:
print(f"torapi-qbit fallback failed: {fbe}")
@ -689,25 +680,69 @@ def parse_size_to_bytes(size_str: str) -> int:
return 0
def extract_hash_from_result(result: dict) -> str:
"""Извлекает хэш торрента из результата API — пробует все возможные поля"""
hash_value = ""
# 1. Прямое поле Hash
hash_value = result.get('Hash', '') or result.get('hash', '') or ''
if hash_value and re.match(r'^[a-fA-F0-9]{40}$', hash_value):
return hash_value.upper()
# 2. Поле InfoHash
if not hash_value:
hash_value = result.get('InfoHash', '') or result.get('info_hash', '') or ''
if hash_value and re.match(r'^[a-fA-F0-9]{40}$', hash_value):
return hash_value.upper()
# 3. Из Magnet ссылки
magnet = result.get('Magnet', '') or result.get('magnet', '') or ''
if magnet:
hm = re.search(r'urn:btih:([a-fA-F0-9]{40})', magnet)
if hm:
return hm.group(1).upper()
hm32 = re.search(r'urn:btih:([a-zA-Z0-9]{32})', magnet)
if hm32:
return hm32.group(1)
# 4. Из Torrent URL (rutracker: /dl.php?t=XXXXX)
torrent_url = result.get('Torrent', '') or result.get('torrent', '') or result.get('Url', '') or ''
# Некоторые провайдеры кодируют хэш в URL
if 'btih:' in torrent_url:
hm = re.search(r'btih:([a-fA-F0-9]{40})', torrent_url)
if hm:
return hm.group(1).upper()
# 5. Из поля Id/ID (некоторые API возвращают хэш как ID)
tid = result.get('Id') or result.get('id') or result.get('ID') or ''
if tid and re.match(r'^[a-fA-F0-9]{40}$', tid):
return tid.upper()
return hash_value # Может быть пустым
def generate_clean_magnet(hash_value: str, title: str = None) -> str:
"""Генерирует чистую magnet-ссылку с публичными трекерами"""
if not hash_value:
return ""
# Проверенные рабочие трекеры (минимальный набор)
# Рабочие публичные трекеры (2025+)
public_trackers = [
"udp://tracker.opentrackr.org:1337/announce",
"udp://open.stealth.si:80/announce",
"udp://tracker.openbittorrent.com:6969/announce",
"udp://tracker.coppersurfer.tk:6969/announce",
"udp://tracker.leechers-paradise.org:6969/announce"
"https://tracker.tamersunion.org:443/announce",
"udp://exodus.desync.com:6969/announce",
"udp://tracker.moeking.me:6969/announce",
]
# Создаем базовую magnet-ссылку с хэшем
magnet = f"magnet:?xt=urn:btih:{hash_value}"
# НЕ добавляем название файла - это может вызывать проблемы с кириллицей
# DHT сам найдет название по хэшу
# Добавляем название в кодировке RFC 3986 для удобства
if title:
from urllib.parse import quote
magnet += f"&dn={quote(title)}"
# Добавляем публичные трекеры
for tracker in public_trackers:
@ -1135,7 +1170,61 @@ async def torrents_page(request: Request, movie_title: str, year: str = None):
}
)
@app.post("/api/add-torrent")
@app.get("/api/proxy-torrent-download")
async def proxy_torrent_download(url: str = Query(...)):
"""Прокси скачивание .torrent файла через NL-сервер (обходит DPI)"""
try:
proxy_base = os.getenv("TMDB_PROXY_URL", "http://localhost:8001")
print(f"Proxying .torrent download: {url} via {proxy_base}")
async with httpx.AsyncClient(timeout=30.0) as client:
# Пробуем через NL прокси (tmdb-proxy имеет прямой выход в интернет)
try:
proxy_resp = await client.get(
f"{proxy_base}/proxy-torrent",
params={"url": url},
timeout=30.0
)
if proxy_resp.status_code == 200:
content_type = proxy_resp.headers.get("content-type", "application/x-bittorrent")
return Response(
content=proxy_resp.content,
media_type=content_type,
headers={
"Content-Disposition": f'attachment; filename="{os.path.basename(url)}.torrent"',
"Content-Length": str(len(proxy_resp.content))
}
)
print(f"NL proxy returned {proxy_resp.status_code}, trying direct download...")
except Exception as proxy_err:
print(f"NL proxy error: {proxy_err}, trying direct download...")
# Fallback: пытаемся скачать напрямую из контейнера (может работать если DNS не блокируется)
try:
direct_resp = await client.get(url, timeout=15.0)
if direct_resp.status_code == 200 and len(direct_resp.content) > 100:
content_type = direct_resp.headers.get("content-type", "application/x-bittorrent")
return Response(
content=direct_resp.content,
media_type=content_type,
headers={
"Content-Disposition": f'attachment; filename="{os.path.basename(url)}.torrent"',
"Content-Length": str(len(direct_resp.content))
}
)
except Exception as direct_err:
print(f"Direct download failed: {direct_err}")
raise HTTPException(
status_code=502,
detail=f"Не удалось скачать .torrent файл. NL-прокси и прямой доступ недоступны."
)
except HTTPException:
raise
except Exception as e:
print(f"Error in proxy-torrent-download: {e}")
raise HTTPException(status_code=500, detail=f"Ошибка прокси: {str(e)}")
async def add_torrent_to_client(torrent_id: str = Form(...), magnet: str = Form(""), torrent_title: str = Form("")):
"""Добавление торрента в qBittorrent через прямое API"""
try:
@ -1207,27 +1296,42 @@ async def add_torrent_to_client(torrent_id: str = Form(...), magnet: str = Form(
if hash_match:
torrent_hash = hash_match.group(1).upper()
if torrent_hash:
torrents_response = await client.get(f"{qb_url}/api/v2/torrents/info")
if torrents_response.status_code == 200:
torrents = torrents_response.json()
# Ищем торрент по хэшу
for torrent in torrents:
if torrent.get('hash', '').upper() == torrent_hash:
return {
"status": "success",
"message": f"Торрент '{torrent_info.get('title', 'Unknown')[:50]}...' добавлен в qBittorrent через magnet-ссылку!",
"torrent_hash": torrent.get('hash'),
"torrent_name": torrent_info.get('title', 'Unknown')
}
# Реальная верификация: проверяем, что торрент появился в qBittorrent
verified = False
actual_hash = torrent_hash
torrents_response = await client.get(f"{qb_url}/api/v2/torrents/info")
if torrents_response.status_code == 200:
qb_torrents = torrents_response.json()
if torrent_hash:
for qt in qb_torrents:
if qt.get('hash', '').upper() == torrent_hash:
verified = True
actual_hash = qt.get('hash', '')
print(f"Verified: torrent found by hash in qBittorrent")
break
if not verified:
# Пробуем найти по названию
t_title = torrent_info.get('title', '').lower()
for qt in qb_torrents:
qt_name = qt.get('name', '').lower()
if t_title and qt_name and (t_title[:30] in qt_name or qt_name[:30] in t_title):
verified = True
actual_hash = qt.get('hash', '')
print(f"Verified: torrent found by name in qBittorrent: {qt.get('name', '')}")
break
# Если не нашли по хэшу, но ответ был Ok, считаем успешным
return {
"status": "success",
"message": f"Торрент '{torrent_info.get('title', 'Unknown')[:50]}...' добавлен в qBittorrent через magnet-ссылку!",
"torrent_hash": torrent_hash,
"torrent_name": torrent_info.get('title', 'Unknown')
}
if verified:
return {
"status": "success",
"message": f"Торрент '{torrent_info.get('title', 'Unknown')[:50]}...' добавлен в qBittorrent!",
"torrent_hash": actual_hash,
"torrent_name": torrent_info.get('title', 'Unknown')
}
else:
# qBittorrent сказал Ok., но торрент не появился — ложный успех
print(f"WARNING: qBittorrent returned Ok. but torrent was not added (hash={torrent_hash or 'empty'})")
print("Proceeding to .torrent file fallback...")
# Не возвращаем success, а пробуем .torrent fallback ниже
else:
print(f"Magnet link failed (status: {add_response.status_code}, response: {add_response.text}), trying .torrent file...")
else:
@ -1288,13 +1392,12 @@ async def add_torrent_to_client(torrent_id: str = Form(...), magnet: str = Form(
if torrents_response.status_code == 200:
torrents = torrents_response.json()
# Ищем торрент по названию (первые 20 символов для точного совпадения)
# Ищем торрент по названию (первые 30 символов)
added_torrent = None
for torrent in torrents:
torrent_name = torrent.get('name', '').lower()
# Проверяем совпадение по началу названия
if torrent_title and torrent_name:
if torrent_title[:20] in torrent_name or torrent_name[:20] in torrent_title:
if torrent_title[:30] in torrent_name or torrent_name[:30] in torrent_title:
added_torrent = torrent
break
@ -1306,12 +1409,11 @@ async def add_torrent_to_client(torrent_id: str = Form(...), magnet: str = Form(
"torrent_name": added_torrent.get('name', torrent_info.get('title', 'Unknown'))
}
# Если не нашли по названию, но ответ был Ok, считаем успешным
# Торрент не появился — честный error вместо ложного success
print(f"WARNING: qBittorrent returned Ok. for .torrent but torrent was not found in list (title={torrent_title or 'empty'})")
return {
"status": "success",
"message": f"Торрент '{torrent_info.get('title', 'Unknown')[:50]}...' добавлен в qBittorrent через .torrent файл (проверьте список торрентов в qBittorrent)!",
"torrent_hash": torrent_info.get('hash', ''),
"torrent_name": torrent_info.get('title', 'Unknown')
"status": "error",
"message": f"qBittorrent не добавил торрент. Возможно, файл недоступен или DPI-блокировка. Попробуйте magnet-ссылку."
}
else:
error_msg = add_response.text.strip()