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.
Files changed (42) hide show
  1. {aw_cli-2.3.2 → aw_cli-2.3.3}/.github/ISSUE_TEMPLATE/bug_report.md +8 -0
  2. {aw_cli-2.3.2 → aw_cli-2.3.3}/PKG-INFO +3 -2
  3. {aw_cli-2.3.2 → aw_cli-2.3.3}/pyproject.toml +1 -0
  4. aw_cli-2.3.3/src/aw_cli/download.py +188 -0
  5. aw_cli-2.3.3/tests/core/test_download.py +53 -0
  6. {aw_cli-2.3.2 → aw_cli-2.3.3}/tests/providers/test_animeworld.py +8 -4
  7. {aw_cli-2.3.2 → aw_cli-2.3.3}/uv.lock +6 -4
  8. aw_cli-2.3.2/src/aw_cli/download.py +0 -97
  9. {aw_cli-2.3.2 → aw_cli-2.3.3}/.github/workflows/python-publish.yml +0 -0
  10. {aw_cli-2.3.2 → aw_cli-2.3.3}/.github/workflows/release.yml +0 -0
  11. {aw_cli-2.3.2 → aw_cli-2.3.3}/.gitignore +0 -0
  12. {aw_cli-2.3.2 → aw_cli-2.3.3}/.pre-commit-config.yaml +0 -0
  13. {aw_cli-2.3.2 → aw_cli-2.3.3}/LICENSE +0 -0
  14. {aw_cli-2.3.2 → aw_cli-2.3.3}/README.md +0 -0
  15. {aw_cli-2.3.2 → aw_cli-2.3.3}/disclaimer.md +0 -0
  16. {aw_cli-2.3.2 → aw_cli-2.3.3}/src/aw_cli/__init__.py +0 -0
  17. {aw_cli-2.3.2 → aw_cli-2.3.3}/src/aw_cli/anilist.py +0 -0
  18. {aw_cli-2.3.2 → aw_cli-2.3.3}/src/aw_cli/anime.py +0 -0
  19. {aw_cli-2.3.2 → aw_cli-2.3.3}/src/aw_cli/arg_parser.py +0 -0
  20. {aw_cli-2.3.2 → aw_cli-2.3.3}/src/aw_cli/history.py +0 -0
  21. {aw_cli-2.3.2 → aw_cli-2.3.3}/src/aw_cli/interface/__init__.py +0 -0
  22. {aw_cli-2.3.2 → aw_cli-2.3.3}/src/aw_cli/interface/fzf.py +0 -0
  23. {aw_cli-2.3.2 → aw_cli-2.3.3}/src/aw_cli/providers/__init__.py +0 -0
  24. {aw_cli-2.3.2 → aw_cli-2.3.3}/src/aw_cli/providers/animeunity.py +0 -0
  25. {aw_cli-2.3.2 → aw_cli-2.3.3}/src/aw_cli/providers/animeworld.py +0 -0
  26. {aw_cli-2.3.2 → aw_cli-2.3.3}/src/aw_cli/providers/local.py +0 -0
  27. {aw_cli-2.3.2 → aw_cli-2.3.3}/src/aw_cli/providers/provider.py +0 -0
  28. {aw_cli-2.3.2 → aw_cli-2.3.3}/src/aw_cli/run.py +0 -0
  29. {aw_cli-2.3.2 → aw_cli-2.3.3}/src/aw_cli/update.py +0 -0
  30. {aw_cli-2.3.2 → aw_cli-2.3.3}/src/aw_cli/utilities.py +0 -0
  31. {aw_cli-2.3.2 → aw_cli-2.3.3}/tests/__init__.py +0 -0
  32. {aw_cli-2.3.2 → aw_cli-2.3.3}/tests/conftest.py +0 -0
  33. {aw_cli-2.3.2 → aw_cli-2.3.3}/tests/core/__init__.py +0 -0
  34. {aw_cli-2.3.2 → aw_cli-2.3.3}/tests/core/test_anime.py +0 -0
  35. {aw_cli-2.3.2 → aw_cli-2.3.3}/tests/core/test_history.py +0 -0
  36. {aw_cli-2.3.2 → aw_cli-2.3.3}/tests/core/test_run.py +0 -0
  37. {aw_cli-2.3.2 → aw_cli-2.3.3}/tests/interface/__init__.py +0 -0
  38. {aw_cli-2.3.2 → aw_cli-2.3.3}/tests/interface/test_fzf.py +0 -0
  39. {aw_cli-2.3.2 → aw_cli-2.3.3}/tests/interface/test_rich_integration.py +0 -0
  40. {aw_cli-2.3.2 → aw_cli-2.3.3}/tests/providers/__init__.py +0 -0
  41. {aw_cli-2.3.2 → aw_cli-2.3.3}/tests/providers/test_animeunity.py +0 -0
  42. {aw_cli-2.3.2 → aw_cli-2.3.3}/tests/providers/test_provider.py +0 -0
@@ -1,3 +1,11 @@
1
+ ---
2
+ name: Bug report
3
+ about: Segnala un problema con aw-cli
4
+ title: "[Bug]: "
5
+ labels: bug
6
+ assignees: ""
7
+ ---
8
+
1
9
  ## Descrizione del problema
2
10
 
3
11
  Descrivi chiaramente il problema riscontrato.
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: aw-cli
3
- Version: 2.3.2
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
@@ -7,6 +7,7 @@ license = "GPL-3.0"
7
7
  requires-python = ">=3.10"
8
8
  dependencies = [
9
9
  "httpx>=0.28.1",
10
+ "pip>=26.2.0",
10
11
  "pip-system-certs>=5.2",
11
12
  "pydantic>=2.12.5",
12
13
  "regex>=2025.9.18",
@@ -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
- assert (
78
- video_url
79
- == "https://srv23-abbaia.sweetpixel.org/DDL/ANIME/Naruto/Naruto_Ep_001_SUB_ITA.mp4"
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", marker = "python_full_version < '3.13'" },
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.1.1"
228
+ version = "26.2.1"
227
229
  source = { registry = "https://pypi.org/simple" }
228
- sdist = { url = "https://files.pythonhosted.org/packages/b6/48/cb9b7a682f6fe01a4221e1728941dd4ac3cd9090a17db3779d6ff490b602/pip-26.1.1.tar.gz", hash = "sha256:d36762751d156a4ee895de8af39aa0abeeeb577f93a2eca6ab62467bbf0f8a78", size = 1840400, upload-time = "2026-05-04T19:02:21.248Z" }
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/3a/eb/fea4d1d51c49832120f7f285d07306db3960f423a2612c6057caf3e8196f/pip-26.1.1-py3-none-any.whl", hash = "sha256:99cb1c2899893b075ff56e4ed0af55669a955b49ad7fb8d8603ecdaf4ed653fb", size = 1812777, upload-time = "2026-05-04T19:02:18.9Z" },
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