Using aria2c to download files. Removing custom file download functions.

This commit is contained in:
Roland 2025-01-10 11:32:37 -07:00
parent 95a524ba33
commit 220cb8010f
No known key found for this signature in database
GPG key ID: 1BA620CE6C9A4680
6 changed files with 185 additions and 59 deletions

View file

@ -1,7 +1,6 @@
import asyncio
import json
import pathlib
from http import HTTPStatus
from itertools import zip_longest
from typing import List, Dict
from uuid import uuid4
@ -28,33 +27,18 @@ def kiwix_download_manager(test_download_manager) -> DownloadManager:
@pytest.fixture
def kiwix_download_zim(test_directory, kiwix_download_manager, test_zim_bytes):
def kiwix_download_zim(test_directory, kiwix_download_manager, test_zim_bytes, mock_downloader_download_file):
async def do_download(download_info: DownloadFileInfo = None, expected_url: str = None,
download_file_side_effect=None, hrefs: List[str] = None):
async def _download_file(url: str, output_path: pathlib.Path = None, info: DownloadFileInfo = None):
if expected_url:
assert url == expected_url
if download_info:
assert info == download_info
output_path.write_bytes(test_zim_bytes)
with mock.patch('modules.zim.downloader.fetch_hrefs') as mock_fetch_hrefs, \
mock.patch('modules.zim.downloader.get_download_info') as mock_get_download_info, \
mock.patch('modules.zim.downloader.download_file') as mock_download_file:
with mock.patch('modules.zim.downloader.fetch_hrefs') as mock_fetch_hrefs:
# Fake the download, but make sure it is called correctly. Also creates a Zim file at the output_path.
mock_download_file.side_effect = download_file_side_effect or _download_file
# These are the links in the parent directory of the url.
mock_fetch_hrefs.return_value = hrefs or ['?C=N;O=D', '?C=M;O=A', '?C=S;O=A', '?C=D;O=A', '/zim/',
'wikipedia_es_all_maxi_2023-05.zim',
'wikipedia_es_all_maxi_2023-06.zim',
'wikipedia_es_all_nopic_2023-05.zim',
'wikipedia_es_all_nopic_2023-06.zim']
# This is the DownloadInfo for the latest Zim file that was selected.
mock_get_download_info.return_value = download_info or DownloadFileInfo(
status=HTTPStatus.OK,
name='wikipedia_es_all_maxi_2023-06.zim',
size=len(test_zim_bytes),
)
mock_downloader_download_file(test_zim_bytes)
await kiwix_download_manager.wait_for_all_downloads()
# Async download are hard to test, sleep to let tasks finish.

View file

@ -2,8 +2,7 @@ from http import HTTPStatus
from typing import List
from modules.zim import lib
from wrolpi.common import logger, aiohttp_get, get_download_info, download_file, background_task, get_html_soup, \
trim_file_name
from wrolpi.common import logger, aiohttp_get, background_task, get_html_soup
from wrolpi.downloader import Downloader, Download, DownloadResult
__all__ = ['KiwixCatalogDownloader', 'KiwixZimDownloader', 'kiwix_zim_downloader', 'kiwix_catalog_downloader']
@ -72,33 +71,14 @@ class KiwixZimDownloader(Downloader):
listable = False
async def do_download(self, download: Download) -> DownloadResult:
url = download.url
download_info = await get_download_info(url)
zim_directory = lib.get_zim_directory()
zim_directory.mkdir(parents=True, exist_ok=True)
name = trim_file_name(download_info.name)
output_path = zim_directory / name
if output_path.is_file():
output_size = output_path.stat().st_size
if download_info.size and download_info.size == output_size:
# File is already downloaded.
return DownloadResult(success=True)
logger.info(f'Downloading Zim {url} to {output_path} of size {download_info.size}')
await download_file(url, output_path=output_path, info=download_info)
output_path = await self.download_file(download.id, download.url, zim_directory)
# Notify the maintainer if outdated Zim files are lying around.
lib.flag_outdated_zim_files()
output_size = output_path.stat().st_size
if download_info.size and output_size != download_info.size:
return DownloadResult(
success=False,
error=f'Download size does not match: {output_size} != {download_info.size}')
return_code = await lib.check_zim(output_path)
if return_code == 127:
logger.warning(f'Not validating {output_path} because zimcheck is not installed')

