Files
x360-web-queue/app/bt.py
T
2026-10-15 14:15:10 +02:00

148 lines
5.4 KiB
Python

"""Supervise anacrolix-based BitTorrent subprocesses (no aria2 for torrents).
Torrent payload data starts only after file selection. All subprocess state is
written as atomic JSON so a restart can detect interrupted work and resume.
"""
import asyncio
import json
import os
import time
from pathlib import Path
from . import store
BT_HELPER = '/usr/local/bin/x360-bt'
_INSPECTIONS = {}
_DOWNLOADS = {}
class BitTorrentError(RuntimeError):
pass
def exe():
candidate = os.getenv('BT_HELPER', BT_HELPER)
if not Path(candidate).is_file():
raise BitTorrentError(f'Brak silnika BitTorrent: {candidate} (przebuduj obraz Docker)')
return candidate
def _read(path):
try:
value = json.loads(Path(path).read_text(encoding='utf-8'))
return value if isinstance(value, dict) else {}
except (FileNotFoundError, ValueError, OSError):
return {}
def inspect_state(preview_id):
state = _read(store.DATA / 'metadata' / preview_id / 'state.json')
process = _INSPECTIONS.get(preview_id)
if not state and (not process or process.returncode is not None):
return {'status': 'error', 'error': 'Klient BitTorrent przerwał odczyt metadanych. Sprawdź engine.log podglądu lub ponów.'}
return state
def job_state(job_id):
return _read(store.DATA / 'bt' / (job_id + '.json'))
async def _spawn(args, log_path):
log_path.parent.mkdir(parents=True, exist_ok=True)
with log_path.open('ab') as log:
try:
return await asyncio.create_subprocess_exec(*args, stdin=asyncio.subprocess.DEVNULL,
stdout=log, stderr=asyncio.subprocess.STDOUT)
except (OSError, ValueError) as exc:
raise BitTorrentError('Nie można uruchomić silnika BT: ' + str(exc)) from exc
async def start_inspection(preview_id, magnet):
from . import torrent_meta
folder = torrent_meta.inspect_dir(preview_id)
args = [exe(), 'inspect', '-magnet', magnet, '-out', str(folder / 'metadata.torrent'),
'-status', str(folder / 'state.json'), '-timeout', '5m', '-port', '0']
process = await _spawn(args, folder / 'engine.log')
_INSPECTIONS[preview_id] = process
return process
async def _stop(process):
if process and process.returncode is None:
process.terminate()
try:
await asyncio.wait_for(process.wait(), 6)
except asyncio.TimeoutError:
process.kill()
await process.wait()
async def stop_inspection(preview_id):
await _stop(_INSPECTIONS.pop(preview_id, None))
async def start_download(job):
jid = job['id']
old = _DOWNLOADS.get(jid)
if old and old.returncode is None:
return
path = store.DATA / 'torrents' / (jid + '.torrent')
if not path.is_file():
raise BitTorrentError('Brak zapisanego pliku .torrent dla zadania')
try:
spec = json.loads(job['source'])
selection = spec['selection']
except (ValueError, KeyError, TypeError) as exc:
raise BitTorrentError('Uszkodzony zapis wyboru plików torrenta') from exc
output = store.DATA / 'raw' / jid
output.mkdir(parents=True, exist_ok=True)
status_path = store.DATA / 'bt' / (jid + '.json')
status_path.parent.mkdir(parents=True, exist_ok=True)
status_path.unlink(missing_ok=True)
args = [exe(), 'fetch', '-torrent', str(path), '-select', selection, '-out', str(output),
'-status', str(status_path), '-port', os.getenv('BT_PORT', '6881')]
_DOWNLOADS[jid] = await _spawn(args, store.DATA / 'logs' / (jid + '.bt.log'))
store.update(jid, gid='', state='downloading', note='BitTorrent: uruchomiono klienta')
async def poll_download(job):
jid = job['id']
process = _DOWNLOADS.get(jid)
if process is None:
# A container restart loses the subprocess; persistently downloaded
# pieces in data/raw are verified and resumed by the helper.
await start_download(job)
return
state = job_state(jid)
done, total = int(state.get('downloaded', 0) or 0), int(state.get('total', 0) or 0)
speed = int(state.get('speed', 0) or 0)
peers = int(state.get('connections', 0) or 0)
known = int(state.get('known_peers', 0) or 0)
if state.get('status') == 'done' and process.returncode == 0:
await process.wait()
_DOWNLOADS.pop(jid, None)
store.update(jid, state='preparing', progress=100, speed=0,
note='BitTorrent ukończony, przygotowanie plików')
return
if state.get('status') == 'error' or process.returncode not in (None, 0):
await process.wait()
_DOWNLOADS.pop(jid, None)
raise BitTorrentError(state.get('error') or 'Silnik BitTorrent zakończył się niepowodzeniem; sprawdź log BT')
if process.returncode == 0 and state.get('status') != 'done':
_DOWNLOADS.pop(jid, None)
raise BitTorrentError('Silnik BitTorrent zakończył pracę bez potwierdzenia ukończenia danych')
percent = round(done / total * 100, 2) if total else 0
store.update(jid, progress=percent, speed=speed, total=total, completed=done,
note=f'BitTorrent: {peers} połączeń, {known} znanych peerów')
async def stop_download(job_id):
await _stop(_DOWNLOADS.pop(job_id, None))
async def shutdown():
jobs = list(_INSPECTIONS.values()) + list(_DOWNLOADS.values())
_INSPECTIONS.clear()
_DOWNLOADS.clear()
await asyncio.gather(*(_stop(p) for p in jobs))