first commit
This commit is contained in:
commit
58cfd95600
35 files changed
+5357
No files matched your search
@@ -0,0 +1,760 @@
|
||||
"""Direct, authenticated Internet Archive downloader.
|
||||
|
||||
Avoids the old aria2 -> localhost HTTP relay (which hid origin errors behind
|
||||
HTTP 500). Supports Range resumes, transient 5xx/429 retries and pause/cancel.
|
||||
Only forwards IA cookies via httpx's domain-scoped cookie jar.
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import time
|
||||
from collections import deque
|
||||
from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
|
||||
from contextlib import closing
|
||||
from threading import Event, Lock
|
||||
from pathlib import Path
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import httpx
|
||||
|
||||
from . import archive_auth, store
|
||||
from .download import source_urls, target_filename
|
||||
|
||||
RETRY_CODES = {408, 425, 429, 500, 502, 503, 504}
|
||||
MAX_ATTEMPTS = 5
|
||||
MAX_CONNECTIONS = 50
|
||||
MIN_SEGMENT_BYTES = 8 * 1024 * 1024
|
||||
CHUNK_BYTES = 256 * 1024
|
||||
RANGE = re.compile(r'^bytes (\d+)-(\d+)/(\d+|\*)$', re.I)
|
||||
FINISH_RANGE = re.compile(r'^bytes \*/(\d+)$', re.I)
|
||||
LOG_LOCK = Lock()
|
||||
|
||||
|
||||
class ArchiveDownloadError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class ArchiveStopped(Exception):
|
||||
"""Pause or cancellation was requested, not a transfer error."""
|
||||
|
||||
|
||||
def _client():
|
||||
return httpx.Client(timeout=httpx.Timeout(90, connect=25), cookies=archive_auth.session_cookies(),
|
||||
follow_redirects=False, trust_env=True)
|
||||
|
||||
|
||||
def _check_state(jid, stop=None):
|
||||
if stop is not None and stop.is_set():
|
||||
raise ArchiveStopped()
|
||||
current = store.get(jid)
|
||||
if not current or current['state'] in ('paused', 'cancelled', 'error'):
|
||||
raise ArchiveStopped()
|
||||
|
||||
|
||||
def _response(client, url, headers):
|
||||
current = url
|
||||
for _ in range(8):
|
||||
if not archive_auth.allowed_redirect(current):
|
||||
raise ArchiveDownloadError('Archive.org: nieprawidłowe przekierowanie poza domenę Archive.org')
|
||||
request = client.build_request('GET', current, headers=headers)
|
||||
response = client.send(request, stream=True)
|
||||
if response.status_code in (301, 302, 303, 307, 308) and 'location' in response.headers:
|
||||
next_url = urljoin(current, response.headers['location'])
|
||||
response.close()
|
||||
current = next_url
|
||||
continue
|
||||
return response
|
||||
raise ArchiveDownloadError('Archive.org: zbyt wiele przekierowań')
|
||||
|
||||
|
||||
def _log(job_id, message):
|
||||
target = store.DATA / 'logs' / (job_id + '.download.log')
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
with LOG_LOCK:
|
||||
with target.open('a', encoding='utf-8') as fp:
|
||||
fp.write(time.strftime('%Y-%m-%d %H:%M:%S') + ' ' + message + '\n')
|
||||
|
||||
|
||||
def _transfer_one(job, *, batch=None, connections=None, stop=None):
|
||||
jid = job['id']
|
||||
ix = job['part_index']
|
||||
urls = source_urls(job)
|
||||
if ix >= len(urls):
|
||||
store.update(jid, state='preparing')
|
||||
return
|
||||
uri = urls[ix]
|
||||
if not archive_auth.is_archive_download(uri):
|
||||
raise ArchiveDownloadError('Nieprawidłowy URL pobierania Archive.org')
|
||||
root = store.DATA / 'raw' / jid
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
filename = target_filename(uri)
|
||||
if not filename:
|
||||
raise ArchiveDownloadError('Brak nazwy pliku Archive.org')
|
||||
destination = root / filename
|
||||
partial = root / (filename + '.part')
|
||||
_check_state(jid, stop)
|
||||
if batch is None:
|
||||
store.update(jid, state='downloading', gid='', note=f'Archive.org: plik {ix+1}/{len(urls)}')
|
||||
_log(jid, f'Rozpoczęto plik {ix+1}/{len(urls)}: {filename}')
|
||||
|
||||
if connections is None:
|
||||
connections = _connection_limit()
|
||||
# An existing manifest may contain many segments even if the new budget
|
||||
# only allows one active connection. Keep those downloaded bytes.
|
||||
if (connections > 1 or _segment_dir(partial).exists()) and _parallel_transfer(
|
||||
job, ix, urls, uri, filename, partial, destination, connections, batch=batch, stop=stop):
|
||||
return
|
||||
# If the server cannot serve ranges (or parallel transfer was disabled),
|
||||
# keep the verified single-stream prefix, not an unfinished assembly.
|
||||
_discard_parallel_state(partial)
|
||||
# Existing aria2 control files are not compatible with this downloader.
|
||||
# Start from our own .part file rather than assuming an aria2 file is complete.
|
||||
with _client() as client:
|
||||
for attempt in range(MAX_ATTEMPTS):
|
||||
_check_state(jid, stop)
|
||||
start = partial.stat().st_size if partial.exists() else 0
|
||||
headers = {'User-Agent': 'X360-Web-Queue/2.0', 'Accept-Encoding': 'identity'}
|
||||
if start:
|
||||
headers['Range'] = f'bytes={start}-'
|
||||
try:
|
||||
with closing(_response(client, uri, headers)) as response:
|
||||
code = response.status_code
|
||||
if code in RETRY_CODES:
|
||||
_log(jid, f'HTTP {code} z Archive.org, próba {attempt+1}/{MAX_ATTEMPTS}')
|
||||
reason = f'Archive.org HTTP {code}'
|
||||
if attempt == MAX_ATTEMPTS - 1:
|
||||
raise ArchiveDownloadError(f'{reason} po {MAX_ATTEMPTS} próbach; możliwy problem serwera Archive.org')
|
||||
elif code in (401, 403):
|
||||
raise ArchiveDownloadError(f'Archive.org HTTP {code}: brak dostępu. Sprawdź sesję i uprawnienia do pliku.')
|
||||
elif code == 404:
|
||||
raise ArchiveDownloadError('Archive.org HTTP 404: nie znaleziono wybranego pliku')
|
||||
elif code == 416 and start:
|
||||
m = FINISH_RANGE.fullmatch(response.headers.get('Content-Range', '').strip())
|
||||
if m and int(m.group(1)) == start:
|
||||
_check_state(jid, stop)
|
||||
partial.replace(destination)
|
||||
_finish(job, ix, urls, batch=batch)
|
||||
_log(jid, f'Ukończono {filename} (HTTP 416 potwierdziło rozmiar)')
|
||||
return
|
||||
partial.unlink(missing_ok=True)
|
||||
continue
|
||||
elif code not in (200, 206):
|
||||
raise ArchiveDownloadError(f'Archive.org HTTP {code}: odmowa pobrania pliku')
|
||||
else:
|
||||
if 'text/html' in response.headers.get('Content-Type', '').lower():
|
||||
raise ArchiveDownloadError('Archive.org zwróciło HTML zamiast pliku. Sprawdź dostęp i logowanie.')
|
||||
if code == 206:
|
||||
match = RANGE.fullmatch(response.headers.get('Content-Range', '').strip())
|
||||
if not match or int(match.group(1)) != start:
|
||||
raise ArchiveDownloadError('Archive.org zwróciło niezgodny zakres HTTP Range')
|
||||
total = int(match.group(3)) if match.group(3) != '*' else 0
|
||||
else:
|
||||
# Server ignored Range: do not append duplicate bytes.
|
||||
start = 0
|
||||
total = int(response.headers.get('Content-Length') or 0)
|
||||
written = start
|
||||
last_update = 0.0
|
||||
prev = written
|
||||
prev_at = time.monotonic()
|
||||
mode = 'ab' if start else 'wb'
|
||||
with partial.open(mode) as fp:
|
||||
for chunk in response.iter_raw(chunk_size=256 * 1024):
|
||||
_check_state(jid, stop)
|
||||
if not chunk:
|
||||
continue
|
||||
fp.write(chunk)
|
||||
written += len(chunk)
|
||||
now = time.monotonic()
|
||||
if now - last_update >= 0.8:
|
||||
elapsed = max(0.001, now - prev_at)
|
||||
speed = int((written - prev) / elapsed)
|
||||
prev, prev_at, last_update = written, now, now
|
||||
portion = written / total if total else 0
|
||||
if batch is not None:
|
||||
batch.report(ix, written, total)
|
||||
else:
|
||||
store.update(jid, progress=round((ix + min(portion, 1)) / len(urls) * 100, 2),
|
||||
speed=speed, total=total, completed=written,
|
||||
note=f'Archive.org: plik {ix+1}/{len(urls)}, próba {attempt+1}')
|
||||
if total and written != total:
|
||||
raise httpx.ReadError(f'Przerwany transfer: pobrano {written} z {total} bajtów')
|
||||
_check_state(jid, stop)
|
||||
partial.replace(destination)
|
||||
_finish(job, ix, urls, batch=batch)
|
||||
_log(jid, f'Ukończono {filename}: {written} bajtów')
|
||||
return
|
||||
except ArchiveStopped:
|
||||
raise
|
||||
except ArchiveDownloadError:
|
||||
raise
|
||||
except (httpx.HTTPError, OSError) as exc:
|
||||
_log(jid, f'Błąd transferu {type(exc).__name__}: {str(exc)[:180]}')
|
||||
if attempt == MAX_ATTEMPTS - 1:
|
||||
raise ArchiveDownloadError(f'Archive.org: przerwane połączenie po {MAX_ATTEMPTS} próbach: {type(exc).__name__}: {exc}') from exc
|
||||
# Applies to transient HTTP codes and connection exceptions.
|
||||
if batch is None:
|
||||
store.update(jid, note=f'Archive.org: ponawianie po HTTP/błędzie sieci ({attempt+2}/{MAX_ATTEMPTS})', speed=0)
|
||||
for _ in range(min(2 ** attempt, 12) * 2):
|
||||
_check_state(jid, stop)
|
||||
time.sleep(0.5)
|
||||
|
||||
|
||||
def _finish(job, ix, urls, *, batch=None):
|
||||
jid = job['id']
|
||||
if batch is not None:
|
||||
batch.complete(ix)
|
||||
return
|
||||
if ix + 1 < len(urls):
|
||||
store.update(jid, part_index=ix+1, state='queued', gid='', speed=0,
|
||||
progress=round((ix+1)/len(urls) * 100, 2), note='Pobrano część archiwum')
|
||||
else:
|
||||
store.update(jid, state='preparing', progress=100, speed=0,
|
||||
note='Pobieranie Archive.org ukończone')
|
||||
|
||||
|
||||
|
||||
class _RangeUnsupported(Exception):
|
||||
"""A server stopped honoring byte ranges; caller can use the serial path."""
|
||||
def __init__(self, message, reset_prefix=False):
|
||||
super().__init__(message)
|
||||
self.reset_prefix = reset_prefix
|
||||
|
||||
|
||||
def _connection_limit():
|
||||
try:
|
||||
return max(1, min(MAX_CONNECTIONS, int(os.getenv('ARCHIVE_CONNECTIONS', '50'))))
|
||||
except (ValueError, OverflowError):
|
||||
return 50
|
||||
|
||||
|
||||
def _segment_dir(partial):
|
||||
return Path(str(partial) + '.segments')
|
||||
|
||||
|
||||
def _read_manifest(folder):
|
||||
try:
|
||||
item = json.loads((folder / 'manifest.json').read_text(encoding='utf-8'))
|
||||
if not isinstance(item, dict):
|
||||
return None
|
||||
return item
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _write_manifest(folder, manifest):
|
||||
folder.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
temp = folder / 'manifest.json.tmp'
|
||||
with temp.open('w', encoding='utf-8') as f:
|
||||
json.dump(manifest, f, sort_keys=True)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
temp.replace(folder / 'manifest.json')
|
||||
|
||||
|
||||
def _discard_parallel_state(partial, reset_prefix=False):
|
||||
"""Discard stale segment files without interpreting an assembled prefix as a resume."""
|
||||
folder = _segment_dir(partial)
|
||||
if not folder.exists():
|
||||
return
|
||||
manifest = _read_manifest(folder)
|
||||
if reset_prefix:
|
||||
partial.unlink(missing_ok=True)
|
||||
elif manifest and manifest.get('assembling') and partial.exists():
|
||||
prefix = manifest.get('prefix')
|
||||
if isinstance(prefix, int) and 0 <= prefix <= partial.stat().st_size:
|
||||
with partial.open('r+b') as f:
|
||||
f.truncate(prefix)
|
||||
else:
|
||||
partial.unlink(missing_ok=True)
|
||||
shutil.rmtree(folder)
|
||||
|
||||
|
||||
def _validate_ranges(ranges, prefix, total):
|
||||
if not isinstance(ranges, list) or len(ranges) < 2 or len(ranges) > MAX_CONNECTIONS:
|
||||
return False
|
||||
cursor = prefix
|
||||
for item in ranges:
|
||||
if (not isinstance(item, list) or len(item) != 2 or
|
||||
not all(isinstance(x, int) and not isinstance(x, bool) for x in item)):
|
||||
return False
|
||||
start, end = item
|
||||
if start != cursor or end < start or end >= total:
|
||||
return False
|
||||
cursor = end + 1
|
||||
return cursor == total
|
||||
|
||||
|
||||
def _prepare_segments(partial, uri, total, validator, connections):
|
||||
folder = _segment_dir(partial)
|
||||
source_hash = hashlib.sha256(uri.encode('utf-8')).hexdigest()
|
||||
manifest = _read_manifest(folder)
|
||||
valid = False
|
||||
if manifest:
|
||||
prefix = manifest.get('prefix')
|
||||
numeric_prefix = isinstance(prefix, int) and not isinstance(prefix, bool)
|
||||
prefix_present = numeric_prefix and (prefix == 0 or
|
||||
(partial.exists() and partial.stat().st_size >= prefix))
|
||||
valid = (manifest.get('version') == 1 and manifest.get('source_hash') == source_hash and
|
||||
manifest.get('total') == total and manifest.get('validator') == validator and
|
||||
isinstance(prefix, int) and not isinstance(prefix, bool) and 0 <= prefix < total and
|
||||
_validate_ranges(manifest.get('ranges'), prefix, total) and prefix_present)
|
||||
if valid and partial.exists() and partial.stat().st_size != prefix:
|
||||
# A crash during assembly is safe to recover from: parts remain intact.
|
||||
if manifest.get('assembling') and partial.stat().st_size > prefix:
|
||||
with partial.open('r+b') as f:
|
||||
f.truncate(prefix)
|
||||
else:
|
||||
valid = False
|
||||
if valid:
|
||||
return folder, manifest
|
||||
|
||||
if folder.exists():
|
||||
# Invalid or mismatched metadata cannot establish which bytes are safe.
|
||||
_discard_parallel_state(partial, reset_prefix=True)
|
||||
prefix = partial.stat().st_size if partial.exists() else 0
|
||||
if prefix > total:
|
||||
partial.unlink(missing_ok=True)
|
||||
prefix = 0
|
||||
if prefix >= total:
|
||||
return None, {'prefix': prefix, 'total': total}
|
||||
remaining = total - prefix
|
||||
n = min(connections, max(1, remaining // MIN_SEGMENT_BYTES))
|
||||
if n < 2:
|
||||
return None, None
|
||||
quotient, extra = divmod(remaining, n)
|
||||
cursor = prefix
|
||||
ranges = []
|
||||
for i in range(n):
|
||||
end = cursor + quotient + (1 if i < extra else 0) - 1
|
||||
ranges.append([cursor, end])
|
||||
cursor = end + 1
|
||||
manifest = {'version': 1, 'source_hash': source_hash, 'total': total,
|
||||
'validator': validator, 'prefix': prefix, 'ranges': ranges,
|
||||
'assembling': False}
|
||||
_write_manifest(folder, manifest)
|
||||
return folder, manifest
|
||||
|
||||
|
||||
def _retry_wait(job_id, attempt, stop, retry_after=None, external_stop=None):
|
||||
delay = min(2 ** attempt, 16)
|
||||
if retry_after and retry_after.isdigit():
|
||||
delay = min(max(1, int(retry_after)), 60)
|
||||
for _ in range(delay * 2):
|
||||
if stop.is_set():
|
||||
raise ArchiveStopped()
|
||||
_check_state(job_id, external_stop)
|
||||
time.sleep(0.5)
|
||||
|
||||
|
||||
def _check_content(response):
|
||||
if 'text/html' in response.headers.get('Content-Type', '').lower():
|
||||
raise ArchiveDownloadError('Archive.org zwróciło HTML zamiast pliku. Sprawdź dostęp i logowanie.')
|
||||
if response.headers.get('Content-Encoding', 'identity').lower() not in ('', 'identity'):
|
||||
raise ArchiveDownloadError('Archive.org zignorowało Accept-Encoding: identity; transfer w tym kodowaniu jest niebezpieczny')
|
||||
|
||||
|
||||
def _probe_range(job_id, uri, stop=None):
|
||||
"""Only a real 206 with a numeric total enables parallel mode."""
|
||||
headers = {'User-Agent': 'X360-Web-Queue/2.1', 'Accept-Encoding': 'identity', 'Range': 'bytes=0-0'}
|
||||
with _client() as client:
|
||||
for attempt in range(MAX_ATTEMPTS):
|
||||
_check_state(job_id, stop)
|
||||
try:
|
||||
with closing(_response(client, uri, headers)) as response:
|
||||
code = response.status_code
|
||||
if code in RETRY_CODES:
|
||||
_log(job_id, f'Archive.org: sprawdzenie Range HTTP {code}, próba {attempt+1}/{MAX_ATTEMPTS}')
|
||||
if attempt == MAX_ATTEMPTS - 1:
|
||||
raise ArchiveDownloadError(f'Archive.org HTTP {code} podczas sprawdzania Range po {MAX_ATTEMPTS} próbach')
|
||||
delay = response.headers.get('Retry-After')
|
||||
elif code in (401, 403):
|
||||
raise ArchiveDownloadError(f'Archive.org HTTP {code}: brak dostępu. Sprawdź sesję i uprawnienia.')
|
||||
elif code == 404:
|
||||
raise ArchiveDownloadError('Archive.org HTTP 404: nie znaleziono pliku')
|
||||
elif code == 200:
|
||||
_check_content(response)
|
||||
return None
|
||||
elif code == 206:
|
||||
_check_content(response)
|
||||
match = RANGE.fullmatch(response.headers.get('Content-Range', '').strip())
|
||||
if not match or (int(match.group(1)), int(match.group(2))) != (0, 0) or match.group(3) == '*':
|
||||
return None
|
||||
total = int(match.group(3))
|
||||
if total <= 0:
|
||||
return None
|
||||
etag = response.headers.get('ETag', '').strip()
|
||||
if etag.startswith('W/'):
|
||||
etag = ''
|
||||
validator = etag or response.headers.get('Last-Modified', '').strip()
|
||||
if not validator or len(validator) > 256 or '\n' in validator:
|
||||
validator = ''
|
||||
return total, validator
|
||||
else:
|
||||
raise ArchiveDownloadError(f'Archive.org HTTP {code}: odmowa pobrania pliku')
|
||||
except ArchiveDownloadError:
|
||||
raise
|
||||
except (httpx.HTTPError, OSError) as exc:
|
||||
_log(job_id, f'Archive.org: sprawdzenie Range - {type(exc).__name__}: {str(exc)[:150]}')
|
||||
if attempt == MAX_ATTEMPTS - 1:
|
||||
raise ArchiveDownloadError(f'Archive.org: brak odpowiedzi na żądanie Range po {MAX_ATTEMPTS} próbach') from exc
|
||||
delay = None
|
||||
_retry_wait(job_id, attempt, Event(), delay, stop)
|
||||
return None
|
||||
|
||||
|
||||
def _segment_worker(job_id, uri, folder, manifest, index, stop, external_stop=None):
|
||||
begin, end = manifest['ranges'][index]
|
||||
total = manifest['total']
|
||||
limit = end - begin + 1
|
||||
target = folder / f'{index:02d}.part'
|
||||
if target.exists() and target.stat().st_size > limit:
|
||||
target.unlink()
|
||||
for attempt in range(MAX_ATTEMPTS):
|
||||
if stop.is_set():
|
||||
raise ArchiveStopped()
|
||||
_check_state(job_id, external_stop)
|
||||
present = target.stat().st_size if target.exists() else 0
|
||||
if present == limit:
|
||||
return
|
||||
headers = {'User-Agent': 'X360-Web-Queue/2.1', 'Accept-Encoding': 'identity',
|
||||
'Range': f'bytes={begin + present}-{end}'}
|
||||
if manifest['validator']:
|
||||
headers['If-Range'] = manifest['validator']
|
||||
try:
|
||||
with _client() as client:
|
||||
with closing(_response(client, uri, headers)) as response:
|
||||
code = response.status_code
|
||||
if code in RETRY_CODES:
|
||||
_log(job_id, f'Segment {index+1}/{len(manifest["ranges"])}: HTTP {code}, próba {attempt+1}/{MAX_ATTEMPTS}')
|
||||
if attempt == MAX_ATTEMPTS - 1:
|
||||
raise ArchiveDownloadError(f'Segment {index+1}/{len(manifest["ranges"])}: HTTP {code} po {MAX_ATTEMPTS} próbach')
|
||||
delay = response.headers.get('Retry-After')
|
||||
elif code in (401, 403):
|
||||
raise ArchiveDownloadError(f'Segment {index+1}: Archive.org HTTP {code}: brak dostępu')
|
||||
elif code == 200:
|
||||
raise _RangeUnsupported('Archive.org zignorowało zakres HTTP Range / If-Range', reset_prefix=True)
|
||||
elif code != 206:
|
||||
raise ArchiveDownloadError(f'Segment {index+1}: Archive.org HTTP {code}')
|
||||
else:
|
||||
_check_content(response)
|
||||
match = RANGE.fullmatch(response.headers.get('Content-Range', '').strip())
|
||||
if not match or match.group(3) == '*' or (int(match.group(1)), int(match.group(2)), int(match.group(3))) != (begin + present, end, total):
|
||||
raise _RangeUnsupported('Archive.org zwróciło niezgodny Content-Range', reset_prefix=True)
|
||||
if manifest['validator']:
|
||||
header = 'ETag' if manifest['validator'].startswith('"') else 'Last-Modified'
|
||||
received_validator = response.headers.get(header, '').strip()
|
||||
if received_validator and received_validator != manifest['validator']:
|
||||
raise _RangeUnsupported('Archive.org zmieniło plik podczas pobierania', reset_prefix=True)
|
||||
last_check = time.monotonic()
|
||||
with target.open('ab') as f:
|
||||
for chunk in response.iter_raw(chunk_size=CHUNK_BYTES):
|
||||
if stop.is_set():
|
||||
raise ArchiveStopped()
|
||||
if time.monotonic() - last_check >= 1:
|
||||
_check_state(job_id, external_stop)
|
||||
last_check = time.monotonic()
|
||||
if not chunk:
|
||||
continue
|
||||
if f.tell() + len(chunk) > limit:
|
||||
raise _RangeUnsupported('Archive.org wysłało zbyt dużo danych w zakresie', reset_prefix=True)
|
||||
f.write(chunk)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
if target.stat().st_size != limit:
|
||||
raise httpx.ReadError('Przerwana transmisja segmentu')
|
||||
return
|
||||
except (ArchiveStopped, ArchiveDownloadError, _RangeUnsupported):
|
||||
raise
|
||||
except (httpx.HTTPError, OSError) as exc:
|
||||
_log(job_id, f'Segment {index+1}: {type(exc).__name__}: {str(exc)[:150]}, próba {attempt+1}/{MAX_ATTEMPTS}')
|
||||
if attempt == MAX_ATTEMPTS - 1:
|
||||
raise ArchiveDownloadError(f'Segment {index+1}/{len(manifest["ranges"])}: przerwany transfer po {MAX_ATTEMPTS} próbach: {type(exc).__name__}') from exc
|
||||
delay = None
|
||||
_retry_wait(job_id, attempt, stop, delay, external_stop)
|
||||
|
||||
|
||||
def _report_parallel(job_id, ix, url_count, partial, folder, manifest, last, batch=None):
|
||||
prefix = manifest['prefix']
|
||||
downloaded = prefix
|
||||
for index, (start, end) in enumerate(manifest['ranges']):
|
||||
path = folder / f'{index:02d}.part'
|
||||
if path.exists():
|
||||
downloaded += min(end - start + 1, path.stat().st_size)
|
||||
now = time.monotonic()
|
||||
prev_size, prev_at = last
|
||||
speed = max(0, int((downloaded - prev_size) / max(0.001, now - prev_at)))
|
||||
total = manifest['total']
|
||||
if batch is None:
|
||||
store.update(job_id, completed=downloaded, total=total, speed=speed,
|
||||
progress=round((ix + min(downloaded / total, 1)) / url_count * 100, 2),
|
||||
note=f'Archive.org: plik {ix+1}/{url_count}, {len(manifest["ranges"])} połączeń')
|
||||
else:
|
||||
batch.report(ix, downloaded, total)
|
||||
return downloaded, now
|
||||
|
||||
|
||||
def _assemble_segments(job_id, partial, destination, folder, manifest, stop=None):
|
||||
prefix = manifest['prefix']
|
||||
total = manifest['total']
|
||||
for index, (start, end) in enumerate(manifest['ranges']):
|
||||
part = folder / f'{index:02d}.part'
|
||||
if not part.is_file() or part.stat().st_size != end - start + 1:
|
||||
raise ArchiveDownloadError('Niekompletne segmenty; nie można złożyć pliku')
|
||||
manifest['assembling'] = True
|
||||
_write_manifest(folder, manifest)
|
||||
with partial.open('r+b' if partial.exists() else 'w+b') as output:
|
||||
output.truncate(prefix)
|
||||
output.seek(prefix)
|
||||
for index in range(len(manifest['ranges'])):
|
||||
with (folder / f'{index:02d}.part').open('rb') as source:
|
||||
while True:
|
||||
_check_state(job_id, stop)
|
||||
block = source.read(1024 * 1024)
|
||||
if not block:
|
||||
break
|
||||
output.write(block)
|
||||
output.flush()
|
||||
os.fsync(output.fileno())
|
||||
if partial.stat().st_size != total:
|
||||
raise ArchiveDownloadError('Błąd scalania pliku: niezgodny rozmiar końcowy')
|
||||
partial.replace(destination)
|
||||
shutil.rmtree(folder)
|
||||
|
||||
|
||||
def _parallel_transfer(job, ix, urls, uri, filename, partial, destination, connections, *, batch=None, stop=None):
|
||||
jid = job['id']
|
||||
batch_stop = stop
|
||||
probe = _probe_range(jid, uri, stop)
|
||||
if probe is None:
|
||||
_log(jid, 'Archive.org: serwer nie obsługuje Range; pobieranie pojedynczym połączeniem')
|
||||
return False
|
||||
total, validator = probe
|
||||
folder, manifest = _prepare_segments(partial, uri, total, validator, connections)
|
||||
if folder is None and manifest is None:
|
||||
_log(jid, 'Archive.org: mały plik lub końcówka; pobieranie pojedynczym połączeniem')
|
||||
return False
|
||||
if folder is None and manifest['prefix'] == total:
|
||||
partial.replace(destination)
|
||||
_finish(job, ix, urls, batch=batch)
|
||||
return True
|
||||
_log(jid, f'Archive.org: {len(manifest["ranges"])} połączeń HTTP Range, rozmiar {total} B, wznowienie od {manifest["prefix"]} B')
|
||||
local_stop = Event()
|
||||
current = (manifest['prefix'] + sum(min((folder / f'{idx:02d}.part').stat().st_size, end - start + 1)
|
||||
for idx, (start, end) in enumerate(manifest['ranges'])
|
||||
if (folder / f'{idx:02d}.part').exists()), time.monotonic())
|
||||
try:
|
||||
with ThreadPoolExecutor(max_workers=min(connections, len(manifest['ranges']))) as pool:
|
||||
pending = {pool.submit(_segment_worker, jid, uri, folder, manifest, i, local_stop, batch_stop)
|
||||
for i in range(len(manifest['ranges']))}
|
||||
try:
|
||||
while pending:
|
||||
_check_state(jid, batch_stop)
|
||||
done, pending = wait(pending, timeout=0.8, return_when=FIRST_COMPLETED)
|
||||
current = _report_parallel(jid, ix, len(urls), partial, folder, manifest, current, batch)
|
||||
for task in done:
|
||||
task.result()
|
||||
finally:
|
||||
local_stop.set()
|
||||
for task in pending:
|
||||
task.cancel()
|
||||
except _RangeUnsupported as exc:
|
||||
_log(jid, 'Archive.org: niezgodna obsługa Range, przełączam na jedno połączenie')
|
||||
_discard_parallel_state(partial, reset_prefix=exc.reset_prefix)
|
||||
return False
|
||||
_check_state(jid, batch_stop)
|
||||
_report_parallel(jid, ix, len(urls), partial, folder, manifest, current, batch)
|
||||
_log(jid, 'Archive.org: scalanie segmentów')
|
||||
if batch is None:
|
||||
store.update(jid, note='Archive.org: scalanie segmentów', speed=0)
|
||||
if batch_stop is None:
|
||||
_assemble_segments(jid, partial, destination, folder, manifest)
|
||||
else:
|
||||
_assemble_segments(jid, partial, destination, folder, manifest, batch_stop)
|
||||
_finish(job, ix, urls, batch=batch)
|
||||
_log(jid, f'Ukończono {filename}: {total} bajtów, {len(manifest["ranges"])} połączeń')
|
||||
return True
|
||||
|
||||
|
||||
class _BatchProgress:
|
||||
"""Collect progress from file threads; only the scheduler updates SQLite.
|
||||
|
||||
A single stream/segment never overwrites the total progress or total speed
|
||||
from another file. No extra database table is needed for old installations.
|
||||
"""
|
||||
|
||||
def __init__(self, job_id, filenames, limit):
|
||||
self.job_id = job_id
|
||||
self.filenames = filenames
|
||||
self.limit = limit
|
||||
self.lock = Lock()
|
||||
self.sizes = [0] * len(filenames)
|
||||
self.totals = [0] * len(filenames)
|
||||
self.seen = [False] * len(filenames)
|
||||
self.done = set()
|
||||
self.active = set()
|
||||
self.transferred = 0
|
||||
self.last_publish = time.monotonic()
|
||||
|
||||
def start(self, index):
|
||||
with self.lock:
|
||||
self.active.add(index)
|
||||
|
||||
def report(self, index, downloaded, total):
|
||||
with self.lock:
|
||||
downloaded = max(0, int(downloaded))
|
||||
if self.seen[index]:
|
||||
self.transferred += max(0, downloaded - self.sizes[index])
|
||||
else:
|
||||
# Existing .part and .segments from an earlier run are not
|
||||
# counted as freshly transferred bytes per second.
|
||||
self.seen[index] = True
|
||||
self.sizes[index] = downloaded
|
||||
self.totals[index] = max(0, int(total))
|
||||
|
||||
def complete(self, index, size=None):
|
||||
if size is None:
|
||||
size = (store.DATA / 'raw' / self.job_id / self.filenames[index]).stat().st_size
|
||||
with self.lock:
|
||||
if self.seen[index]:
|
||||
self.transferred += max(0, size - self.sizes[index])
|
||||
self.seen[index] = True
|
||||
self.sizes[index] = size
|
||||
self.totals[index] = size
|
||||
self.done.add(index)
|
||||
self.active.discard(index)
|
||||
|
||||
def publish(self):
|
||||
now = time.monotonic()
|
||||
with self.lock:
|
||||
elapsed = max(0.001, now - self.last_publish)
|
||||
speed = max(0, int(self.transferred / elapsed))
|
||||
self.transferred = 0
|
||||
self.last_publish = now
|
||||
total_files = len(self.filenames)
|
||||
fractions = []
|
||||
for index in range(total_files):
|
||||
if index in self.done:
|
||||
fractions.append(1.0)
|
||||
elif self.totals[index]:
|
||||
fractions.append(min(self.sizes[index] / self.totals[index], 1))
|
||||
else:
|
||||
fractions.append(0.0)
|
||||
completed_bytes = sum(self.sizes)
|
||||
total_bytes = sum(self.totals)
|
||||
if total_bytes and all(self.totals):
|
||||
# A 20 KiB NFO must not count the same as an 8 GiB ISO.
|
||||
progress = round(100 * min(completed_bytes, total_bytes) / total_bytes, 2)
|
||||
else:
|
||||
# Until all content lengths are known, show the fraction of
|
||||
# completed files/known segments as a provisional estimate.
|
||||
progress = round(sum(fractions) / total_files * 100, 2)
|
||||
finished = len(self.done)
|
||||
active = len(self.active)
|
||||
contiguous = next((i for i in range(total_files) if i not in self.done), total_files)
|
||||
store.update(self.job_id, part_index=contiguous, progress=progress,
|
||||
completed=completed_bytes, total=total_bytes, speed=speed,
|
||||
note=f'Archive.org: {finished}/{total_files} gotowe, {active} równolegle, limit {self.limit} połączeń')
|
||||
|
||||
|
||||
def _transfer_many(job):
|
||||
"""Download multiple Archive.org files at once with ONE connection budget.
|
||||
|
||||
Each slot owns a fixed connection allowance. Reusing the slot for the next
|
||||
file preserves the global cap even with 50-segment old resume manifests.
|
||||
"""
|
||||
jid = job['id']
|
||||
urls = source_urls(job)
|
||||
if not urls:
|
||||
raise ArchiveDownloadError('Brak plików Archive.org do pobrania')
|
||||
names = []
|
||||
for uri in urls:
|
||||
if not archive_auth.is_archive_download(uri):
|
||||
raise ArchiveDownloadError('Nieprawidłowy URL pobierania Archive.org')
|
||||
name = target_filename(uri)
|
||||
if not name:
|
||||
raise ArchiveDownloadError('Brak nazwy pliku Archive.org')
|
||||
names.append(name)
|
||||
if len({name.casefold() for name in names}) != len(names):
|
||||
raise ArchiveDownloadError('Wybrane pliki mają powtarzające się nazwy. Wybierz pliki o różnych nazwach.')
|
||||
|
||||
root = store.DATA / 'raw' / jid
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
_check_state(jid)
|
||||
limit = _connection_limit()
|
||||
tracker = _BatchProgress(jid, names, limit)
|
||||
waiting = deque()
|
||||
for ix, name in enumerate(names):
|
||||
dest = root / name
|
||||
if dest.is_file() and not Path(str(dest) + '.aria2').exists():
|
||||
# A completed destination was atomically renamed by the old or
|
||||
# new downloader. A legacy aria2 control file means its sibling
|
||||
# destination may only contain an incomplete preallocated file.
|
||||
tracker.complete(ix, dest.stat().st_size)
|
||||
_log(jid, f'Plik {ix+1}/{len(urls)} już gotowy: {name}')
|
||||
else:
|
||||
waiting.append(ix)
|
||||
|
||||
store.update(jid, state='downloading', gid='', note=f'Archive.org: start równoległego pobierania {len(waiting)} plików')
|
||||
tracker.publish()
|
||||
if not waiting:
|
||||
_check_state(jid)
|
||||
store.update(jid, state='preparing', progress=100, speed=0,
|
||||
note='Pobieranie Archive.org ukończone')
|
||||
return
|
||||
|
||||
slots = min(limit, len(waiting))
|
||||
budgets = [limit // slots + (1 if i < limit % slots else 0) for i in range(slots)]
|
||||
_log(jid, f'Archive.org: start {slots} plików równolegle; budżet {limit} połączeń HTTP '
|
||||
f'(przydział na plik: {", ".join(map(str, budgets))})')
|
||||
stopped = Event()
|
||||
first_error = None
|
||||
|
||||
def launch(pool, slot):
|
||||
ix = waiting.popleft()
|
||||
item = dict(job, part_index=ix)
|
||||
tracker.start(ix)
|
||||
return pool.submit(_transfer_one, item, batch=tracker, connections=budgets[slot], stop=stopped)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=slots, thread_name_prefix='archive-file') as pool:
|
||||
running = {launch(pool, slot): slot for slot in range(slots)}
|
||||
try:
|
||||
while running:
|
||||
_check_state(jid, stopped)
|
||||
finished, _ = wait(running, timeout=0.8, return_when=FIRST_COMPLETED)
|
||||
tracker.publish()
|
||||
for future in finished:
|
||||
slot = running.pop(future)
|
||||
try:
|
||||
future.result()
|
||||
except Exception as exc:
|
||||
first_error = first_error or exc
|
||||
stopped.set()
|
||||
else:
|
||||
if waiting and not stopped.is_set():
|
||||
running[launch(pool, slot)] = slot
|
||||
if first_error is not None:
|
||||
break
|
||||
finally:
|
||||
stopped.set()
|
||||
for future in running:
|
||||
future.cancel()
|
||||
|
||||
if first_error is not None:
|
||||
tracker.publish()
|
||||
raise first_error
|
||||
_check_state(jid) # Especially important if a pause arrives at the last byte.
|
||||
tracker.publish()
|
||||
store.update(jid, state='preparing', progress=100, speed=0,
|
||||
part_index=len(urls), note='Pobieranie Archive.org ukończone')
|
||||
_log(jid, f'Archive.org: pobrano wszystkie {len(urls)} pliki')
|
||||
|
||||
|
||||
async def run_one(job):
|
||||
import asyncio
|
||||
urls = source_urls(job)
|
||||
if job['source_type'] == 'archive_many' and len(urls) > 1:
|
||||
await asyncio.to_thread(_transfer_many, job)
|
||||
else:
|
||||
await asyncio.to_thread(_transfer_one, job)
|
||||
Reference in new issue
Block a user