aw-cli 2.3.2__tar.gz → 2.3.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {aw_cli-2.3.2 → aw_cli-2.3.3}/.github/ISSUE_TEMPLATE/bug_report.md +8 -0
- {aw_cli-2.3.2 → aw_cli-2.3.3}/PKG-INFO +3 -2
- {aw_cli-2.3.2 → aw_cli-2.3.3}/pyproject.toml +1 -0
- aw_cli-2.3.3/src/aw_cli/download.py +188 -0
- aw_cli-2.3.3/tests/core/test_download.py +53 -0
- {aw_cli-2.3.2 → aw_cli-2.3.3}/tests/providers/test_animeworld.py +8 -4
- {aw_cli-2.3.2 → aw_cli-2.3.3}/uv.lock +6 -4
- aw_cli-2.3.2/src/aw_cli/download.py +0 -97
- {aw_cli-2.3.2 → aw_cli-2.3.3}/.github/workflows/python-publish.yml +0 -0
- {aw_cli-2.3.2 → aw_cli-2.3.3}/.github/workflows/release.yml +0 -0
- {aw_cli-2.3.2 → aw_cli-2.3.3}/.gitignore +0 -0
- {aw_cli-2.3.2 → aw_cli-2.3.3}/.pre-commit-config.yaml +0 -0
- {aw_cli-2.3.2 → aw_cli-2.3.3}/LICENSE +0 -0
- {aw_cli-2.3.2 → aw_cli-2.3.3}/README.md +0 -0
- {aw_cli-2.3.2 → aw_cli-2.3.3}/disclaimer.md +0 -0
- {aw_cli-2.3.2 → aw_cli-2.3.3}/src/aw_cli/__init__.py +0 -0
- {aw_cli-2.3.2 → aw_cli-2.3.3}/src/aw_cli/anilist.py +0 -0
- {aw_cli-2.3.2 → aw_cli-2.3.3}/src/aw_cli/anime.py +0 -0
- {aw_cli-2.3.2 → aw_cli-2.3.3}/src/aw_cli/arg_parser.py +0 -0
- {aw_cli-2.3.2 → aw_cli-2.3.3}/src/aw_cli/history.py +0 -0
- {aw_cli-2.3.2 → aw_cli-2.3.3}/src/aw_cli/interface/__init__.py +0 -0
- {aw_cli-2.3.2 → aw_cli-2.3.3}/src/aw_cli/interface/fzf.py +0 -0
- {aw_cli-2.3.2 → aw_cli-2.3.3}/src/aw_cli/providers/__init__.py +0 -0
- {aw_cli-2.3.2 → aw_cli-2.3.3}/src/aw_cli/providers/animeunity.py +0 -0
- {aw_cli-2.3.2 → aw_cli-2.3.3}/src/aw_cli/providers/animeworld.py +0 -0
- {aw_cli-2.3.2 → aw_cli-2.3.3}/src/aw_cli/providers/local.py +0 -0
- {aw_cli-2.3.2 → aw_cli-2.3.3}/src/aw_cli/providers/provider.py +0 -0
- {aw_cli-2.3.2 → aw_cli-2.3.3}/src/aw_cli/run.py +0 -0
- {aw_cli-2.3.2 → aw_cli-2.3.3}/src/aw_cli/update.py +0 -0
- {aw_cli-2.3.2 → aw_cli-2.3.3}/src/aw_cli/utilities.py +0 -0
- {aw_cli-2.3.2 → aw_cli-2.3.3}/tests/__init__.py +0 -0
- {aw_cli-2.3.2 → aw_cli-2.3.3}/tests/conftest.py +0 -0
- {aw_cli-2.3.2 → aw_cli-2.3.3}/tests/core/__init__.py +0 -0
- {aw_cli-2.3.2 → aw_cli-2.3.3}/tests/core/test_anime.py +0 -0
- {aw_cli-2.3.2 → aw_cli-2.3.3}/tests/core/test_history.py +0 -0
- {aw_cli-2.3.2 → aw_cli-2.3.3}/tests/core/test_run.py +0 -0
- {aw_cli-2.3.2 → aw_cli-2.3.3}/tests/interface/__init__.py +0 -0
- {aw_cli-2.3.2 → aw_cli-2.3.3}/tests/interface/test_fzf.py +0 -0
- {aw_cli-2.3.2 → aw_cli-2.3.3}/tests/interface/test_rich_integration.py +0 -0
- {aw_cli-2.3.2 → aw_cli-2.3.3}/tests/providers/__init__.py +0 -0
- {aw_cli-2.3.2 → aw_cli-2.3.3}/tests/providers/test_animeunity.py +0 -0
- {aw_cli-2.3.2 → aw_cli-2.3.3}/tests/providers/test_provider.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: aw-cli
|
|
3
|
-
Version: 2.3.
|
|
3
|
+
Version: 2.3.3
|
|
4
4
|
Summary: Guarda anime dal terminale e molto altro!
|
|
5
5
|
Project-URL: Homepage, https://github.com/fexh10/aw-cli
|
|
6
6
|
Project-URL: Repository, https://github.com/fexh10/aw-cli
|
|
@@ -9,6 +9,7 @@ License-File: LICENSE
|
|
|
9
9
|
Requires-Python: >=3.10
|
|
10
10
|
Requires-Dist: httpx>=0.28.1
|
|
11
11
|
Requires-Dist: pip-system-certs>=5.2
|
|
12
|
+
Requires-Dist: pip>=26.2.0
|
|
12
13
|
Requires-Dist: pydantic>=2.12.5
|
|
13
14
|
Requires-Dist: regex>=2025.9.18
|
|
14
15
|
Requires-Dist: rich>=13.9.4
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import re
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from httpx import AsyncClient
|
|
5
|
+
from rich.progress import Progress, BarColumn, TextColumn, TaskID, DownloadColumn, TransferSpeedColumn
|
|
6
|
+
from . import utilities as ut
|
|
7
|
+
from .anime import Anime
|
|
8
|
+
from .providers import Provider
|
|
9
|
+
|
|
10
|
+
from functools import lru_cache
|
|
11
|
+
|
|
12
|
+
# Numero di connessioni parallele usate di default per scaricare un singolo
|
|
13
|
+
# episodio. Molti CDN limitano la banda per-connessione: scaricare lo stesso
|
|
14
|
+
# file a segmenti con più connessioni aggira quel limite. Sovrascrivibile
|
|
15
|
+
# tramite l'opzione "connections-per-download" nel file di configurazione.
|
|
16
|
+
DEFAULT_CONNECTIONS = 4
|
|
17
|
+
|
|
18
|
+
# Dimensione dei blocchi letti dallo stream. 1 MB riduce drasticamente
|
|
19
|
+
# l'overhead rispetto a letture da 1 KB su file di centinaia di MB.
|
|
20
|
+
CHUNK_SIZE = 1024 * 1024
|
|
21
|
+
|
|
22
|
+
def _segment_ranges(total: int, connections: int) -> list[tuple[int, int]]:
|
|
23
|
+
"""
|
|
24
|
+
Divide un download in intervalli byte inclusivi, evitando segmenti vuoti.
|
|
25
|
+
"""
|
|
26
|
+
if total <= 0 or connections <= 0:
|
|
27
|
+
return []
|
|
28
|
+
|
|
29
|
+
connections = min(connections, total)
|
|
30
|
+
base_size, remainder = divmod(total, connections)
|
|
31
|
+
ranges = []
|
|
32
|
+
start = 0
|
|
33
|
+
for i in range(connections):
|
|
34
|
+
size = base_size + (1 if i < remainder else 0)
|
|
35
|
+
end = start + size - 1
|
|
36
|
+
ranges.append((start, end))
|
|
37
|
+
start = end + 1
|
|
38
|
+
return ranges
|
|
39
|
+
|
|
40
|
+
def _valid_partial_response(response, start: int, end: int, total: int) -> bool:
|
|
41
|
+
"""
|
|
42
|
+
Verifica che il server abbia rispettato il Range richiesto.
|
|
43
|
+
"""
|
|
44
|
+
if response.status_code != 206:
|
|
45
|
+
return False
|
|
46
|
+
|
|
47
|
+
content_range = response.headers.get("content-range", "")
|
|
48
|
+
match = re.fullmatch(r"bytes (\d+)-(\d+)/(\d+|\*)", content_range)
|
|
49
|
+
if not match:
|
|
50
|
+
return False
|
|
51
|
+
|
|
52
|
+
actual_start = int(match.group(1))
|
|
53
|
+
actual_end = int(match.group(2))
|
|
54
|
+
actual_total = match.group(3)
|
|
55
|
+
return (
|
|
56
|
+
actual_start == start
|
|
57
|
+
and actual_end == end
|
|
58
|
+
and (actual_total == "*" or int(actual_total) == total)
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
@lru_cache
|
|
62
|
+
def path(create: bool = True) -> Path:
|
|
63
|
+
"""
|
|
64
|
+
Restituisce il percorso di download dell'anime, a seconda del sistema operativo in uso.
|
|
65
|
+
Se create è True (valore predefinito) e il percorso non esiste, viene creato.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
create (bool, optional): se impostato a True, crea il percorso se non esiste. Valore predefinito: True.
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
Path: il percorso di download dell'anime.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
if (ut.os_name == "Android"):
|
|
75
|
+
path = Path("/sdcard/Movies/Anime")
|
|
76
|
+
else:
|
|
77
|
+
path = Path.home() / "Videos/Anime"
|
|
78
|
+
|
|
79
|
+
if create:
|
|
80
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
81
|
+
return path
|
|
82
|
+
|
|
83
|
+
def episodes(anime: Anime, episodes: list[Anime.Episode], provider: Provider) -> None:
|
|
84
|
+
"""
|
|
85
|
+
Scarica più episodi in modo concorrente (max concurrent_downloads).
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
anime (Anime): l'anime di cui scaricare gli episodi.
|
|
89
|
+
episodes (list[Anime.Episode]): la lista degli episodi da scaricare.
|
|
90
|
+
provider (Provider): il provider da cui scaricare gli episodi.
|
|
91
|
+
"""
|
|
92
|
+
async def download_worker(ep: Anime.Episode, task_id: TaskID, progress: Progress, sem: asyncio.Semaphore):
|
|
93
|
+
async with sem:
|
|
94
|
+
filename = path() / anime.name / f"{ep}.mp4"
|
|
95
|
+
if filename.exists():
|
|
96
|
+
progress.update(task_id, completed=100, total=100, description=f"[green]Ep. {ep.num} (Esistente)")
|
|
97
|
+
return
|
|
98
|
+
|
|
99
|
+
filename.parent.mkdir(parents=True, exist_ok=True)
|
|
100
|
+
try:
|
|
101
|
+
url = provider.episode_link(anime, ep)
|
|
102
|
+
except Exception as e:
|
|
103
|
+
progress.console.print(f"[red]Errore link Ep. {ep.num}: {e}")
|
|
104
|
+
return
|
|
105
|
+
|
|
106
|
+
headers = provider.Client.headers
|
|
107
|
+
temp_filename = filename.with_name(f"{filename.name}.temp")
|
|
108
|
+
connections = max(1, ut.config_data["general"].get("connections-per-download", DEFAULT_CONNECTIONS))
|
|
109
|
+
|
|
110
|
+
async def single_stream(client: AsyncClient) -> None:
|
|
111
|
+
"""Scarica l'episodio con un'unica connessione (fallback)."""
|
|
112
|
+
async with client.stream("GET", url, headers=headers) as response:
|
|
113
|
+
response.raise_for_status()
|
|
114
|
+
total = int(response.headers.get('content-length', 0))
|
|
115
|
+
progress.update(task_id, total=total or None)
|
|
116
|
+
with open(temp_filename, "wb") as f:
|
|
117
|
+
async for chunk in response.aiter_bytes(CHUNK_SIZE):
|
|
118
|
+
if chunk:
|
|
119
|
+
progress.update(task_id, advance=f.write(chunk))
|
|
120
|
+
|
|
121
|
+
async def download_segment(client: AsyncClient, start: int, end: int) -> None:
|
|
122
|
+
"""Scarica l'intervallo di byte [start, end] e lo scrive alla sua posizione nel file."""
|
|
123
|
+
seg_headers = {**headers, "Range": f"bytes={start}-{end}"}
|
|
124
|
+
async with client.stream("GET", url, headers=seg_headers) as response:
|
|
125
|
+
if not _valid_partial_response(response, start, end, total):
|
|
126
|
+
raise ValueError(
|
|
127
|
+
f"Risposta Range non valida per bytes={start}-{end}"
|
|
128
|
+
)
|
|
129
|
+
with open(temp_filename, "r+b") as f:
|
|
130
|
+
f.seek(start)
|
|
131
|
+
async for chunk in response.aiter_bytes(CHUNK_SIZE):
|
|
132
|
+
if chunk:
|
|
133
|
+
progress.update(task_id, advance=f.write(chunk))
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
async with AsyncClient(timeout=30.0, follow_redirects=True) as client:
|
|
137
|
+
total = 0
|
|
138
|
+
accept_ranges = False
|
|
139
|
+
try:
|
|
140
|
+
head = await client.head(url, headers=headers)
|
|
141
|
+
head.raise_for_status()
|
|
142
|
+
total = int(head.headers.get('content-length', 0))
|
|
143
|
+
accept_ranges = head.headers.get('accept-ranges', '').lower() == 'bytes'
|
|
144
|
+
except Exception:
|
|
145
|
+
pass
|
|
146
|
+
|
|
147
|
+
if connections > 1 and accept_ranges and total > 0:
|
|
148
|
+
# Download segmentato: più connessioni in parallelo sullo stesso
|
|
149
|
+
# file, per aggirare i limiti di banda per-connessione dei CDN.
|
|
150
|
+
progress.update(task_id, total=total)
|
|
151
|
+
with open(temp_filename, "wb") as f:
|
|
152
|
+
f.truncate(total)
|
|
153
|
+
segments = [
|
|
154
|
+
download_segment(client, start, end)
|
|
155
|
+
for start, end in _segment_ranges(total, connections)
|
|
156
|
+
]
|
|
157
|
+
await asyncio.gather(*segments)
|
|
158
|
+
else:
|
|
159
|
+
# Server senza supporto ai range (o dimensione ignota): fallback.
|
|
160
|
+
await single_stream(client)
|
|
161
|
+
|
|
162
|
+
temp_filename.rename(filename)
|
|
163
|
+
progress.update(task_id, description=f"[success]Ep. {ep.num} (Completato)[/]")
|
|
164
|
+
except Exception as e:
|
|
165
|
+
progress.console.print(f"[error]Errore download Ep. {ep.num}: {e}[/]")
|
|
166
|
+
|
|
167
|
+
async def _download_all():
|
|
168
|
+
concurrent_downloads = ut.config_data["general"].get("parallel-downloads", 3)
|
|
169
|
+
semaphore = asyncio.Semaphore(concurrent_downloads)
|
|
170
|
+
ordered_eps = sorted(episodes, key=lambda e: e.numeric())
|
|
171
|
+
|
|
172
|
+
with Progress(
|
|
173
|
+
TextColumn("[info]{task.description}[/]", style="info"),
|
|
174
|
+
BarColumn(bar_width=None),
|
|
175
|
+
"•",
|
|
176
|
+
DownloadColumn(),
|
|
177
|
+
"•",
|
|
178
|
+
TransferSpeedColumn(),
|
|
179
|
+
console=ut.console,
|
|
180
|
+
) as progress:
|
|
181
|
+
tasks = []
|
|
182
|
+
for ep in ordered_eps:
|
|
183
|
+
task_id = progress.add_task(str(ep), total=None, start=True)
|
|
184
|
+
tasks.append(asyncio.create_task(download_worker(ep, task_id, progress, semaphore)))
|
|
185
|
+
|
|
186
|
+
await asyncio.gather(*tasks)
|
|
187
|
+
|
|
188
|
+
asyncio.run(_download_all())
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from types import SimpleNamespace
|
|
2
|
+
|
|
3
|
+
from aw_cli import download
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def response(status_code: int, content_range: str = ""):
|
|
7
|
+
return SimpleNamespace(
|
|
8
|
+
status_code=status_code,
|
|
9
|
+
headers={"content-range": content_range} if content_range else {},
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_segment_ranges_cover_file_without_gaps():
|
|
14
|
+
assert download._segment_ranges(total=10, connections=3) == [
|
|
15
|
+
(0, 3),
|
|
16
|
+
(4, 6),
|
|
17
|
+
(7, 9),
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_segment_ranges_do_not_create_empty_ranges():
|
|
22
|
+
assert download._segment_ranges(total=3, connections=10) == [
|
|
23
|
+
(0, 0),
|
|
24
|
+
(1, 1),
|
|
25
|
+
(2, 2),
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_valid_partial_response_accepts_matching_content_range():
|
|
30
|
+
assert download._valid_partial_response(
|
|
31
|
+
response(206, "bytes 4-6/10"),
|
|
32
|
+
start=4,
|
|
33
|
+
end=6,
|
|
34
|
+
total=10,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_valid_partial_response_rejects_ignored_range():
|
|
39
|
+
assert not download._valid_partial_response(
|
|
40
|
+
response(200, "bytes 0-9/10"),
|
|
41
|
+
start=4,
|
|
42
|
+
end=6,
|
|
43
|
+
total=10,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_valid_partial_response_rejects_wrong_content_range():
|
|
48
|
+
assert not download._valid_partial_response(
|
|
49
|
+
response(206, "bytes 0-6/10"),
|
|
50
|
+
start=4,
|
|
51
|
+
end=6,
|
|
52
|
+
total=10,
|
|
53
|
+
)
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import re
|
|
1
2
|
import pytest
|
|
2
3
|
from pathlib import Path
|
|
3
4
|
from unittest.mock import patch, MagicMock
|
|
@@ -74,10 +75,13 @@ class TestAnimeworld:
|
|
|
74
75
|
aw.Client.get.return_value = mock_response
|
|
75
76
|
|
|
76
77
|
video_url = aw._episode_link(anime, episode)
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
78
|
+
# Il sottodominio del CDN (es. "srv23-masafi") ruota nel tempo e le
|
|
79
|
+
# fixtures si rigenerano dal vivo: verifichiamo la struttura stabile
|
|
80
|
+
# dell'URL (dominio + path) senza fissare l'host volatile.
|
|
81
|
+
assert re.fullmatch(
|
|
82
|
+
r"https://[\w.-]+\.sweetpixel\.org/DDL/ANIME/Naruto/Naruto_Ep_001_SUB_ITA\.mp4",
|
|
83
|
+
video_url,
|
|
84
|
+
), f"URL video inatteso: {video_url}"
|
|
81
85
|
|
|
82
86
|
|
|
83
87
|
def test_animeworld_info_anime(self, aw):
|
|
@@ -30,6 +30,7 @@ name = "aw-cli"
|
|
|
30
30
|
source = { editable = "." }
|
|
31
31
|
dependencies = [
|
|
32
32
|
{ name = "httpx" },
|
|
33
|
+
{ name = "pip" },
|
|
33
34
|
{ name = "pip-system-certs" },
|
|
34
35
|
{ name = "pydantic" },
|
|
35
36
|
{ name = "regex" },
|
|
@@ -47,6 +48,7 @@ dev = [
|
|
|
47
48
|
[package.metadata]
|
|
48
49
|
requires-dist = [
|
|
49
50
|
{ name = "httpx", specifier = ">=0.28.1" },
|
|
51
|
+
{ name = "pip", specifier = ">=26.2.0" },
|
|
50
52
|
{ name = "pip-system-certs", specifier = ">=5.2" },
|
|
51
53
|
{ name = "pydantic", specifier = ">=2.12.5" },
|
|
52
54
|
{ name = "regex", specifier = ">=2025.9.18" },
|
|
@@ -102,7 +104,7 @@ name = "exceptiongroup"
|
|
|
102
104
|
version = "1.3.1"
|
|
103
105
|
source = { registry = "https://pypi.org/simple" }
|
|
104
106
|
dependencies = [
|
|
105
|
-
{ name = "typing-extensions"
|
|
107
|
+
{ name = "typing-extensions" },
|
|
106
108
|
]
|
|
107
109
|
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
|
|
108
110
|
wheels = [
|
|
@@ -223,11 +225,11 @@ wheels = [
|
|
|
223
225
|
|
|
224
226
|
[[package]]
|
|
225
227
|
name = "pip"
|
|
226
|
-
version = "26.
|
|
228
|
+
version = "26.2.1"
|
|
227
229
|
source = { registry = "https://pypi.org/simple" }
|
|
228
|
-
sdist = { url = "https://files.pythonhosted.org/packages/
|
|
230
|
+
sdist = { url = "https://files.pythonhosted.org/packages/ae/15/4500e320e6b101ec3b719ae85b697d9940b6cda672bc555bd6016fc60c6f/pip-26.2.1.tar.gz", hash = "sha256:f6ad667e89a1fe78046c8f13232b247200f5258d7828f3f7883d660878e0813f", size = 1848877, upload-time = "2026-08-04T22:51:14.148Z" }
|
|
229
231
|
wheels = [
|
|
230
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
232
|
+
{ url = "https://files.pythonhosted.org/packages/f3/6e/1736e5b4ae2b778ef2f81c47d797de9f891d4d8acb047a24ca37a60294dd/pip-26.2.1-py3-none-any.whl", hash = "sha256:71138adf1f4ca900cdb7d289c21b7494329f2332b6d85f0e1c42108c0384ed3e", size = 1816632, upload-time = "2026-08-04T22:51:12.472Z" },
|
|
231
233
|
]
|
|
232
234
|
|
|
233
235
|
[[package]]
|
|
@@ -1,97 +0,0 @@
|
|
|
1
|
-
import asyncio
|
|
2
|
-
from pathlib import Path
|
|
3
|
-
from httpx import AsyncClient
|
|
4
|
-
from rich.progress import Progress, BarColumn, TextColumn, TaskID, DownloadColumn, TransferSpeedColumn
|
|
5
|
-
from . import utilities as ut
|
|
6
|
-
from .anime import Anime
|
|
7
|
-
from .providers import Provider
|
|
8
|
-
|
|
9
|
-
from functools import lru_cache
|
|
10
|
-
|
|
11
|
-
@lru_cache
|
|
12
|
-
def path(create: bool = True) -> Path:
|
|
13
|
-
"""
|
|
14
|
-
Restituisce il percorso di download dell'anime, a seconda del sistema operativo in uso.
|
|
15
|
-
Se create è True (valore predefinito) e il percorso non esiste, viene creato.
|
|
16
|
-
|
|
17
|
-
Args:
|
|
18
|
-
create (bool, optional): se impostato a True, crea il percorso se non esiste. Valore predefinito: True.
|
|
19
|
-
|
|
20
|
-
Returns:
|
|
21
|
-
Path: il percorso di download dell'anime.
|
|
22
|
-
"""
|
|
23
|
-
|
|
24
|
-
if (ut.os_name == "Android"):
|
|
25
|
-
path = Path("/sdcard/Movies/Anime")
|
|
26
|
-
else:
|
|
27
|
-
path = Path.home() / "Videos/Anime"
|
|
28
|
-
|
|
29
|
-
if create:
|
|
30
|
-
path.mkdir(parents=True, exist_ok=True)
|
|
31
|
-
return path
|
|
32
|
-
|
|
33
|
-
def episodes(anime: Anime, episodes: list[Anime.Episode], provider: Provider) -> None:
|
|
34
|
-
"""
|
|
35
|
-
Scarica più episodi in modo concorrente (max concurrent_downloads).
|
|
36
|
-
|
|
37
|
-
Args:
|
|
38
|
-
anime (Anime): l'anime di cui scaricare gli episodi.
|
|
39
|
-
episodes (list[Anime.Episode]): la lista degli episodi da scaricare.
|
|
40
|
-
provider (Provider): il provider da cui scaricare gli episodi.
|
|
41
|
-
"""
|
|
42
|
-
async def download_worker(ep: Anime.Episode, task_id: TaskID, progress: Progress, sem: asyncio.Semaphore):
|
|
43
|
-
async with sem:
|
|
44
|
-
filename = path() / anime.name / f"{ep}.mp4"
|
|
45
|
-
if filename.exists():
|
|
46
|
-
progress.update(task_id, completed=100, total=100, description=f"[green]Ep. {ep.num} (Esistente)")
|
|
47
|
-
return
|
|
48
|
-
|
|
49
|
-
filename.parent.mkdir(parents=True, exist_ok=True)
|
|
50
|
-
try:
|
|
51
|
-
url = provider.episode_link(anime, ep)
|
|
52
|
-
except Exception as e:
|
|
53
|
-
progress.console.print(f"[red]Errore link Ep. {ep.num}: {e}")
|
|
54
|
-
return
|
|
55
|
-
|
|
56
|
-
try:
|
|
57
|
-
async with AsyncClient() as client:
|
|
58
|
-
async with client.stream("GET", url, headers=provider.Client.headers) as response:
|
|
59
|
-
total = int(response.headers.get('content-length', 0))
|
|
60
|
-
progress.update(task_id, total=total)
|
|
61
|
-
|
|
62
|
-
downloaded = 0
|
|
63
|
-
temp_filename = filename.with_name(f"{filename.name}.temp")
|
|
64
|
-
with open(temp_filename, "wb") as f:
|
|
65
|
-
async for chunk in response.aiter_bytes(1024):
|
|
66
|
-
if chunk:
|
|
67
|
-
n = f.write(chunk)
|
|
68
|
-
downloaded += n
|
|
69
|
-
progress.update(task_id, advance=n)
|
|
70
|
-
|
|
71
|
-
temp_filename.rename(filename)
|
|
72
|
-
progress.update(task_id, description=f"[success]Ep. {ep.num} (Completato)[/]")
|
|
73
|
-
except Exception as e:
|
|
74
|
-
progress.console.print(f"[error]Errore download Ep. {ep.num}: {e}[/]")
|
|
75
|
-
|
|
76
|
-
async def _download_all():
|
|
77
|
-
concurrent_downloads = ut.config_data["general"].get("parallel-downloads", 3)
|
|
78
|
-
semaphore = asyncio.Semaphore(concurrent_downloads)
|
|
79
|
-
ordered_eps = sorted(episodes, key=lambda e: e.numeric())
|
|
80
|
-
|
|
81
|
-
with Progress(
|
|
82
|
-
TextColumn("[info]{task.description}[/]", style="info"),
|
|
83
|
-
BarColumn(bar_width=None),
|
|
84
|
-
"•",
|
|
85
|
-
DownloadColumn(),
|
|
86
|
-
"•",
|
|
87
|
-
TransferSpeedColumn(),
|
|
88
|
-
console=ut.console,
|
|
89
|
-
) as progress:
|
|
90
|
-
tasks = []
|
|
91
|
-
for ep in ordered_eps:
|
|
92
|
-
task_id = progress.add_task(str(ep), total=None, start=True)
|
|
93
|
-
tasks.append(asyncio.create_task(download_worker(ep, task_id, progress, semaphore)))
|
|
94
|
-
|
|
95
|
-
await asyncio.gather(*tasks)
|
|
96
|
-
|
|
97
|
-
asyncio.run(_download_all())
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|