diff --git a/src/aw_cli/download.py b/src/aw_cli/download.py index 4e44014..4bf626e 100644 --- a/src/aw_cli/download.py +++ b/src/aw_cli/download.py @@ -1,4 +1,5 @@ import asyncio +import re from pathlib import Path from httpx import AsyncClient from rich.progress import Progress, BarColumn, TextColumn, TaskID, DownloadColumn, TransferSpeedColumn @@ -8,6 +9,55 @@ from functools import lru_cache +# Numero di connessioni parallele usate di default per scaricare un singolo +# episodio. Molti CDN limitano la banda per-connessione: scaricare lo stesso +# file a segmenti con più connessioni aggira quel limite. Sovrascrivibile +# tramite l'opzione "connections-per-download" nel file di configurazione. +DEFAULT_CONNECTIONS = 4 + +# Dimensione dei blocchi letti dallo stream. 1 MB riduce drasticamente +# l'overhead rispetto a letture da 1 KB su file di centinaia di MB. +CHUNK_SIZE = 1024 * 1024 + +def _segment_ranges(total: int, connections: int) -> list[tuple[int, int]]: + """ + Divide un download in intervalli byte inclusivi, evitando segmenti vuoti. + """ + if total <= 0 or connections <= 0: + return [] + + connections = min(connections, total) + base_size, remainder = divmod(total, connections) + ranges = [] + start = 0 + for i in range(connections): + size = base_size + (1 if i < remainder else 0) + end = start + size - 1 + ranges.append((start, end)) + start = end + 1 + return ranges + +def _valid_partial_response(response, start: int, end: int, total: int) -> bool: + """ + Verifica che il server abbia rispettato il Range richiesto. + """ + if response.status_code != 206: + return False + + content_range = response.headers.get("content-range", "") + match = re.fullmatch(r"bytes (\d+)-(\d+)/(\d+|\*)", content_range) + if not match: + return False + + actual_start = int(match.group(1)) + actual_end = int(match.group(2)) + actual_total = match.group(3) + return ( + actual_start == start + and actual_end == end + and (actual_total == "*" or int(actual_total) == total) + ) + @lru_cache def path(create: bool = True) -> Path: """ @@ -53,20 +103,61 @@ async def download_worker(ep: Anime.Episode, task_id: TaskID, progress: Progress progress.console.print(f"[red]Errore link Ep. {ep.num}: {e}") return + headers = provider.Client.headers + temp_filename = filename.with_name(f"{filename.name}.temp") + connections = max(1, ut.config_data["general"].get("connections-per-download", DEFAULT_CONNECTIONS)) + + async def single_stream(client: AsyncClient) -> None: + """Scarica l'episodio con un'unica connessione (fallback).""" + async with client.stream("GET", url, headers=headers) as response: + response.raise_for_status() + total = int(response.headers.get('content-length', 0)) + progress.update(task_id, total=total or None) + with open(temp_filename, "wb") as f: + async for chunk in response.aiter_bytes(CHUNK_SIZE): + if chunk: + progress.update(task_id, advance=f.write(chunk)) + + async def download_segment(client: AsyncClient, start: int, end: int) -> None: + """Scarica l'intervallo di byte [start, end] e lo scrive alla sua posizione nel file.""" + seg_headers = {**headers, "Range": f"bytes={start}-{end}"} + async with client.stream("GET", url, headers=seg_headers) as response: + if not _valid_partial_response(response, start, end, total): + raise ValueError( + f"Risposta Range non valida per bytes={start}-{end}" + ) + with open(temp_filename, "r+b") as f: + f.seek(start) + async for chunk in response.aiter_bytes(CHUNK_SIZE): + if chunk: + progress.update(task_id, advance=f.write(chunk)) + try: - async with AsyncClient() as client: - async with client.stream("GET", url, headers=provider.Client.headers) as response: - total = int(response.headers.get('content-length', 0)) + async with AsyncClient(timeout=30.0, follow_redirects=True) as client: + total = 0 + accept_ranges = False + try: + head = await client.head(url, headers=headers) + head.raise_for_status() + total = int(head.headers.get('content-length', 0)) + accept_ranges = head.headers.get('accept-ranges', '').lower() == 'bytes' + except Exception: + pass + + if connections > 1 and accept_ranges and total > 0: + # Download segmentato: più connessioni in parallelo sullo stesso + # file, per aggirare i limiti di banda per-connessione dei CDN. progress.update(task_id, total=total) - - downloaded = 0 - temp_filename = filename.with_name(f"{filename.name}.temp") with open(temp_filename, "wb") as f: - async for chunk in response.aiter_bytes(1024): - if chunk: - n = f.write(chunk) - downloaded += n - progress.update(task_id, advance=n) + f.truncate(total) + segments = [ + download_segment(client, start, end) + for start, end in _segment_ranges(total, connections) + ] + await asyncio.gather(*segments) + else: + # Server senza supporto ai range (o dimensione ignota): fallback. + await single_stream(client) temp_filename.rename(filename) progress.update(task_id, description=f"[success]Ep. {ep.num} (Completato)[/]") diff --git a/tests/core/test_download.py b/tests/core/test_download.py new file mode 100644 index 0000000..c1e8aeb --- /dev/null +++ b/tests/core/test_download.py @@ -0,0 +1,53 @@ +from types import SimpleNamespace + +from aw_cli import download + + +def response(status_code: int, content_range: str = ""): + return SimpleNamespace( + status_code=status_code, + headers={"content-range": content_range} if content_range else {}, + ) + + +def test_segment_ranges_cover_file_without_gaps(): + assert download._segment_ranges(total=10, connections=3) == [ + (0, 3), + (4, 6), + (7, 9), + ] + + +def test_segment_ranges_do_not_create_empty_ranges(): + assert download._segment_ranges(total=3, connections=10) == [ + (0, 0), + (1, 1), + (2, 2), + ] + + +def test_valid_partial_response_accepts_matching_content_range(): + assert download._valid_partial_response( + response(206, "bytes 4-6/10"), + start=4, + end=6, + total=10, + ) + + +def test_valid_partial_response_rejects_ignored_range(): + assert not download._valid_partial_response( + response(200, "bytes 0-9/10"), + start=4, + end=6, + total=10, + ) + + +def test_valid_partial_response_rejects_wrong_content_range(): + assert not download._valid_partial_response( + response(206, "bytes 0-6/10"), + start=4, + end=6, + total=10, + ) diff --git a/tests/providers/test_animeworld.py b/tests/providers/test_animeworld.py index 0d1d7b8..b663bed 100644 --- a/tests/providers/test_animeworld.py +++ b/tests/providers/test_animeworld.py @@ -1,3 +1,4 @@ +import re import pytest from pathlib import Path from unittest.mock import patch, MagicMock @@ -74,10 +75,13 @@ def test_animeworld_episode_link(self, aw): aw.Client.get.return_value = mock_response video_url = aw._episode_link(anime, episode) - assert ( - video_url - == "https://srv23-abbaia.sweetpixel.org/DDL/ANIME/Naruto/Naruto_Ep_001_SUB_ITA.mp4" - ) + # Il sottodominio del CDN (es. "srv23-masafi") ruota nel tempo e le + # fixtures si rigenerano dal vivo: verifichiamo la struttura stabile + # dell'URL (dominio + path) senza fissare l'host volatile. + assert re.fullmatch( + r"https://[\w.-]+\.sweetpixel\.org/DDL/ANIME/Naruto/Naruto_Ep_001_SUB_ITA\.mp4", + video_url, + ), f"URL video inatteso: {video_url}" def test_animeworld_info_anime(self, aw):