View file

@ -7,9 +7,11 @@ import os
import pathlib
import tempfile
import traceback
import xml.etree.ElementTree as ET
from dataclasses import dataclass, field
from datetime import timedelta, datetime
from enum import Enum
from http import HTTPStatus
from itertools import filterfalse
from typing import List, Dict, Generator, Iterable, Coroutine
from typing import Tuple, Optional
@ -25,11 +27,13 @@ from sqlalchemy.sql import Delete
from wrolpi import flags
from wrolpi.api_utils import api_app, perpetual_signal
from wrolpi.cmd import which
from wrolpi.common import Base, ModelHelper, logger, wrol_mode_check, zig_zag, ConfigFile, \
wrol_mode_enabled, background_task, get_wrolpi_config, get_absolute_media_path, timer
wrol_mode_enabled, background_task, get_wrolpi_config, get_absolute_media_path, timer, aiohttp_get, \
get_download_info, trim_file_name
from wrolpi.dates import TZDateTime, now, Seconds
from wrolpi.db import get_db_session, get_db_curs, optional_session
from wrolpi.errors import InvalidDownload, UnrecoverableDownloadError, UnknownDownload, ValidationError
from wrolpi.errors import InvalidDownload, UnrecoverableDownloadError, UnknownDownload, ValidationError, DownloadError
from wrolpi.events import Events
from wrolpi.media_path import MediaPathType
from wrolpi.switches import register_switch_handler, ActivateSwitchMethod, await_switches
@ -37,6 +41,8 @@ from wrolpi.vars import PYTEST, SIMULTANEOUS_DOWNLOAD_DOMAINS
logger = logger.getChild(__name__)
ARIA2C_PATH = which('aria2c', '/usr/bin/aria2c')
@perpetual_signal(sleep=1 if PYTEST else 5)
async def perpetual_download_worker():
@ -377,6 +383,70 @@ class Downloader:
# Return the result of the download attempt.
return task.result()
@staticmethod
async def get_meta4_contents(url: str) -> bytes | None:
try:
meta4_url = f'{url}.meta4'
async with aiohttp_get(meta4_url, timeout=5) as response:
if response.status == HTTPStatus.OK:
contents = await response.content.read()
try:
root = ET.fromstring(contents)
if root.tag == '{urn:ietf:params:xml:ns:metalink4}metalink':
return contents
logger.debug(f'meta4 file was not a metalink file: {meta4_url}')
except Exception as e:
logger.debug(f'Failed to parse XML {meta4_url}', exc_info=e)
except Exception as e:
logger.debug(f'Failed to fetch meta4 file {url}', exc_info=e)
return None
async def download_file(self, download_id: int, url: str, destination: pathlib.Path, check_for_meta4: bool = True) \
-> pathlib.Path:
meta4_contents = None
if check_for_meta4:
meta4_contents = await self.get_meta4_contents(url)
info = await get_download_info(url)
# TODO verify that this output_path is exclusive.
output_path = destination / trim_file_name(info.name)
with tempfile.NamedTemporaryFile(suffix='.meta4') as meta4_path:
cmd = (
ARIA2C_PATH,
'-l', '-',
'-j3', # concurrent downloads
'-s3', # split jobs
'-d', destination,
url
)
meta4_path = pathlib.Path(meta4_path.name)
if meta4_contents:
meta4_path.write_bytes(meta4_contents)
cmd = (*cmd,
'-M', meta4_path,
)
return_code, logs, stdout = await self.process_runner(
download_id,
url, # Log the original URL.
cmd,
destination,
)
error = logs.get('stderr') or logs.get('stdout')
error = error.decode() if error else error
if return_code != 0:
raise DownloadError(f'{error}\n\nFileDownloader failed with return code {return_code}')
if not output_path.is_file():
raise DownloadError(f'{error}\n\nOutput file not found: {output_path}')
return output_path
class DownloadManager:
"""

View file

@ -1,10 +1,12 @@
import pathlib
import pathlib
import tempfile
import traceback
from abc import ABC
from wrolpi.cmd import which
from wrolpi.common import get_media_directory, logger, get_download_info, \
download_file, trim_file_name
from wrolpi.db import get_db_session
trim_file_name, TRACE_LEVEL
from wrolpi.downloader import Downloader, Download, DownloadResult
from wrolpi.errors import UnrecoverableDownloadError
from wrolpi.files.lib import upsert_file
@ -14,6 +16,8 @@ __all__ = ['FileDownloader', 'file_downloader']
logger = logger.getChild(__name__)
ARIA2C_PATH = which('aria2c', '/usr/bin/aria2c')
class FileDownloader(Downloader, ABC):
"""A Downloader which can download an arbitrary HTTP file.
@ -31,8 +35,6 @@ class FileDownloader(Downloader, ABC):
if download.attempts > 3:
raise UnrecoverableDownloadError(f'Max download attempts reached for {download.url}')
media_directory = get_media_directory()
settings = download.settings or dict()
# We don't know where to put the file if we don't have a destination.
@ -40,28 +42,28 @@ class FileDownloader(Downloader, ABC):
if not destination:
raise UnrecoverableDownloadError(f'Cannot download the file without a destination')
if logger.isEnabledFor(TRACE_LEVEL):
logger.trace(f'FileDownloader: downloading {download.url} to {destination}')
# Create the destination only if it is within the media directory.
destination = pathlib.Path(destination)
if not str(destination.absolute()).startswith(str(media_directory)):
destination = pathlib.Path(destination).resolve()
media_directory = get_media_directory()
if not str(destination).startswith(str(media_directory)):
raise UnrecoverableDownloadError(f'Cannot download outside media directory!')
destination.mkdir(parents=True, exist_ok=True)
info = await get_download_info(download.url)
# TODO verify that this output_path is exclusive.
output_path = destination / trim_file_name(info.name)
error = None
try:
await download_file(download.url, output_path, info)
output_path = await self.download_file(download.id, download.url, destination)
fg = await upsert_file(output_path, tag_names=download.tag_names)
location = fg.location
return DownloadResult(
success=True,
location=location,
)
except Exception as e:
if error:
logger.error(f'FileDownloader failed with aria2c error:\n{error}')
logger.error(f'Failed to download {repr(str(download.url))}', exc_info=e)
if PYTEST:
raise

