aw-cli 2.3.1__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.3/.github/ISSUE_TEMPLATE/bug_report.md +73 -0
- {aw_cli-2.3.1 → aw_cli-2.3.3}/.gitignore +3 -0
- {aw_cli-2.3.1 → aw_cli-2.3.3}/PKG-INFO +5 -3
- {aw_cli-2.3.1 → aw_cli-2.3.3}/pyproject.toml +9 -1
- aw_cli-2.3.3/src/aw_cli/download.py +188 -0
- {aw_cli-2.3.1/src/aw_cli/fzf → aw_cli-2.3.3/src/aw_cli/interface}/fzf.py +31 -10
- aw_cli-2.3.3/src/aw_cli/providers/__init__.py +36 -0
- {aw_cli-2.3.1 → aw_cli-2.3.3}/src/aw_cli/providers/animeunity.py +14 -14
- aw_cli-2.3.3/src/aw_cli/providers/animeworld.py +217 -0
- {aw_cli-2.3.1 → aw_cli-2.3.3}/src/aw_cli/providers/local.py +1 -1
- {aw_cli-2.3.1 → aw_cli-2.3.3}/src/aw_cli/providers/provider.py +8 -6
- {aw_cli-2.3.1 → aw_cli-2.3.3}/src/aw_cli/run.py +274 -119
- aw_cli-2.3.3/tests/conftest.py +128 -0
- aw_cli-2.3.3/tests/core/__init__.py +0 -0
- aw_cli-2.3.3/tests/core/test_download.py +53 -0
- aw_cli-2.3.3/tests/interface/__init__.py +0 -0
- {aw_cli-2.3.1/tests → aw_cli-2.3.3/tests/interface}/test_fzf.py +36 -5
- aw_cli-2.3.3/tests/providers/__init__.py +0 -0
- aw_cli-2.3.3/tests/providers/test_animeunity.py +90 -0
- aw_cli-2.3.3/tests/providers/test_animeworld.py +94 -0
- aw_cli-2.3.3/tests/providers/test_provider.py +42 -0
- aw_cli-2.3.3/uv.lock +761 -0
- aw_cli-2.3.1/src/aw_cli/download.py +0 -97
- aw_cli-2.3.1/src/aw_cli/providers/__init__.py +0 -12
- aw_cli-2.3.1/src/aw_cli/providers/animeworld.py +0 -123
- aw_cli-2.3.1/tests/empty +0 -25
- aw_cli-2.3.1/tests/search_dragonball +0 -216
- aw_cli-2.3.1/tests/search_onepiece +0 -127
- aw_cli-2.3.1/tests/theeminenceinshadowep13 +0 -499
- aw_cli-2.3.1/uv.lock +0 -773
- {aw_cli-2.3.1 → aw_cli-2.3.3}/.github/workflows/python-publish.yml +0 -0
- {aw_cli-2.3.1 → aw_cli-2.3.3}/.github/workflows/release.yml +0 -0
- {aw_cli-2.3.1 → aw_cli-2.3.3}/.pre-commit-config.yaml +0 -0
- {aw_cli-2.3.1 → aw_cli-2.3.3}/LICENSE +0 -0
- {aw_cli-2.3.1 → aw_cli-2.3.3}/README.md +0 -0
- {aw_cli-2.3.1 → aw_cli-2.3.3}/disclaimer.md +0 -0
- {aw_cli-2.3.1 → aw_cli-2.3.3}/src/aw_cli/__init__.py +0 -0
- {aw_cli-2.3.1 → aw_cli-2.3.3}/src/aw_cli/anilist.py +0 -0
- {aw_cli-2.3.1 → aw_cli-2.3.3}/src/aw_cli/anime.py +0 -0
- {aw_cli-2.3.1 → aw_cli-2.3.3}/src/aw_cli/arg_parser.py +0 -0
- {aw_cli-2.3.1 → aw_cli-2.3.3}/src/aw_cli/history.py +0 -0
- {aw_cli-2.3.1/src/aw_cli/fzf → aw_cli-2.3.3/src/aw_cli/interface}/__init__.py +0 -0
- {aw_cli-2.3.1 → aw_cli-2.3.3}/src/aw_cli/update.py +0 -0
- {aw_cli-2.3.1 → aw_cli-2.3.3}/src/aw_cli/utilities.py +0 -0
- {aw_cli-2.3.1 → aw_cli-2.3.3}/tests/__init__.py +0 -0
- {aw_cli-2.3.1/tests → aw_cli-2.3.3/tests/core}/test_anime.py +0 -0
- {aw_cli-2.3.1/tests → aw_cli-2.3.3/tests/core}/test_history.py +0 -0
- {aw_cli-2.3.1/tests → aw_cli-2.3.3/tests/core}/test_run.py +0 -0
- {aw_cli-2.3.1/tests → aw_cli-2.3.3/tests/interface}/test_rich_integration.py +0 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: Bug report
|
|
3
|
+
about: Segnala un problema con aw-cli
|
|
4
|
+
title: "[Bug]: "
|
|
5
|
+
labels: bug
|
|
6
|
+
assignees: ""
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Descrizione del problema
|
|
10
|
+
|
|
11
|
+
Descrivi chiaramente il problema riscontrato.
|
|
12
|
+
|
|
13
|
+
## Passi per riprodurre
|
|
14
|
+
|
|
15
|
+
Esempio:
|
|
16
|
+
|
|
17
|
+
1. Esegui `aw-cli ...`
|
|
18
|
+
2. Seleziona ...
|
|
19
|
+
3. Il problema si verifica quando ...
|
|
20
|
+
|
|
21
|
+
## Informazioni sull'ambiente
|
|
22
|
+
|
|
23
|
+
### Versione di aw-cli
|
|
24
|
+
|
|
25
|
+
Esegui:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
aw-cli -v
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Output:
|
|
32
|
+
|
|
33
|
+
```text
|
|
34
|
+
...
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### Sistema operativo
|
|
38
|
+
|
|
39
|
+
Esempio: Fedora 40, Ubuntu 24.04, Arch Linux, macOS, Android, Windows WSL, ecc.
|
|
40
|
+
|
|
41
|
+
```text
|
|
42
|
+
...
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### Metodo di installazione
|
|
46
|
+
|
|
47
|
+
- [ ] `uv`
|
|
48
|
+
- [ ] `pipx`
|
|
49
|
+
- [ ] `pip`
|
|
50
|
+
- [ ] Installazione manuale / da sorgente
|
|
51
|
+
- [ ] Altro (specifica qui)
|
|
52
|
+
|
|
53
|
+
### Provider
|
|
54
|
+
|
|
55
|
+
- [ ] AnimeWorld
|
|
56
|
+
- [ ] AnimeUnity
|
|
57
|
+
|
|
58
|
+
### Player
|
|
59
|
+
|
|
60
|
+
- [ ] mpv
|
|
61
|
+
- [ ] vlc
|
|
62
|
+
|
|
63
|
+
## Log / output del terminale
|
|
64
|
+
|
|
65
|
+
Incolla qui l'output completo dell'errore, se presente.
|
|
66
|
+
|
|
67
|
+
```
|
|
68
|
+
...
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Informazioni aggiuntive
|
|
72
|
+
|
|
73
|
+
Aggiungi qualsiasi altro dettaglio utile, screenshot o contesto.
|
|
@@ -1,17 +1,19 @@
|
|
|
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
|
+
Project-URL: Homepage, https://github.com/fexh10/aw-cli
|
|
6
|
+
Project-URL: Repository, https://github.com/fexh10/aw-cli
|
|
5
7
|
License-Expression: GPL-3.0
|
|
6
8
|
License-File: LICENSE
|
|
7
9
|
Requires-Python: >=3.10
|
|
8
10
|
Requires-Dist: httpx>=0.28.1
|
|
9
11
|
Requires-Dist: pip-system-certs>=5.2
|
|
12
|
+
Requires-Dist: pip>=26.2.0
|
|
10
13
|
Requires-Dist: pydantic>=2.12.5
|
|
11
14
|
Requires-Dist: regex>=2025.9.18
|
|
12
15
|
Requires-Dist: rich>=13.9.4
|
|
13
16
|
Requires-Dist: toml>=0.10.2
|
|
14
|
-
Requires-Dist: wheel>=0.45.1
|
|
15
17
|
Description-Content-Type: text/markdown
|
|
16
18
|
|
|
17
19
|
# aw-cli
|
|
@@ -7,14 +7,18 @@ 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",
|
|
13
14
|
"rich>=13.9.4",
|
|
14
15
|
"toml>=0.10.2",
|
|
15
|
-
"wheel>=0.45.1",
|
|
16
16
|
]
|
|
17
17
|
|
|
18
|
+
[project.urls]
|
|
19
|
+
Homepage = "https://github.com/fexh10/aw-cli"
|
|
20
|
+
Repository = "https://github.com/fexh10/aw-cli"
|
|
21
|
+
|
|
18
22
|
[project.scripts]
|
|
19
23
|
aw-cli = "aw_cli.run:main"
|
|
20
24
|
|
|
@@ -31,3 +35,7 @@ dev = [
|
|
|
31
35
|
"pyright>=1.1.406",
|
|
32
36
|
"pytest>=8.4.2",
|
|
33
37
|
]
|
|
38
|
+
|
|
39
|
+
[tool.pytest.ini_options]
|
|
40
|
+
testpaths = ["tests"]
|
|
41
|
+
addopts = "-ra"
|
|
@@ -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())
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import subprocess
|
|
2
2
|
import sys
|
|
3
3
|
import re
|
|
4
|
+
import socket
|
|
5
|
+
import json
|
|
4
6
|
import argparse
|
|
5
7
|
import urllib.request
|
|
6
8
|
|
|
7
|
-
import aw_cli.utilities as ut
|
|
8
|
-
|
|
9
9
|
|
|
10
10
|
class Fzf:
|
|
11
11
|
"""
|
|
@@ -20,7 +20,7 @@ class Fzf:
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
def __init__(self):
|
|
23
|
-
self._port: int =
|
|
23
|
+
self._port: int = 0
|
|
24
24
|
|
|
25
25
|
def run(
|
|
26
26
|
self,
|
|
@@ -38,7 +38,10 @@ class Fzf:
|
|
|
38
38
|
multi: se True abilita la selezione multipla (Ctrl+A = toggle all).
|
|
39
39
|
filter: se True abilita il filtro per range (formato: inizio-fine).
|
|
40
40
|
"""
|
|
41
|
-
|
|
41
|
+
self._port = self._find_free_port()
|
|
42
|
+
cmd = self._build_cmd(
|
|
43
|
+
elements=elements, prompt=prompt, multi=multi, filter=filter
|
|
44
|
+
)
|
|
42
45
|
|
|
43
46
|
try:
|
|
44
47
|
while True:
|
|
@@ -82,6 +85,16 @@ class Fzf:
|
|
|
82
85
|
except OSError:
|
|
83
86
|
pass
|
|
84
87
|
|
|
88
|
+
@staticmethod
|
|
89
|
+
def _find_free_port() -> int:
|
|
90
|
+
"""
|
|
91
|
+
Restituisce una porta TCP libera assegnata dal sistema operativo.
|
|
92
|
+
Usare questa porta per --listen evita conflitti e TIME_WAIT tra riavvii di fzf.
|
|
93
|
+
"""
|
|
94
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
95
|
+
s.bind(("", 0))
|
|
96
|
+
return s.getsockname()[1]
|
|
97
|
+
|
|
85
98
|
def _build_cmd(
|
|
86
99
|
self,
|
|
87
100
|
elements: list[str],
|
|
@@ -109,11 +122,12 @@ class Fzf:
|
|
|
109
122
|
cmd += ["--multi", "--bind", "ctrl-a:toggle-all"]
|
|
110
123
|
|
|
111
124
|
if filter:
|
|
112
|
-
reload_cmd = f"{sys.executable} -m aw_cli.
|
|
125
|
+
reload_cmd = f"{sys.executable} -m aw_cli.interface.fzf --filter {{q}} --episodes '{json.dumps(elements)}'"
|
|
113
126
|
cmd += [
|
|
114
127
|
"--phony",
|
|
115
|
-
"--bind",
|
|
116
|
-
"
|
|
128
|
+
"--bind",
|
|
129
|
+
f"change:reload({reload_cmd})",
|
|
130
|
+
"--header=Range: inizio-fine. crtl+A seleziona tutto, tab/shift+tab selezione singola",
|
|
117
131
|
]
|
|
118
132
|
|
|
119
133
|
return cmd
|
|
@@ -132,7 +146,10 @@ def _filter_episodes(query: str, episodes_raw: str) -> None:
|
|
|
132
146
|
ha successo se i due intervalli si intersecano.
|
|
133
147
|
"""
|
|
134
148
|
|
|
135
|
-
|
|
149
|
+
try:
|
|
150
|
+
episodes: list[str] = json.loads(episodes_raw)
|
|
151
|
+
except (json.JSONDecodeError, ValueError):
|
|
152
|
+
return
|
|
136
153
|
|
|
137
154
|
if not query:
|
|
138
155
|
print("\n".join(episodes))
|
|
@@ -185,8 +202,12 @@ def _filter_episodes(query: str, episodes_raw: str) -> None:
|
|
|
185
202
|
|
|
186
203
|
if __name__ == "__main__":
|
|
187
204
|
parser = argparse.ArgumentParser()
|
|
188
|
-
parser.add_argument(
|
|
189
|
-
|
|
205
|
+
parser.add_argument(
|
|
206
|
+
"--filter", type=str, default="", help="Query per filtrare gli episodi"
|
|
207
|
+
)
|
|
208
|
+
parser.add_argument(
|
|
209
|
+
"--episodes", type=str, required=True, help="Lista episodi JSON"
|
|
210
|
+
)
|
|
190
211
|
args = parser.parse_args()
|
|
191
212
|
|
|
192
213
|
_filter_episodes(args.filter, args.episodes)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# pyright: reportUnusedImport=false
|
|
2
|
+
from .provider import Provider
|
|
3
|
+
|
|
4
|
+
PROVIDERS_AVAILABLE = {
|
|
5
|
+
"animeunity": ("aw_cli.providers.animeunity", "Animeunity"),
|
|
6
|
+
"animeworld": ("aw_cli.providers.animeworld", "Animeworld"),
|
|
7
|
+
# "animeworld-api": ("aw_cli.providers.animeworld_api", "AnimeWorldAPI"),
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
def create_provider(name: str) -> Provider:
|
|
11
|
+
"""
|
|
12
|
+
Crea un'istanza del provider in base al nome.
|
|
13
|
+
Usa importazione lazy per caricare solo il modulo necessario.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
name (str): il nome del provider (es. 'animeunity', 'animeworld').
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
Provider: l'istanza del provider creato.
|
|
20
|
+
|
|
21
|
+
Raises:
|
|
22
|
+
ValueError: se il nome del provider non è disponibile.
|
|
23
|
+
"""
|
|
24
|
+
if name not in PROVIDERS_AVAILABLE:
|
|
25
|
+
raise ValueError(f"Provider '{name}' non disponibile. Scegli tra: {list(PROVIDERS_AVAILABLE.keys())}")
|
|
26
|
+
|
|
27
|
+
module_name, class_name = PROVIDERS_AVAILABLE[name]
|
|
28
|
+
import importlib
|
|
29
|
+
module = importlib.import_module(module_name)
|
|
30
|
+
provider_class = getattr(module, class_name)
|
|
31
|
+
return provider_class()
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"Provider",
|
|
35
|
+
"create_provider",
|
|
36
|
+
]
|
|
@@ -1,38 +1,38 @@
|
|
|
1
1
|
import re
|
|
2
2
|
import json
|
|
3
3
|
from html import unescape
|
|
4
|
+
from functools import cached_property
|
|
4
5
|
from ..anime import Anime, AnimeStatus
|
|
5
6
|
from .provider import Provider, HTTPError
|
|
6
7
|
|
|
7
8
|
class Animeunity(Provider):
|
|
8
9
|
"""
|
|
9
10
|
Classe che gestisce il collegamento con Animeworld.
|
|
10
|
-
|
|
11
|
+
|
|
11
12
|
Attributes:
|
|
12
13
|
_url (str): l'url del sito di Animeworld.
|
|
13
14
|
_headers (dict): gli headers da utilizzare per le richieste HTTP.
|
|
14
15
|
_cookies (dict): i cookies da utilizzare per le richieste HTTP.
|
|
15
16
|
_visited (dict): le pagine già visitate.
|
|
16
17
|
"""
|
|
17
|
-
def __init__(self):
|
|
18
|
-
super().__init__("https://www.animeunity.so")
|
|
19
|
-
self._get_token() # Assicura che i token/cookie siano aggiornati
|
|
18
|
+
def __init__(self, client=None):
|
|
19
|
+
super().__init__("https://www.animeunity.so", client=client)
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
def
|
|
23
|
-
|
|
21
|
+
@cached_property
|
|
22
|
+
def _session(self) -> str:
|
|
23
|
+
"""Inizializza la sessione (token CSRF + cookies) al primo accesso. Ritorna l'HTML."""
|
|
24
24
|
response = self.Client.get(self.BASE_URL)
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
csrf_match = re.search(r'<meta.*?name="csrf-token".*?content="([^"]*)".*?>', self.html)
|
|
25
|
+
html = response.text
|
|
26
|
+
csrf_match = re.search(r'<meta.*?name="csrf-token".*?content="([^"]*)".*?>', html)
|
|
28
27
|
if not csrf_match:
|
|
29
28
|
raise ValueError("CSRF token not found in the HTML")
|
|
30
|
-
|
|
31
|
-
self.Client.headers['x-csrf-token'] = csrf_token
|
|
29
|
+
self.Client.headers['x-csrf-token'] = csrf_match.group(1)
|
|
32
30
|
self.Client.cookies['animeunity_session'] = response.cookies.get('animeunity_session') or ""
|
|
31
|
+
return html
|
|
33
32
|
|
|
34
33
|
|
|
35
34
|
def _search(self, input: str) -> list[Anime]:
|
|
35
|
+
_ = self._session # trigger lazy init
|
|
36
36
|
search_url = f"{self.BASE_URL}/livesearch"
|
|
37
37
|
response = self.Client.post(
|
|
38
38
|
url=search_url,
|
|
@@ -52,7 +52,7 @@ class Animeunity(Provider):
|
|
|
52
52
|
|
|
53
53
|
|
|
54
54
|
def _latest(self, filter, specials) -> list[Anime]:
|
|
55
|
-
regex = re.search(r'<layout-items[^>]*items-json="([^"]*)"', self.
|
|
55
|
+
regex = re.search(r'<layout-items[^>]*items-json="([^"]*)"', self._session)
|
|
56
56
|
if not regex:
|
|
57
57
|
raise ValueError("Errore nel parsing della pagina principale")
|
|
58
58
|
|
|
@@ -131,7 +131,7 @@ class Animeunity(Provider):
|
|
|
131
131
|
|
|
132
132
|
def _parse_info(self, data: dict) -> tuple[str, str, int, AnimeStatus, dict[str, str]]:
|
|
133
133
|
title = data['title_eng'] or data['title'] or data['title_it']
|
|
134
|
-
last_ep = str(data['real_episodes_count']) if 'real_episodes_count' in data else "0"
|
|
134
|
+
last_ep = str(data['real_episodes_count']) if 'real_episodes_count' in data else "0"
|
|
135
135
|
anilist_id = data['anilist_id']
|
|
136
136
|
match data['status']:
|
|
137
137
|
case "In Corso":
|