157 lines
5.8 KiB
Python
157 lines
5.8 KiB
Python
"""aria2 JSON-RPC. aria2 is started alongside the API server, not exposed publicly."""
|
|
import asyncio
|
|
import base64
|
|
import json
|
|
import os
|
|
import urllib.parse
|
|
from pathlib import Path
|
|
|
|
import httpx
|
|
|
|
from . import archive_auth, store
|
|
|
|
RPC = 'http://127.0.0.1:6800/jsonrpc'
|
|
|
|
|
|
async def rpc(method, *params):
|
|
payload = {
|
|
'jsonrpc': '2.0', 'id': 'x360', 'method': 'aria2.' + method,
|
|
'params': ['token:' + os.environ['ARIA2_SECRET'], *params],
|
|
}
|
|
# On a container restart the API may start slightly before aria2 RPC.
|
|
# Retry the connection instead of permanently failing restored jobs.
|
|
async with httpx.AsyncClient(timeout=15) as client:
|
|
for attempt in range(15):
|
|
try:
|
|
r = await client.post(RPC, json=payload)
|
|
r.raise_for_status()
|
|
answer = r.json()
|
|
if 'error' in answer:
|
|
raise RuntimeError(answer['error'].get('message', 'Bledna odpowiedz aria2'))
|
|
return answer['result']
|
|
except (httpx.ConnectError, httpx.ConnectTimeout):
|
|
if attempt == 14:
|
|
raise
|
|
await asyncio.sleep(1)
|
|
|
|
|
|
def source_urls(job):
|
|
if job['source_type'] == 'archive_many':
|
|
return json.loads(job['source'])
|
|
return [job['source']]
|
|
|
|
|
|
def target_filename(uri):
|
|
if uri.lower().startswith('magnet:'):
|
|
return None
|
|
name = urllib.parse.unquote(Path(urllib.parse.urlsplit(uri).path).name)
|
|
if not name or name in ('.', '..'):
|
|
return 'pobrany-plik.bin'
|
|
# Do not allow URI-provided paths to escape the download directory.
|
|
return ''.join(c if c not in '\\/:*?"<>|\x00\n\r' else '_' for c in name)[:200]
|
|
|
|
|
|
async def begin(job):
|
|
base = store.DATA / 'raw' / job['id']
|
|
base.mkdir(parents=True, exist_ok=True)
|
|
urls = source_urls(job)
|
|
ix = job['part_index']
|
|
if ix >= len(urls):
|
|
store.update(job['id'], state='preparing')
|
|
return
|
|
uri = urls[ix]
|
|
opts = {
|
|
'dir': str(base), 'continue': 'true', 'max-connection-per-server': '2',
|
|
'split': '2', 'seed-time': '0', 'auto-file-renaming': 'false',
|
|
'allow-overwrite': 'false', 'max-tries': '8', 'retry-wait': '30',
|
|
}
|
|
if job['source_type'] != 'torrent_selected':
|
|
name = target_filename(uri)
|
|
if name and '.' in name:
|
|
opts['out'] = name
|
|
elif name:
|
|
# Let the server's Content-Disposition supply the real filename.
|
|
opts['content-disposition'] = 'true'
|
|
if job['source_type'] == 'torrent_selected':
|
|
torrent = store.DATA / 'torrents' / (job['id'] + '.torrent')
|
|
encoded = base64.b64encode(torrent.read_bytes()).decode('ascii')
|
|
select = json.loads(job['source'])['selection']
|
|
opts['select-file'] = select
|
|
opts['bt-remove-unselected-file'] = 'true'
|
|
gid = await rpc('addTorrent', encoded, [], opts)
|
|
note = 'Pobieranie wybranych plików torrenta'
|
|
else:
|
|
if archive_auth.is_archive_download(uri):
|
|
# aria2 downloads from the local proxy. It never receives IA cookies.
|
|
opts['header'] = [archive_auth.signed_header(job['id'], ix)]
|
|
uri = archive_auth.internal_url(job['id'], ix)
|
|
opts['split'] = '1'
|
|
opts['max-connection-per-server'] = '1'
|
|
gid = await rpc('addUri', [uri], opts)
|
|
note = f'Pobieranie pliku {ix+1}/{len(urls)}'
|
|
store.update(job['id'], gid=gid, state='downloading', note=note)
|
|
|
|
|
|
async def poll(job):
|
|
urls = source_urls(job)
|
|
gid = job['gid']
|
|
if not gid:
|
|
await begin(job)
|
|
return
|
|
try:
|
|
response = await rpc('tellStatus', gid, ['gid','status','totalLength','completedLength',
|
|
'downloadSpeed','followedBy','errorMessage'])
|
|
except RuntimeError as ex:
|
|
# On restart aria2 may lose an old GID. Re-add to resume available .aria2 files.
|
|
if 'not found' in str(ex).lower():
|
|
store.update(job['id'], gid='', note='Odzyskiwanie transferu po restarcie')
|
|
return
|
|
raise
|
|
|
|
followed = response.get('followedBy', [])
|
|
if followed:
|
|
store.update(job['id'], gid=followed[0], note='Magnet: metadane gotowe')
|
|
return
|
|
state = response.get('status')
|
|
length = int(response.get('totalLength', 0) or 0)
|
|
completed = int(response.get('completedLength', 0) or 0)
|
|
speed = int(response.get('downloadSpeed', 0) or 0)
|
|
part_progress = (completed / length) if length else 0
|
|
ix = job['part_index']
|
|
pct = (ix + part_progress) / len(urls) * 100
|
|
store.update(job['id'], progress=round(pct, 2), speed=speed, total=length,
|
|
completed=completed, note=f'Pobieranie {ix+1}/{len(urls)}: {state}')
|
|
if state == 'complete':
|
|
if ix + 1 < len(urls):
|
|
store.update(job['id'], gid='', part_index=ix+1)
|
|
else:
|
|
store.update(job['id'], state='preparing', progress=100, speed=0,
|
|
note='Pobieranie ukonczone, przygotowuje pliki')
|
|
elif state in ('error','removed'):
|
|
message = response.get('errorMessage') or state
|
|
if job['source_type'] == 'archive_many' or (job['source_type'] == 'url' and archive_auth.is_archive_download(urls[ix])):
|
|
if any(x in message.lower() for x in ('authorization', '403', '401', 'forbidden')):
|
|
message += ' — Internet Archive: sprawdź logowanie i dostęp do pliku.'
|
|
raise RuntimeError('aria2: ' + message)
|
|
elif state == 'paused':
|
|
store.update(job['id'], state='paused', speed=0)
|
|
|
|
|
|
async def cancel_download(gid):
|
|
if not gid:
|
|
return
|
|
try:
|
|
await rpc('forceRemove', gid)
|
|
except (RuntimeError, httpx.HTTPError):
|
|
pass
|
|
|
|
|
|
async def pause_download(gid):
|
|
if gid:
|
|
await rpc('forcePause', gid)
|
|
|
|
|
|
async def resume_download(gid):
|
|
if gid:
|
|
await rpc('unpause', gid)
|