From 3d029a332629672906014823580fc46bcf4f990f Mon Sep 17 00:00:00 2001 From: Andrea <130001824+andrealiberatoreilardi@users.noreply.github.com> Date: Sat, 8 Aug 2026 15:30:26 +0200 Subject: [PATCH 1/3] feat(download): scarica gli episodi con connessioni multiple (HTTP Range) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Molti CDN limitano la banda per-connessione, quindi un download a connessione singola resta lento anche su linee veloci. Ora ogni episodio viene scaricato a segmenti con più connessioni parallele tramite richieste HTTP Range, aggirando il limite per-connessione. - nuova opzione di configurazione "connections-per-download" (default 4), letta come la già esistente "parallel-downloads" - fallback automatico alla connessione singola se il server non supporta i range o non espone la dimensione del file - blocchi di lettura portati da 1 KB a 1 MB per ridurre l'overhead su file di centinaia di MB --- src/aw_cli/download.py | 69 +++++++++++++++++++++++++++++++++++------- 1 file changed, 58 insertions(+), 11 deletions(-) diff --git a/src/aw_cli/download.py b/src/aw_cli/download.py index 4e44014..b8f2f50 100644 --- a/src/aw_cli/download.py +++ b/src/aw_cli/download.py @@ -8,6 +8,16 @@ 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 + @lru_cache def path(create: bool = True) -> Path: """ @@ -53,20 +63,57 @@ 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: + 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: + 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) + 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) + segment_size = total // connections + segments = [] + for i in range(connections): + start = i * segment_size + end = total - 1 if i == connections - 1 else start + segment_size - 1 + segments.append(download_segment(client, start, end)) + 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)[/]") From 94c838097a8822e62a4c185ca8e44a2ef1e1b6ed Mon Sep 17 00:00:00 2001 From: Andrea <130001824+andrealiberatoreilardi@users.noreply.github.com> Date: Sat, 8 Aug 2026 15:32:35 +0200 Subject: [PATCH 2/3] fix(tests): non fissare il sottodominio volatile del CDN in test_animeworld_episode_link Le fixtures si rigenerano dal vivo e il sottodominio del CDN (es. "srv23-masafi") ruota nel tempo, quindi il confronto con un URL hardcoded rendeva il test instabile (rosso in CI a ogni rotazione). Ora verifichiamo la struttura stabile dell'URL (dominio + path) con una regex, mantenendo il controllo sul corretto parsing del . --- tests/providers/test_animeworld.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) 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): From 59a4696a30598fa09359b1ce153541d1c99a6cd3 Mon Sep 17 00:00:00 2001 From: fexh10 Date: Sat, 12 Sep 2026 09:55:18 +0200 Subject: [PATCH 3/3] fix(download): validate ranged downloads --- src/aw_cli/download.py | 56 +++++++++++++++++++++++++++++++++---- tests/core/test_download.py | 53 +++++++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+), 6 deletions(-) create mode 100644 tests/core/test_download.py diff --git a/src/aw_cli/download.py b/src/aw_cli/download.py index b8f2f50..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 @@ -18,6 +19,45 @@ # 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: """ @@ -70,6 +110,7 @@ async def download_worker(ep: Anime.Episode, task_id: TaskID, progress: Progress 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: @@ -81,6 +122,10 @@ 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): @@ -93,6 +138,7 @@ async def download_segment(client: AsyncClient, start: int, end: int) -> None: 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: @@ -104,12 +150,10 @@ async def download_segment(client: AsyncClient, start: int, end: int) -> None: progress.update(task_id, total=total) with open(temp_filename, "wb") as f: f.truncate(total) - segment_size = total // connections - segments = [] - for i in range(connections): - start = i * segment_size - end = total - 1 if i == connections - 1 else start + segment_size - 1 - segments.append(download_segment(client, start, end)) + 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. 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, + )