first commit
This commit is contained in:
commit
58cfd95600
35 files changed
+5357
No files matched your search
@@ -0,0 +1,147 @@
|
||||
"""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))
|
||||
Reference in new issue
Block a user