findFilms/app/app.py

1437 lines
68 KiB
Python
Raw Normal View History

import os
import re
import asyncio
import httpx
import requests
from bs4 import BeautifulSoup
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")
# Настройка CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # В продакшене лучше указать конкретные домены
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Настройка шаблонов
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")
# URL torAPI
TORAPI_URL = os.getenv("TORAPI_URL", "http://localhost:8088")
# URL Torrent Search API
TORRENT_SEARCH_URL = os.getenv("TORRENT_SEARCH_URL", "http://localhost:8443")
TORRENT_ADD_URL = os.getenv("TORRENT_ADD_URL", "http://localhost:8444")
async def search_movies(query: str) -> dict:
"""Поиск фильмов через TMDB Proxy"""
async with httpx.AsyncClient() as client:
try:
response = await client.get(
f"{TMDB_PROXY_URL}/search/movie",
params={
"query": query,
"language": "ru-RU",
"include_adult": False
}
)
response.raise_for_status()
return response.json()
except httpx.HTTPError as e:
raise HTTPException(status_code=500, detail=f"TMDB Proxy error: {str(e)}")
async def get_movie_details(movie_id: int) -> dict:
"""Получение детальной информации о фильме из TMDB через прокси"""
async with httpx.AsyncClient() as client:
try:
response = await client.get(
f"{TMDB_PROXY_URL}/movie/{movie_id}",
params={
"language": "ru-RU",
"append_to_response": "external_ids"
}
)
response.raise_for_status()
return response.json()
except httpx.HTTPError as e:
raise HTTPException(status_code=500, detail=f"TMDB Proxy error: {str(e)}")
def parse_size(size_str: str) -> tuple:
"""Парсинг размера файла и возврат в байтах и читаемом виде"""
if not size_str:
return 0, "Неизвестно"
size_str = size_str.upper().strip()
multipliers = {
'KB': 1024,
'MB': 1024**2,
'GB': 1024**3,
'TB': 1024**4
}
for unit, multiplier in multipliers.items():
if unit in size_str:
try:
number = float(re.findall(r'[\d.]+', size_str)[0])
bytes_size = int(number * multiplier)
return bytes_size, size_str
except (ValueError, IndexError):
pass
return 0, size_str
def extract_resolution(title: str) -> str:
"""Извлечение разрешения из названия торрента"""
title_upper = title.upper()
# Поиск разрешений
resolutions = ['2160P', '4K', '1080P', '720P', '480P', '360P']
for res in resolutions:
if res in title_upper:
if res == '4K':
return '2160p'
return res.lower()
# Поиск по паттернам
if re.search(r'\b(2160|4k)\b', title_upper):
return '2160p'
elif re.search(r'\b1080\b', title_upper):
return '1080p'
elif re.search(r'\b720\b', title_upper):
return '720p'
elif re.search(r'\b480\b', title_upper):
return '480p'
return 'Неизвестно'
def normalize_search_term(term: str) -> str:
"""Нормализация поискового термина для сцены"""
if not term:
return ""
# Убираем артикли
term = re.sub(r'\b(the|a|an)\b', '', term, flags=re.IGNORECASE)
# Заменяем пробелы на точки и дефисы
term = re.sub(r'\s+', '.', term.strip())
# Убираем лишние точки
term = re.sub(r'\.+', '.', term)
# Убираем точки в начале и конце
term = term.strip('.')
return term
def generate_search_variants(title: str, original_title: str = None, year: str = None) -> list:
"""Генерация вариантов названий для поиска"""
variants = []
# Базовые варианты
variants.extend([title, original_title] if original_title else [title])
# Варианты с годом
if year:
variants.extend([f"{title} {year}", f"{original_title} {year}"] if original_title else [f"{title} {year}"])
# Нормализованные варианты
normalized_variants = [normalize_search_term(v) for v in variants if v]
variants.extend(normalized_variants)
# Специальные варианты для известных фильмов
title_lower = title.lower()
if 'терминатор' in title_lower or 'terminator' in title_lower:
variants.extend(['T2', 'T2.Judgment.Day', 'T2.Judgement.Day'])
if '2' in title_lower or 'второй' in title_lower:
variants.extend(['Terminator.2', 'Терминатор.2'])
# Убираем дубликаты и пустые значения
return list(set([v for v in variants if v and v.strip()]))
def score_torrent(torrent: dict, movie_title: str, original_title: str = None, year: str = None) -> float:
"""Скоринг торрента по релевантности"""
score = 0.0
torrent_name = torrent.get('title', '').lower()
movie_title_lower = movie_title.lower()
original_title_lower = original_title.lower() if original_title else ""
# Скоринг по названию (0-1)
if movie_title_lower in torrent_name:
score += 0.8
if original_title_lower and original_title_lower in torrent_name:
score += 0.9
# Скоринг по году (0-0.5)
if year and year in torrent_name:
score += 0.5
# Скоринг по качеству (0-0.3)
quality = torrent.get('quality', '').lower()
if 'bluray' in quality or 'remux' in quality:
score += 0.3
elif 'web-dl' in quality or 'webdl' in quality:
score += 0.2
elif 'hdtv' in quality:
score += 0.1
# Скоринг по разрешению (0-0.2)
resolution = torrent.get('resolution', '').lower()
if '2160p' in resolution or '4k' in resolution:
score += 0.2
elif '1080p' in resolution:
score += 0.15
elif '720p' in resolution:
score += 0.1
# Скоринг по сидам (0-0.1)
seeds = torrent.get('seeds', 0)
if seeds > 100:
score += 0.1
elif seeds > 50:
score += 0.05
return min(score, 1.0) # Ограничиваем максимум 1.0
def generate_demo_torrents(movie_title: str, year: str = None, original_title: str = None) -> list:
"""Генерация демо-данных для тестирования (когда API не работает)"""
torrents = []
# Создаем реалистичные варианты торрентов
quality_variants = [
{"quality": "BluRay", "resolution": "1080p", "size": "8.5 GB", "seeds": 150, "peers": 25},
{"quality": "WEB-DL", "resolution": "1080p", "size": "6.2 GB", "seeds": 120, "peers": 20},
{"quality": "BluRay", "resolution": "2160p", "size": "25.3 GB", "seeds": 200, "peers": 35},
{"quality": "WEBRip", "resolution": "720p", "size": "3.8 GB", "seeds": 80, "peers": 15},
{"quality": "HDTV", "resolution": "720p", "size": "2.1 GB", "seeds": 50, "peers": 8}
]
# Генерируем варианты названий
title_variants = [movie_title]
if original_title and original_title != movie_title:
title_variants.append(original_title)
for i, variant in enumerate(quality_variants):
# Выбираем случайное название
base_title = title_variants[i % len(title_variants)]
# Создаем реалистичное название торрента
torrent_title = f"{base_title} ({year or '2020'}) - {variant['resolution']} - {variant['quality']}"
# Создаем fake magnet hash
fake_hash = f"08ada5a7a6183aae1e09d831df6748d566095a10{i:02d}"
torrent = {
"title": torrent_title,
"size_bytes": parse_size_to_bytes(variant['size']),
"size_readable": variant['size'],
"resolution": variant['resolution'],
"quality": variant['quality'],
"seeds": variant['seeds'],
"peers": variant['peers'],
"magnet": f"magnet:?xt=urn:btih:{fake_hash}&dn={base_title.replace(' ', '+')}&tr=udp://tracker.openbittorrent.com:80",
"download_url": f"https://example.com/torrent/{fake_hash}/",
"category": "Фильмы",
"date": "2025-01-05",
"provider": "Demo",
"relevance_score": 0.9 - (i * 0.1) # Убывающий скор
}
torrents.append(torrent)
return torrents
async def search_torrents(movie_title: str, year: str = None, original_title: str = None, genres: list = None, imdb_id: str = None) -> list:
"""Поиск торрентов для фильма через Torrent Search API"""
torrents = []
try:
# Генерируем варианты поисковых запросов
search_queries = generate_search_variants(movie_title, original_title, year)
print(f"Generated search variants: {search_queries}")
# Добавляем IMDB ID если есть
if imdb_id:
search_queries.append(imdb_id)
# Получаем список доступных провайдеров
async with httpx.AsyncClient() as client:
providers_response = await client.get(f"{TORRENT_SEARCH_URL}/api/provider/list")
if providers_response.status_code != 200:
print(f"Failed to get providers: {providers_response.status_code}")
return torrents
providers = providers_response.json()
print(f"Available providers: {[p['Provider'] for p in providers]}")
# Ищем торренты на всех доступных провайдерах
for provider in providers:
provider_name = provider['Provider'].lower()
try:
# Пробуем каждый вариант поискового запроса
for search_query in search_queries:
print(f"Searching '{search_query}' on {provider_name}")
# Поиск на конкретном провайдере - исправленный эндпоинт
try:
search_response = await client.get(
f"{TORRENT_SEARCH_URL}/api/search/title/{provider_name}",
params={"query": search_query},
timeout=30.0
)
except Exception as request_error:
print(f"Request error on {provider_name} for '{search_query}': {request_error}")
import traceback
traceback.print_exc()
continue
if search_response.status_code == 200:
try:
results = search_response.json()
except Exception as json_error:
print(f"JSON parse error on {provider_name} for '{search_query}': {json_error}")
print(f"Response text (first 500 chars): {search_response.text[:500]}")
continue
if not isinstance(results, list):
print(f"Unexpected response format from {provider_name}: {type(results)}")
if isinstance(results, dict):
print(f"Dict keys: {list(results.keys())}")
continue
print(f"Found {len(results)} results for '{search_query}' on {provider_name}")
# Обрабатываем все результаты и скорим их
for result in results[:20]: # Берем больше результатов для скоринга
print(f"Processing torrent: {result['Name'][:100]}...")
# Логируем доступные поля для отладки
torrent_id = result.get('Id') or result.get('id') or result.get('ID') or result.get('Hash', '')[:8] or ''
if torrent_id:
print(f" Found ID: {torrent_id[:20]}...")
torrent = parse_torrent_result(result, movie_title, year)
if torrent:
# Если ID не был найден, пробуем использовать Hash как ID
if not torrent.get('id') or torrent.get('id') == '':
torrent_id_from_hash = result.get('Hash', '')
if torrent_id_from_hash:
torrent['id'] = torrent_id_from_hash
print(f" Using Hash as ID: {torrent_id_from_hash[:20]}...")
# Скорим торрент
score = score_torrent(torrent, movie_title, original_title, year)
torrent['relevance_score'] = score
size_mb = torrent.get('size_bytes', 0) / (1024 * 1024)
print(f"Torrent score: {score:.2f}, size: {size_mb:.1f}MB, seeds: {torrent.get('seeds', 0)}, ID: {torrent.get('id', 'None')[:20]}...")
# Добавляем только если скор больше 0.1 и размер больше 100MB
if score > 0.1 and torrent.get('size_bytes', 0) > 100 * 1024 * 1024:
# Проверяем, что ID есть, иначе используем Hash
if not torrent.get('id') or torrent.get('id') == '':
if result.get('Hash'):
torrent['id'] = result['Hash']
elif result.get('Url'):
# Пытаемся извлечь ID из URL
url_id_match = re.search(r'/(\d+)/?$', result.get('Url', ''))
if url_id_match:
torrent['id'] = url_id_match.group(1)
else:
torrent['id'] = result.get('Url', '')[-20:] # Используем последние 20 символов URL
torrents.append(torrent)
print(f" ✅ Added to results with ID: {torrent.get('id', 'None')[:20]}...")
else:
print(f" ❌ Filtered out (score: {score:.2f}, size: {size_mb:.1f}MB)")
# Если нашли достаточно результатов, переходим к следующему провайдеру
if len(torrents) >= 20:
break
except Exception as e:
print(f"Error searching on {provider_name}: {type(e).__name__}: {e}")
import traceback
traceback.print_exc()
continue
# Сортируем по количеству сидов и ограничиваем общее количество
torrents.sort(key=lambda x: x.get('seeds', 0), reverse=True)
return torrents[:30] # Возвращаем топ-30 результатов
except Exception as e:
print(f"Error searching torrents: {e}")
import traceback
traceback.print_exc()
return torrents
async def search_torrent_by_id(torrent_id: str) -> dict:
"""Поиск торрента по ID через Torrent Search API"""
try:
async with httpx.AsyncClient() as client:
print(f"Searching torrent by ID: {torrent_id}")
# Получаем список доступных провайдеров
providers_response = await client.get(f"{TORRENT_SEARCH_URL}/api/provider/list")
if providers_response.status_code != 200:
print(f"Failed to get providers: {providers_response.status_code}")
return None
providers = providers_response.json()
print(f"Available providers: {[p['Provider'] for p in providers]}")
# Пробуем найти торрент на всех доступных провайдерах
for provider in providers:
provider_name = provider['Provider'].lower()
try:
print(f"Searching ID {torrent_id} on {provider_name}")
# Пробуем разные варианты API для поиска по ID
search_urls = [
f"{TORRENT_SEARCH_URL}/api/search/id/{provider_name}",
f"{TORRENT_SEARCH_URL}/api/search/{provider_name}",
]
response = None
results = None
for search_url in search_urls:
try:
response = await client.get(search_url, params={"query": torrent_id}, timeout=30.0)
if response.status_code == 200:
results = response.json()
print(f"Response from {provider_name} ({search_url}): {type(results)} - {len(results) if isinstance(results, list) else 'not a list'}")
break
except Exception as e:
print(f"Error trying {search_url}: {e}")
continue
if response and response.status_code == 200 and results:
if isinstance(results, list) and len(results) > 0:
# Берем первый результат
result = results[0]
# Используем Original_Name если Name пустое
torrent_name = result.get('Name', '') or result.get('Original_Name', '')
print(f"Found torrent by ID on {provider_name}: {torrent_name[:100]}...")
# Получаем хэш — используем единую функцию извлечения
hash_value = extract_hash_from_result(result)
if hash_value:
# Генерируем чистую magnet-ссылку с публичными трекерами
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 fields: Hash={result.get('Hash', 'None')[:20]}, Magnet={result.get('Magnet', 'None')[:50]}")
magnet = ""
# Пробуем локальный torapi-qbit если хэш пустой или битый
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(
f"{torapi_add_url}/api/search/id/{provider_name}",
params={"query": torrent_id},
timeout=15.0
)
if fb_resp.status_code == 200:
fb_data = fb_resp.json()
if isinstance(fb_data, list) and len(fb_data) > 0:
fb_result = fb_data[0]
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 hash {fb_hash[:10]}... via extract_hash_from_result")
except Exception as fbe:
print(f"torapi-qbit fallback failed: {fbe}")
# Если хэш всё ещё пустой — пропускаем этого провайдера
if not hash_value and not re.search(r'urn:btih:([a-fA-F0-9]{40}|[a-zA-Z0-9]{32})', magnet):
print(f"Skipping {provider_name}: no valid magnet hash")
continue
# Парсим результат в стандартный формат
torrent = {
"title": torrent_name,
"url": result.get('Url', ''),
"hash": result.get('Hash', ''),
"magnet": magnet,
"torrent_url": result.get('Torrent', ''),
"imdb_url": result.get('IMDb_link', ''),
"kinopoisk_url": result.get('Kinopoisk_link', ''),
"poster": result.get('Poster', ''),
"year": result.get('Year', ''),
"release": result.get('Release', ''),
"type": result.get('Type', ''),
"duration": result.get('Duration', ''),
"audio": result.get('Audio', ''),
"director": result.get('Directer', ''),
"actors": result.get('Actors', ''),
"description": result.get('Description', ''),
"quality": result.get('Quality', ''),
"video": result.get('Video', ''),
"files": result.get('Files', []),
"provider": provider_name,
"id": torrent_id
}
return torrent
else:
print(f"No results found for ID {torrent_id} on {provider_name}")
else:
print(f"Error searching by ID on {provider_name}: {response.status_code} - {response.text}")
except Exception as e:
print(f"Error searching on {provider_name}: {type(e).__name__}: {e}")
import traceback
traceback.print_exc()
continue
print(f"No results found for ID {torrent_id} on any provider via search API")
# Fallback: пробуем локальный torapi-qbit (через qBittorrent)
try:
torapi_add_url = os.getenv("TORAPI_ADD_URL", "http://localhost:8444")
fallback_response = await client.get(
f"{torapi_add_url}/api/search/id/rutracker",
params={"query": torrent_id},
timeout=30.0
)
if fallback_response.status_code == 200:
fallback_results = fallback_response.json()
if isinstance(fallback_results, list) and len(fallback_results) > 0:
result = fallback_results[0]
hash_value = result.get('Hash', '')
if not hash_value:
orig_magnet = result.get('Magnet', '')
hm = re.search(r'urn:btih:([a-fA-F0-9]{40})', orig_magnet)
if hm:
hash_value = hm.group(1)
if hash_value:
torrent_name = result.get('Name', '') or result.get('Original_Name', '')
magnet = generate_clean_magnet(hash_value, torrent_name)
print(f"Fallback: got magnet via torapi-qbit: {magnet[:60]}...")
return {
"title": torrent_name,
"url": result.get('Url', ''),
"hash": hash_value,
"magnet": magnet,
"torrent_url": result.get('Torrent', ''),
"provider": "torapi-qbit",
"id": torrent_id
}
except Exception as e:
print(f"Fallback to torapi-qbit failed: {e}")
print(f"All fallbacks exhausted for ID {torrent_id}")
return None
except Exception as e:
print(f"Error searching torrent by ID: {e}")
import traceback
traceback.print_exc()
return None
def is_movie_torrent(torrent_name: str, movie_title: str, original_title: str = None) -> bool:
"""Проверяет, является ли торрент фильмом"""
torrent_name_lower = torrent_name.lower()
movie_title_lower = movie_title.lower()
original_title_lower = original_title.lower() if original_title else ""
# Исключаем только явно не-фильмы
exclude_keywords = ['игра', 'game', 'музыка', 'music', 'программа', 'program', 'софт', 'software']
for keyword in exclude_keywords:
if keyword in torrent_name_lower:
print(f"Excluding due to keyword '{keyword}': {torrent_name[:100]}...")
return False
# Проверяем, что название содержит название фильма (русское или оригинальное)
contains_title = movie_title_lower in torrent_name_lower
if original_title_lower and not contains_title:
contains_title = original_title_lower in torrent_name_lower
print(f"Title '{movie_title_lower}' or '{original_title_lower}' in torrent: {contains_title}")
return contains_title
def parse_torrent_result(result: dict, movie_title: str, year: str = None) -> dict:
"""Парсит результат поиска торрентов"""
try:
# Извлекаем разрешение из названия
resolution = extract_resolution(result['Name'])
# Извлекаем качество из названия
quality = extract_quality(result['Name'])
# Парсим размер
size_bytes = parse_size_to_bytes(result['Size'])
# Создаем чистую magnet-ссылку с публичными трекерами
magnet = None
if 'Hash' in result and result['Hash']:
# Генерируем чистую magnet-ссылку из хэша с публичными трекерами
hash_value = result['Hash']
torrent_title = result['Name']
magnet = generate_clean_magnet(hash_value, torrent_title)
elif 'Magnet' in result and result['Magnet']:
# Если нет хэша, используем оригинальную magnet-ссылку
magnet = result['Magnet']
elif 'Torrent' in result and result['Torrent']:
# Используем URL на .torrent файл как fallback
magnet = result['Torrent']
# Определяем провайдера по URL
provider = 'Unknown'
torrent_url = result.get('Torrent', '')
if 'rutracker.org' in torrent_url:
provider = 'rutracker'
elif 'kinozal.tv' in torrent_url:
provider = 'kinozal'
elif 'rutor.info' in torrent_url:
provider = 'rutor'
elif 'nnmclub.to' in torrent_url:
provider = 'nonameclub'
# Извлекаем ID из разных возможных полей
torrent_id = (
result.get('Id') or
result.get('id') or
result.get('ID') or
result.get('Hash', '')[:8] or # Используем первые 8 символов хэша как fallback
''
)
# Если ID не найден, пытаемся извлечь из URL
if not torrent_id and result.get('Url'):
url_id_match = re.search(r'/(\d+)/?$', result.get('Url', ''))
if url_id_match:
torrent_id = url_id_match.group(1)
# Если все еще нет ID, используем Hash полностью
if not torrent_id:
torrent_id = result.get('Hash', '')
return {
"title": result['Name'],
"size_bytes": size_bytes,
"size_readable": result['Size'],
"resolution": resolution,
"quality": quality,
"seeds": int(result.get('Seeds', 0)),
"peers": int(result.get('Peers', 0)),
"magnet": magnet,
"download_url": result.get('Torrent', ''),
"category": result.get('Category', ''),
"date": result.get('Date', ''),
"provider": provider,
"id": torrent_id
}
except Exception as e:
print(f"Error parsing torrent result: {e}")
return None
def extract_quality(title: str) -> str:
"""Извлекает качество из названия торрента"""
title_upper = title.upper()
if 'BLURAY' in title_upper or 'BDRIP' in title_upper:
return 'BluRay'
elif 'WEB-DL' in title_upper or 'WEBDL' in title_upper:
return 'WEB-DL'
elif 'WEBRIP' in title_upper or 'WEB-RIP' in title_upper:
return 'WEBRip'
elif 'HDTV' in title_upper:
return 'HDTV'
elif 'DVDRIP' in title_upper or 'DVD-RIP' in title_upper:
return 'DVDRip'
else:
return 'Unknown'
def parse_size_to_bytes(size_str: str) -> int:
"""Конвертирует строку размера в байты"""
size_str = size_str.upper().strip()
multipliers = {
'KB': 1024,
'MB': 1024**2,
'GB': 1024**3,
'TB': 1024**4
}
for unit, multiplier in multipliers.items():
if unit in size_str:
try:
number = float(re.findall(r'[\d.]+', size_str)[0])
return int(number * multiplier)
except (ValueError, IndexError):
pass
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",
"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}"
# Добавляем название в кодировке RFC 3986 для удобства
if title:
from urllib.parse import quote
magnet += f"&dn={quote(title)}"
# Добавляем публичные трекеры
for tracker in public_trackers:
magnet += f"&tr={tracker}"
return magnet
@app.get("/", response_class=HTMLResponse)
async def home(request: Request):
"""Главная страница с формой поиска"""
try:
print(f"Home page requested from {request.client.host}")
return HTMLResponse("""
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>🎬 Поиск фильмов и сериалов</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.container {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border-radius: 20px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
padding: 40px;
max-width: 600px;
width: 100%;
text-align: center;
}
.logo {
font-size: 4rem;
margin-bottom: 20px;
background: linear-gradient(45deg, #667eea, #764ba2);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
h1 {
color: #333;
font-size: 2.5rem;
margin-bottom: 10px;
font-weight: 700;
}
.subtitle {
color: #666;
font-size: 1.2rem;
margin-bottom: 40px;
line-height: 1.6;
}
.search-form {
display: flex;
flex-direction: column;
gap: 20px;
margin-bottom: 30px;
}
.search-input {
padding: 15px 20px;
border: 2px solid #e1e5e9;
border-radius: 50px;
font-size: 1.1rem;
outline: none;
transition: all 0.3s ease;
background: #fff;
}
.search-input:focus {
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.search-button {
padding: 15px 30px;
background: linear-gradient(45deg, #667eea, #764ba2);
color: white;
border: none;
border-radius: 50px;
font-size: 1.1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
text-transform: uppercase;
letter-spacing: 1px;
}
.search-button:hover {
transform: translateY(-2px);
box-shadow: 0 10px 20px rgba(102, 126, 234, 0.3);
}
.search-button:active {
transform: translateY(0);
}
.features {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 20px;
margin-top: 40px;
}
.feature {
padding: 20px;
background: rgba(102, 126, 234, 0.1);
border-radius: 15px;
transition: all 0.3s ease;
}
.feature:hover {
transform: translateY(-5px);
background: rgba(102, 126, 234, 0.2);
}
.feature-icon {
font-size: 2rem;
margin-bottom: 10px;
}
.feature-title {
font-weight: 600;
color: #333;
margin-bottom: 5px;
}
.feature-desc {
font-size: 0.9rem;
color: #666;
}
.loading {
display: none;
margin-top: 20px;
}
.spinner {
border: 3px solid #f3f3f3;
border-top: 3px solid #667eea;
border-radius: 50%;
width: 30px;
height: 30px;
animation: spin 1s linear infinite;
margin: 0 auto;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.error {
color: #e74c3c;
background: #fdf2f2;
padding: 10px 15px;
border-radius: 10px;
margin-top: 20px;
display: none;
}
.success {
color: #27ae60;
background: #f0f9f0;
padding: 10px 15px;
border-radius: 10px;
margin-top: 20px;
display: none;
}
@media (max-width: 768px) {
.container {
padding: 30px 20px;
}
h1 {
font-size: 2rem;
}
.logo {
font-size: 3rem;
}
}
</style>
</head>
<body>
<div class="container">
<div class="logo">🎬</div>
<h1>Поиск фильмов и сериалов</h1>
<p class="subtitle">
Найдите любой фильм или сериал и скачайте его через торрент.<br>
Быстро, удобно и бесплатно!
</p>
<form class="search-form" action="/search" method="post" id="searchForm">
<input
type="text"
name="movie_title"
class="search-input"
placeholder="Введите название фильма или сериала..."
required
autocomplete="off"
>
<button type="submit" class="search-button">
🔍 Найти
</button>
</form>
<div class="loading" id="loading">
<div class="spinner"></div>
<p>Ищем фильмы...</p>
</div>
<div class="error" id="error"></div>
<div class="success" id="success"></div>
<div class="features">
<div class="feature">
<div class="feature-icon">🎯</div>
<div class="feature-title">Точный поиск</div>
<div class="feature-desc">Находим именно то, что вы ищете</div>
</div>
<div class="feature">
<div class="feature-icon"></div>
<div class="feature-title">Быстро</div>
<div class="feature-desc">Результаты за секунды</div>
</div>
<div class="feature">
<div class="feature-icon">🆓</div>
<div class="feature-title">Бесплатно</div>
<div class="feature-desc">Полностью бесплатный сервис</div>
</div>
<div class="feature">
<div class="feature-icon">📱</div>
<div class="feature-title">Удобно</div>
<div class="feature-desc">Работает на всех устройствах</div>
</div>
</div>
</div>
<script>
document.getElementById('searchForm').addEventListener('submit', function(e) {
const loading = document.getElementById('loading');
const error = document.getElementById('error');
const success = document.getElementById('success');
// Скрываем предыдущие сообщения
error.style.display = 'none';
success.style.display = 'none';
// Показываем загрузку
loading.style.display = 'block';
// Проверяем, что поле не пустое
const input = document.querySelector('input[name="movie_title"]');
if (!input.value.trim()) {
e.preventDefault();
loading.style.display = 'none';
error.textContent = 'Пожалуйста, введите название фильма';
error.style.display = 'block';
return;
}
});
// Автофокус на поле ввода
document.querySelector('input[name="movie_title"]').focus();
</script>
</body>
</html>
""")
except Exception as e:
print(f"Error in home page: {e}")
raise HTTPException(status_code=500, detail=f"Error loading home page: {str(e)}")
@app.post("/search", response_class=HTMLResponse)
async def search(request: Request, movie_title: str = Form(...)):
"""Обработка поиска фильмов"""
try:
search_results = await search_movies(movie_title)
return templates.TemplateResponse(
"results.html",
{
"request": request,
"query": movie_title,
"movies": search_results.get("results", []),
"total_results": search_results.get("total_results", 0)
}
)
except HTTPException as e:
return templates.TemplateResponse(
"error.html",
{
"request": request,
"error": str(e.detail)
}
)
@app.get("/api/search/{movie_title}")
async def api_search(movie_title: str):
"""API endpoint для поиска фильмов (возвращает JSON)"""
try:
search_results = await search_movies(movie_title)
return search_results
except HTTPException as e:
raise e
@app.get("/api/torrents/{movie_title}")
async def api_search_torrents(movie_title: str, year: str = None, original_title: str = None, genres: str = None, imdb_id: str = None):
"""API endpoint для поиска торрентов фильма"""
try:
# Парсим жанры из строки
genres_list = None
if genres:
genres_list = [g.strip() for g in genres.split(',')]
torrents = await search_torrents(movie_title, year, original_title, genres_list, imdb_id)
return {"torrents": torrents, "movie_title": movie_title, "year": year}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Torrent search error: {str(e)}")
@app.get("/api/torrent/id/{torrent_id}")
async def api_search_torrent_by_id(torrent_id: str):
"""API endpoint для поиска торрента по ID"""
try:
torrent = await search_torrent_by_id(torrent_id)
if torrent:
return {"status": "success", "torrent": torrent}
else:
return {"status": "error", "message": f"Торрент с ID {torrent_id} не найден"}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Torrent ID search error: {str(e)}")
@app.get("/api/check-qbittorrent")
async def check_qbittorrent_connection():
"""Проверка доступности qBittorrent"""
try:
qb_username = os.getenv("QBITTORRENT_USERNAME", "admin")
qb_password = os.getenv("QBITTORRENT_PASSWORD", "vrubel07")
qb_host = os.getenv("QBITTORRENT_HOST", "localhost")
qb_port = os.getenv("QBITTORRENT_PORT", "8082")
qb_url = f"http://{qb_host}:{qb_port}"
async with httpx.AsyncClient(timeout=10.0) as client:
try:
auth_response = await client.post(
f"{qb_url}/api/v2/auth/login",
data={"username": qb_username, "password": qb_password}
)
if auth_response.status_code == 200 and auth_response.text.strip() == "Ok.":
# Получаем версию qBittorrent
version_response = await client.get(f"{qb_url}/api/v2/app/version")
version = version_response.text if version_response.status_code == 200 else "Unknown"
# Получаем список торрентов
torrents_response = await client.get(f"{qb_url}/api/v2/torrents/info")
torrents_count = 0
if torrents_response.status_code == 200:
torrents_count = len(torrents_response.json())
return {
"status": "success",
"message": "qBittorrent доступен",
"url": qb_url,
"version": version,
"torrents_count": torrents_count
}
else:
return {
"status": "error",
"message": f"Ошибка аутентификации: {auth_response.text}",
"url": qb_url
}
except httpx.ConnectError as e:
return {
"status": "error",
"message": f"Не удалось подключиться к qBittorrent: {str(e)}",
"url": qb_url,
"hint": "Проверьте, что qBittorrent запущен и доступен по адресу выше. Если используется VPN, убедитесь, что он настроен правильно."
}
except Exception as e:
return {
"status": "error",
"message": f"Ошибка при проверке: {str(e)}"
}
@app.get("/torrents/{movie_title}", response_class=HTMLResponse)
async def torrents_page(request: Request, movie_title: str, year: str = None):
"""Страница с результатами поиска торрентов"""
try:
torrents = await search_torrents(movie_title, year)
return templates.TemplateResponse(
"torrents.html",
{
"request": request,
"movie_title": movie_title,
"year": year,
"torrents": torrents
}
)
except Exception as e:
return templates.TemplateResponse(
"error.html",
{
"request": request,
"error": f"Ошибка поиска торрентов: {str(e)}"
}
)
@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:
print(f"Attempting to add torrent with ID: {torrent_id}")
if not torrent_id or torrent_id.strip() == '':
return {"status": "error", "message": "ID торрента не указан"}
# Если magnet передан напрямую (из результатов поиска), используем его
if magnet and magnet.startswith('magnet:'):
print(f"Using direct magnet link: {magnet[:100]}...")
torrent_info = {
"title": torrent_title or torrent_id,
"hash": "",
"magnet": magnet,
"torrent_url": ""
}
else:
# Fallback: ищем по ID через TorAPI
torrent_info = await search_torrent_by_id(torrent_id)
if not torrent_info:
print(f"Torrent info not found for ID: {torrent_id}")
return {"status": "error", "message": f"Торрент с ID {torrent_id} не найден. Проверьте логи для деталей."}
print(f"Found torrent info: title={torrent_info.get('title', 'Unknown')[:50]}, magnet={'present' if torrent_info.get('magnet') else 'missing'}, torrent_url={'present' if torrent_info.get('torrent_url') else 'missing'}")
# Получаем учетные данные из переменных окружения
qb_username = os.getenv("QBITTORRENT_USERNAME", "admin")
qb_password = os.getenv("QBITTORRENT_PASSWORD", "vrubel07")
qb_host = os.getenv("QBITTORRENT_HOST", "localhost")
qb_port = os.getenv("QBITTORRENT_PORT", "8082")
qb_url = f"http://{qb_host}:{qb_port}"
async with httpx.AsyncClient(timeout=60.0) as client:
# Аутентификация в qBittorrent
auth_response = await client.post(
f"{qb_url}/api/v2/auth/login",
data={"username": qb_username, "password": qb_password}
)
if auth_response.status_code != 200 or auth_response.text.strip() != "Ok.":
return {"status": "error", "message": f"Ошибка аутентификации в qBittorrent: {auth_response.text}"}
print("Successfully authenticated with qBittorrent")
# Пробуем сначала добавить через magnet-ссылку (более надежно)
magnet = torrent_info.get('magnet', '').strip()
# Проверяем, что magnet ссылка валидна (содержит хэш)
if magnet and 'urn:btih:' in magnet and len(magnet) > 20:
print(f"Trying to add via magnet link: {magnet[:100]}...")
add_response = await client.post(
f"{qb_url}/api/v2/torrents/add",
data={"urls": magnet}
)
print(f"Add via magnet response status: {add_response.status_code}")
print(f"Add via magnet response text: {add_response.text}")
if add_response.status_code == 200 and add_response.text.strip() == "Ok.":
# Проверяем, что торрент действительно добавился
await asyncio.sleep(2) # Ждем немного
torrent_hash = torrent_info.get('hash', '').upper()
# Если хэш не был в torrent_info, извлекаем из magnet ссылки
if not torrent_hash and magnet:
hash_match = re.search(r'urn:btih:([a-fA-F0-9]{40}|[a-zA-Z0-9]{32})', magnet)
if hash_match:
torrent_hash = hash_match.group(1).upper()
# Реальная верификация: проверяем, что торрент появился в 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
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:
print(f"Magnet link invalid or empty: '{magnet[:50] if magnet else 'None'}...', trying .torrent file...")
# Если magnet не сработал, пробуем .torrent файл
torrent_url = torrent_info.get('torrent_url', '')
if not torrent_url:
# Пробуем также поле url
torrent_url = torrent_info.get('url', '')
if torrent_url:
print(f"Trying to add via .torrent file: {torrent_url}")
# Сначала пробуем скачать .torrent через NL-прокси (обходит DPI)
try:
proxy_base = os.getenv("TMDB_PROXY_URL", "http://localhost:8001")
print(f"Downloading .torrent via NL proxy: {proxy_base}/proxy-torrent")
proxy_resp = await client.get(
f"{proxy_base}/proxy-torrent",
params={"url": torrent_url},
timeout=30.0
)
if proxy_resp.status_code == 200:
torrent_content = proxy_resp.content
print(f"Downloaded .torrent via NL proxy: {len(torrent_content)} bytes")
# Загружаем файл в qBittorrent
add_response = await client.post(
f"{qb_url}/api/v2/torrents/add",
files={"torrents": ("torrent.torrent", torrent_content, "application/x-bittorrent")}
)
print(f"Add via .torrent file upload response: {add_response.status_code} - {add_response.text}")
else:
print(f"NL proxy failed: {proxy_resp.status_code}, sending URL directly to qBittorrent")
add_response = await client.post(
f"{qb_url}/api/v2/torrents/add",
data={"urls": torrent_url}
)
print(f"Add via .torrent response status: {add_response.status_code}")
print(f"Add via .torrent response text: {add_response.text}")
except Exception as proxy_err:
print(f"NL proxy error: {proxy_err}, falling back to direct URL")
add_response = await client.post(
f"{qb_url}/api/v2/torrents/add",
data={"urls": torrent_url}
)
print(f"Add via .torrent response status: {add_response.status_code}")
print(f"Add via .torrent response text: {add_response.text}")
if add_response.status_code == 200 and add_response.text.strip() == "Ok.":
# Проверяем, что торрент действительно добавился
await asyncio.sleep(2) # Ждем немного
# Пытаемся найти торрент в списке по названию (так как хэш может быть неизвестен)
torrent_title = torrent_info.get('title', '').lower()
torrents_response = await client.get(f"{qb_url}/api/v2/torrents/info")
if torrents_response.status_code == 200:
torrents = torrents_response.json()
# Ищем торрент по названию (первые 30 символов)
added_torrent = None
for torrent in torrents:
torrent_name = torrent.get('name', '').lower()
if torrent_title and torrent_name:
if torrent_title[:30] in torrent_name or torrent_name[:30] in torrent_title:
added_torrent = torrent
break
if added_torrent:
return {
"status": "success",
"message": f"Торрент '{torrent_info.get('title', 'Unknown')[:50]}...' успешно добавлен в qBittorrent через .torrent файл!",
"torrent_hash": added_torrent.get('hash', ''),
"torrent_name": added_torrent.get('name', torrent_info.get('title', 'Unknown'))
}
# Торрент не появился — честный error вместо ложного success
print(f"WARNING: qBittorrent returned Ok. for .torrent but torrent was not found in list (title={torrent_title or 'empty'})")
Добавлена система уведомлений о завершении загрузки в Telegram - Реализован класс DownloadMonitor для мониторинга загрузок в qBittorrent - Добавлена автоматическая аутентификация в qBittorrent API - Система проверяет статус загрузок каждые 30 секунд - Автоматические уведомления при завершении загрузки: * ✅ Успешное завершение с информацией о фильме и торренте * ❌ Уведомления об ошибках загрузки - Интеграция с API: возврат torrent_hash и torrent_name - Отслеживание загрузок по hash с привязкой к пользователю - Фоновый мониторинг через отдельный поток - Уведомления отправляются напрямую в Telegram чат пользователя Технические детали: - Добавлен класс DownloadMonitor в telegram_bot.py - Модифицирован API endpoint /api/add-torrent в app.py - Добавлена поддержка возврата torrent_hash и torrent_name - Реализована система отслеживания активных загрузок - Автоматическое удаление из мониторинга после уведомления Теперь пользователи получают уведомления: 🎉 'Фильм скачался!' - при успешном завершении ❌ 'Ошибка загрузки' - при проблемах с загрузкой
2025-10-09 12:53:06 +03:00
return {
"status": "error",
"message": f"qBittorrent не добавил торрент. Возможно, файл недоступен или DPI-блокировка. Попробуйте magnet-ссылку."
Добавлена система уведомлений о завершении загрузки в Telegram - Реализован класс DownloadMonitor для мониторинга загрузок в qBittorrent - Добавлена автоматическая аутентификация в qBittorrent API - Система проверяет статус загрузок каждые 30 секунд - Автоматические уведомления при завершении загрузки: * ✅ Успешное завершение с информацией о фильме и торренте * ❌ Уведомления об ошибках загрузки - Интеграция с API: возврат torrent_hash и torrent_name - Отслеживание загрузок по hash с привязкой к пользователю - Фоновый мониторинг через отдельный поток - Уведомления отправляются напрямую в Telegram чат пользователя Технические детали: - Добавлен класс DownloadMonitor в telegram_bot.py - Модифицирован API endpoint /api/add-torrent в app.py - Добавлена поддержка возврата torrent_hash и torrent_name - Реализована система отслеживания активных загрузок - Автоматическое удаление из мониторинга после уведомления Теперь пользователи получают уведомления: 🎉 'Фильм скачался!' - при успешном завершении ❌ 'Ошибка загрузки' - при проблемах с загрузкой
2025-10-09 12:53:06 +03:00
}
else:
error_msg = add_response.text.strip()
if "Fails" in error_msg or "Bad Request" in error_msg:
return {"status": "error", "message": f"qBittorrent отклонил торрент. Возможно, проблема с VPN или файл недоступен. Ответ: {error_msg}"}
return {"status": "error", "message": f"Ошибка добавления торрента (HTTP {add_response.status_code}): {error_msg}"}
else:
return {"status": "error", "message": f"Не найдена ни валидная magnet-ссылка, ни .torrent файл для торрента {torrent_id}. Проверьте, что торрент доступен."}
except httpx.ConnectError as e:
print(f"Connection error: {e}")
raise HTTPException(status_code=503, detail=f"qBittorrent недоступен по адресу {qb_url}. Убедитесь, что сервис запущен")
except Exception as e:
print(f"Unexpected error: {e}")
raise HTTPException(status_code=500, detail=f"Ошибка при добавлении торрента: {str(e)}")
if __name__ == "__main__":
import uvicorn
print("Starting server on 0.0.0.0:8000")
uvicorn.run(app, host="0.0.0.0", port=8000, log_level="debug", access_log=True)