View file

@ -31,7 +31,7 @@ from wrolpi.dates import now, from_timestamp, months_selector_to_where, date_ran
from wrolpi.db import get_db_session, get_db_curs, mogrify, optional_session
from wrolpi.downloader import download_manager
from wrolpi.errors import InvalidFile, UnknownDirectory, UnknownFile, UnknownTag, FileConflict, FileGroupIsTagged, \
NoPrimaryFile, InvalidDirectory
NoPrimaryFile, InvalidDirectory, IgnoredDirectoryError
from wrolpi.events import Events
from wrolpi.files.models import FileGroup, Directory
from wrolpi.lang import ISO_639_CODES, ISO_3166_CODES
@ -1537,13 +1537,16 @@ def remove_ignored_directory(directory: Union[pathlib.Path, str]):
async def upsert_file(file: pathlib.Path | str, tag_names: List[str] = None) -> FileGroup:
"""Insert/update all files in the provided file's FileGroup."""
if not file or not file.is_file():
raise InvalidFile(f'Cannot upsert file that does not exist: {file}')
# Update/Insert all files in the FileGroup.
paths = glob_shared_stem(pathlib.Path(file))
# Remove any ignored files.
paths = remove_files_in_ignored_directories(paths)
if not paths:
logger.warning('upsert_file called, but all files are in ignored directories!')
return
raise IgnoredDirectoryError(f'all files are in ignored directories: {file}')
for i in range(2):
# Try multiple times because uploads happen concurrently and may conflict.

