148 lines
5.4 KiB
Python
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))
|