Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 102 additions & 11 deletions src/aw_cli/download.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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:
"""
Expand Down Expand Up @@ -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)[/]")
Expand Down
53 changes: 53 additions & 0 deletions tests/core/test_download.py
Original file line number Diff line number Diff line change
@@ -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,
)
12 changes: 8 additions & 4 deletions tests/providers/test_animeworld.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import re
import pytest
from pathlib import Path
from unittest.mock import patch, MagicMock
Expand Down Expand Up @@ -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):
Expand Down