View file

@ -0,0 +1,87 @@
import hashlib
from http import HTTPStatus
from wrolpi import common
from wrolpi.downloader import Download
from wrolpi.files.downloader import FileDownloader
async def test_file_downloader(test_session, make_files_structure, test_directory, await_switches,
simple_file_server, test_download_manager, video_file, video_bytes):
"""The FileDownloader can download a file without a metalink file."""
file_downloader = FileDownloader()
test_download_manager.register_downloader(file_downloader)
host, port = simple_file_server.server_address
url = f'http://{host}:{port}' # noqa
# Create download for the video file.
video_url = f'{url}/{video_file.name}'
test_download_manager.create_download(video_url, file_downloader.name, test_session,
destination=str(test_directory / 'downloads'))
test_session.commit()
await test_download_manager.wait_for_all_downloads()
download: Download = test_session.query(Download).one()
assert not download.error, download.error
# The video was downloaded correctly.
assert (test_directory / f'downloads/{video_file.name}').is_file()
assert (test_directory / f'downloads/{video_file.name}').stat().st_size == 1056318
assert (test_directory / f'downloads/{video_file.name}').read_bytes() == video_bytes
# Minimum Metalink file to download a file.
META4_XML = '''<?xml version="1.0" encoding="UTF-8"?>
<metalink xmlns="urn:ietf:params:xml:ns:metalink">
<file name="{name}">
<size>{size}</size>
<hash type="sha-256">{sha256}</hash>
<url>{url}/{name}</url>
</file>
</metalink>
'''
async def test_file_downloader_meta4(test_session, make_files_structure, test_directory, await_switches,
simple_file_server, test_download_manager, video_file, video_bytes):
"""The FileDownloader can download a file using a Metalink file."""
file_downloader = FileDownloader()
test_download_manager.register_downloader(file_downloader)
host, port = simple_file_server.server_address
url = f'http://{host}:{port}' # noqa
meta4 = META4_XML.format(
url=url,
name=video_file.name,
size=video_file.stat().st_size,
sha256=hashlib.sha256(video_bytes).hexdigest(),
)
(test_directory / f'{video_file.name}.meta4').write_text(meta4)
# Ensure files are being served correctly so they can be downloaded.
video_url = f'{url}/{video_file.name}'
video_meta4_url = f'{url}/{video_file.name}.meta4'
async with common.aiohttp_head(video_url, timeout=5) as response:
assert response.status == HTTPStatus.OK, response.content
async with common.aiohttp_head(video_meta4_url, timeout=5) as response:
assert response.status == HTTPStatus.OK, response.content
# Create download for the video file, this should automatically check for the .meta4 file and use it with aria2c.
test_download_manager.create_download(video_url, file_downloader.name, test_session,
destination=str(test_directory / 'downloads'))
test_session.commit()
await test_download_manager.wait_for_all_downloads()
download: Download = test_session.query(Download).one()
assert not download.error, download.error
# The video was downloaded correctly.
assert (test_directory / f'downloads/{video_file.name}').is_file()
assert (test_directory / f'downloads/{video_file.name}').stat().st_size == 1056318
assert (test_directory / f'downloads/{video_file.name}').read_bytes() == video_bytes
# The meta4 was saved to a temporary file, but not saved.
assert not (test_directory / 'downloads/foo.txt.meta4').exists()