reanimdl 0.1.0__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.
- reanimdl-0.1.0/PKG-INFO +149 -0
- reanimdl-0.1.0/README.md +135 -0
- reanimdl-0.1.0/pyproject.toml +22 -0
- reanimdl-0.1.0/pyproject.toml.orig +23 -0
- reanimdl-0.1.0/src/reanimdl/__init__.py +9 -0
- reanimdl-0.1.0/src/reanimdl/__main__.py +4 -0
- reanimdl-0.1.0/src/reanimdl/api.py +221 -0
- reanimdl-0.1.0/src/reanimdl/cli.py +239 -0
- reanimdl-0.1.0/src/reanimdl/downloader.py +415 -0
- reanimdl-0.1.0/src/reanimdl/flixcloud.py +421 -0
- reanimdl-0.1.0/src/reanimdl/handler.py +427 -0
- reanimdl-0.1.0/src/reanimdl/hls.py +385 -0
- reanimdl-0.1.0/src/reanimdl/mux.py +337 -0
- reanimdl-0.1.0/src/reanimdl/preferences.py +88 -0
- reanimdl-0.1.0/src/reanimdl/utils.py +355 -0
reanimdl-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: reanimdl
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: An asynchronous anime downloader CLI for reanime.to
|
|
5
|
+
Requires-Dist: aiohttp>=3.12
|
|
6
|
+
Requires-Dist: av>=19.0.1
|
|
7
|
+
Requires-Dist: json5>=0.12
|
|
8
|
+
Requires-Dist: m3u8>=6.0,<7
|
|
9
|
+
Requires-Dist: platformdirs>=4.13.5
|
|
10
|
+
Requires-Dist: pycryptodome>=3.21
|
|
11
|
+
Requires-Dist: pywasm>=2.2,<3
|
|
12
|
+
Requires-Python: >=3.14
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# reanimdl
|
|
16
|
+
|
|
17
|
+
An asynchronous command-line anime downloader for reanime.to.
|
|
18
|
+
|
|
19
|
+
It searches the site's public API, resolves the FlixCloud player, downloads
|
|
20
|
+
HLS video/audio segments directly, decrypts them, and muxes everything into an
|
|
21
|
+
MKV with subtitles, audio tracks, fonts, and chapter markers. Downloaded media
|
|
22
|
+
stays in memory; only the final MKV is written to disk.
|
|
23
|
+
|
|
24
|
+
## Requirements
|
|
25
|
+
|
|
26
|
+
- Linux, macOS, or Windows (tested on Linux and Windows)
|
|
27
|
+
- [uv](https://docs.astral.sh/uv/)
|
|
28
|
+
|
|
29
|
+
Python itself is managed by `uv`; the project uses Python 3.14. Muxing uses
|
|
30
|
+
[PyAV](https://pyav.org/), which bundles its own FFmpeg libraries, so no system
|
|
31
|
+
`ffmpeg` binary is required.
|
|
32
|
+
|
|
33
|
+
## PyAV
|
|
34
|
+
|
|
35
|
+
[PyAV](https://pyav.org/) is a Pythonic binding for FFmpeg. It bundles its own
|
|
36
|
+
FFmpeg libraries, so reanimdl does not require a system `ffmpeg` binary. It
|
|
37
|
+
provides direct access to stream templates, per-stream metadata and disposition,
|
|
38
|
+
chapters, and `add_attachment` for embedding subtitle fonts — all without
|
|
39
|
+
re-encoding a single frame. In reanimdl, video, audio, and subtitle streams stay
|
|
40
|
+
entirely in memory while PyAV remuxes them directly into a Matroska container,
|
|
41
|
+
writing only the final MKV to disk. No temporary media files are written.
|
|
42
|
+
|
|
43
|
+
## Usage
|
|
44
|
+
|
|
45
|
+
### Preferences
|
|
46
|
+
|
|
47
|
+
Save defaults for the output directory, preferred quality, and audio mode:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
reanimdl preferences -o ~/Videos/Anime -q 720 --audio dual
|
|
51
|
+
reanimdl preferences # show saved preferences
|
|
52
|
+
reanimdl preferences --reset # restore built-in defaults
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`--audio` accepts `sub`, `dub`, or `dual`. Command-line options on
|
|
56
|
+
`reanimdl download` override saved preferences. The preferences file lives in the
|
|
57
|
+
platform-specific user config directory chosen by
|
|
58
|
+
[`platformdirs`](https://pypi.org/project/platformdirs/), for example:
|
|
59
|
+
|
|
60
|
+
- Linux: `~/.config/reanimdl/preferences.json`
|
|
61
|
+
- macOS: `~/Library/Application Support/reanimdl/preferences.json`
|
|
62
|
+
- Windows: `%LOCALAPPDATA%\reanimdl\preferences.json`
|
|
63
|
+
|
|
64
|
+
### Search
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
reanimdl search "solo leveling"
|
|
68
|
+
reanimdl s frieren --limit 5
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Show anime details and episodes:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
reanimdl info solo-leveling-gyycz8
|
|
75
|
+
reanimdl episodes solo-leveling-gyycz8
|
|
76
|
+
reanimdl eps solo-leveling-gyycz8 --limit 5 --reverse
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### Download
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
# all episodes, best quality up to 1080p
|
|
83
|
+
reanimdl download solo-leveling-gyycz8
|
|
84
|
+
|
|
85
|
+
# episodes 1-3 and 7, English dub
|
|
86
|
+
reanimdl download solo-leveling-gyycz8 -e 1-3,7 --dub
|
|
87
|
+
|
|
88
|
+
# the newest five episodes, 720p, into a specific folder
|
|
89
|
+
reanimdl download solo-leveling-gyycz8 --latest 5 -q 720 -o ~/Videos/Anime
|
|
90
|
+
|
|
91
|
+
# both Japanese and English audio tracks
|
|
92
|
+
reanimdl download solo-leveling-gyycz8 -e 1 --dual
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
A title can be passed instead of an ID. If several anime match, `reanimdl`
|
|
96
|
+
prints a numbered list and asks you to choose (or use `--yes` to accept the
|
|
97
|
+
best match):
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
reanimdl download "frieren beyond journeys end" -e 1
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### Episode selection
|
|
104
|
+
|
|
105
|
+
`-e/--episodes` accepts:
|
|
106
|
+
|
|
107
|
+
| Value | Meaning |
|
|
108
|
+
| --- | --- |
|
|
109
|
+
| `all` or omitted | every episode |
|
|
110
|
+
| `1,3,5-8` | explicit numbers and ranges |
|
|
111
|
+
| `latest` | the last episode |
|
|
112
|
+
| `1-last` | from episode 1 to the end |
|
|
113
|
+
| `-5` | the last five episodes |
|
|
114
|
+
|
|
115
|
+
Use `--latest N` as a shortcut for the newest `N` episodes.
|
|
116
|
+
|
|
117
|
+
### Download options
|
|
118
|
+
|
|
119
|
+
| Option | Description |
|
|
120
|
+
| --- | --- |
|
|
121
|
+
| `-q, --quality N` | preferred vertical resolution (default: saved preference or `1080`) |
|
|
122
|
+
| `--audio sub\|dub\|dual` | audio track (default: saved preference or `sub`) |
|
|
123
|
+
| `--dub` | shortcut for `--audio dub` |
|
|
124
|
+
| `--dual` | shortcut for `--audio dual` |
|
|
125
|
+
| `--server HD-1\|HD-2` | pick the CDN/server (default `HD-1`) |
|
|
126
|
+
| `-o, --output DIR` | output directory (default: saved preference or `.`) |
|
|
127
|
+
| `--workers N` | concurrent segment downloads (default `32`) |
|
|
128
|
+
| `-f, --force` | overwrite existing output files |
|
|
129
|
+
| `--no-subs` | do not mux subtitles |
|
|
130
|
+
| `--no-fonts` | do not attach subtitle fonts |
|
|
131
|
+
| `--no-chapters` | do not write chapter markers |
|
|
132
|
+
| `-y, --yes` | accept automatic choices / skip confirmation |
|
|
133
|
+
| `--quiet` | hide progress and status output |
|
|
134
|
+
|
|
135
|
+
Files are written as:
|
|
136
|
+
|
|
137
|
+
```text
|
|
138
|
+
<output>/<Anime Title>/<Anime Title> - E01 [1080p] [Sub].mkv
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Video/audio segments, subtitles, and fonts are kept in memory. PyAV muxes
|
|
142
|
+
directly from those in-memory streams into the final MKV, so no temporary media
|
|
143
|
+
files are written.
|
|
144
|
+
|
|
145
|
+
## Disclaimer
|
|
146
|
+
|
|
147
|
+
Downloading copyrighted material may be illegal where you live. Use this tool
|
|
148
|
+
only for content you are allowed to download. The project is not affiliated
|
|
149
|
+
with reanime.to, FlixCloud, or their CDNs.
|
reanimdl-0.1.0/README.md
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# reanimdl
|
|
2
|
+
|
|
3
|
+
An asynchronous command-line anime downloader for reanime.to.
|
|
4
|
+
|
|
5
|
+
It searches the site's public API, resolves the FlixCloud player, downloads
|
|
6
|
+
HLS video/audio segments directly, decrypts them, and muxes everything into an
|
|
7
|
+
MKV with subtitles, audio tracks, fonts, and chapter markers. Downloaded media
|
|
8
|
+
stays in memory; only the final MKV is written to disk.
|
|
9
|
+
|
|
10
|
+
## Requirements
|
|
11
|
+
|
|
12
|
+
- Linux, macOS, or Windows (tested on Linux and Windows)
|
|
13
|
+
- [uv](https://docs.astral.sh/uv/)
|
|
14
|
+
|
|
15
|
+
Python itself is managed by `uv`; the project uses Python 3.14. Muxing uses
|
|
16
|
+
[PyAV](https://pyav.org/), which bundles its own FFmpeg libraries, so no system
|
|
17
|
+
`ffmpeg` binary is required.
|
|
18
|
+
|
|
19
|
+
## PyAV
|
|
20
|
+
|
|
21
|
+
[PyAV](https://pyav.org/) is a Pythonic binding for FFmpeg. It bundles its own
|
|
22
|
+
FFmpeg libraries, so reanimdl does not require a system `ffmpeg` binary. It
|
|
23
|
+
provides direct access to stream templates, per-stream metadata and disposition,
|
|
24
|
+
chapters, and `add_attachment` for embedding subtitle fonts — all without
|
|
25
|
+
re-encoding a single frame. In reanimdl, video, audio, and subtitle streams stay
|
|
26
|
+
entirely in memory while PyAV remuxes them directly into a Matroska container,
|
|
27
|
+
writing only the final MKV to disk. No temporary media files are written.
|
|
28
|
+
|
|
29
|
+
## Usage
|
|
30
|
+
|
|
31
|
+
### Preferences
|
|
32
|
+
|
|
33
|
+
Save defaults for the output directory, preferred quality, and audio mode:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
reanimdl preferences -o ~/Videos/Anime -q 720 --audio dual
|
|
37
|
+
reanimdl preferences # show saved preferences
|
|
38
|
+
reanimdl preferences --reset # restore built-in defaults
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
`--audio` accepts `sub`, `dub`, or `dual`. Command-line options on
|
|
42
|
+
`reanimdl download` override saved preferences. The preferences file lives in the
|
|
43
|
+
platform-specific user config directory chosen by
|
|
44
|
+
[`platformdirs`](https://pypi.org/project/platformdirs/), for example:
|
|
45
|
+
|
|
46
|
+
- Linux: `~/.config/reanimdl/preferences.json`
|
|
47
|
+
- macOS: `~/Library/Application Support/reanimdl/preferences.json`
|
|
48
|
+
- Windows: `%LOCALAPPDATA%\reanimdl\preferences.json`
|
|
49
|
+
|
|
50
|
+
### Search
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
reanimdl search "solo leveling"
|
|
54
|
+
reanimdl s frieren --limit 5
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Show anime details and episodes:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
reanimdl info solo-leveling-gyycz8
|
|
61
|
+
reanimdl episodes solo-leveling-gyycz8
|
|
62
|
+
reanimdl eps solo-leveling-gyycz8 --limit 5 --reverse
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### Download
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
# all episodes, best quality up to 1080p
|
|
69
|
+
reanimdl download solo-leveling-gyycz8
|
|
70
|
+
|
|
71
|
+
# episodes 1-3 and 7, English dub
|
|
72
|
+
reanimdl download solo-leveling-gyycz8 -e 1-3,7 --dub
|
|
73
|
+
|
|
74
|
+
# the newest five episodes, 720p, into a specific folder
|
|
75
|
+
reanimdl download solo-leveling-gyycz8 --latest 5 -q 720 -o ~/Videos/Anime
|
|
76
|
+
|
|
77
|
+
# both Japanese and English audio tracks
|
|
78
|
+
reanimdl download solo-leveling-gyycz8 -e 1 --dual
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
A title can be passed instead of an ID. If several anime match, `reanimdl`
|
|
82
|
+
prints a numbered list and asks you to choose (or use `--yes` to accept the
|
|
83
|
+
best match):
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
reanimdl download "frieren beyond journeys end" -e 1
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### Episode selection
|
|
90
|
+
|
|
91
|
+
`-e/--episodes` accepts:
|
|
92
|
+
|
|
93
|
+
| Value | Meaning |
|
|
94
|
+
| --- | --- |
|
|
95
|
+
| `all` or omitted | every episode |
|
|
96
|
+
| `1,3,5-8` | explicit numbers and ranges |
|
|
97
|
+
| `latest` | the last episode |
|
|
98
|
+
| `1-last` | from episode 1 to the end |
|
|
99
|
+
| `-5` | the last five episodes |
|
|
100
|
+
|
|
101
|
+
Use `--latest N` as a shortcut for the newest `N` episodes.
|
|
102
|
+
|
|
103
|
+
### Download options
|
|
104
|
+
|
|
105
|
+
| Option | Description |
|
|
106
|
+
| --- | --- |
|
|
107
|
+
| `-q, --quality N` | preferred vertical resolution (default: saved preference or `1080`) |
|
|
108
|
+
| `--audio sub\|dub\|dual` | audio track (default: saved preference or `sub`) |
|
|
109
|
+
| `--dub` | shortcut for `--audio dub` |
|
|
110
|
+
| `--dual` | shortcut for `--audio dual` |
|
|
111
|
+
| `--server HD-1\|HD-2` | pick the CDN/server (default `HD-1`) |
|
|
112
|
+
| `-o, --output DIR` | output directory (default: saved preference or `.`) |
|
|
113
|
+
| `--workers N` | concurrent segment downloads (default `32`) |
|
|
114
|
+
| `-f, --force` | overwrite existing output files |
|
|
115
|
+
| `--no-subs` | do not mux subtitles |
|
|
116
|
+
| `--no-fonts` | do not attach subtitle fonts |
|
|
117
|
+
| `--no-chapters` | do not write chapter markers |
|
|
118
|
+
| `-y, --yes` | accept automatic choices / skip confirmation |
|
|
119
|
+
| `--quiet` | hide progress and status output |
|
|
120
|
+
|
|
121
|
+
Files are written as:
|
|
122
|
+
|
|
123
|
+
```text
|
|
124
|
+
<output>/<Anime Title>/<Anime Title> - E01 [1080p] [Sub].mkv
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Video/audio segments, subtitles, and fonts are kept in memory. PyAV muxes
|
|
128
|
+
directly from those in-memory streams into the final MKV, so no temporary media
|
|
129
|
+
files are written.
|
|
130
|
+
|
|
131
|
+
## Disclaimer
|
|
132
|
+
|
|
133
|
+
Downloading copyrighted material may be illegal where you live. Use this tool
|
|
134
|
+
only for content you are allowed to download. The project is not affiliated
|
|
135
|
+
with reanime.to, FlixCloud, or their CDNs.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "reanimdl"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "An asynchronous anime downloader CLI for reanime.to"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.14"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"aiohttp>=3.12",
|
|
9
|
+
"av>=19.0.1",
|
|
10
|
+
"json5>=0.12",
|
|
11
|
+
"m3u8>=6.0,<7",
|
|
12
|
+
"platformdirs>=4.13.5",
|
|
13
|
+
"pycryptodome>=3.21",
|
|
14
|
+
"pywasm>=2.2,<3",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[project.scripts]
|
|
18
|
+
reanimdl = "reanimdl.cli:run"
|
|
19
|
+
|
|
20
|
+
[build-system]
|
|
21
|
+
requires = ["uv_build>=0.13.1,<0.14.0"]
|
|
22
|
+
build-backend = "uv_build"
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "reanimdl"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "An asynchronous anime downloader CLI for reanime.to"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.14"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"aiohttp>=3.12",
|
|
9
|
+
"av>=19.0.1",
|
|
10
|
+
"json5>=0.12",
|
|
11
|
+
"m3u8>=6.0,<7",
|
|
12
|
+
"platformdirs>=4.13.5",
|
|
13
|
+
"pycryptodome>=3.21",
|
|
14
|
+
"pywasm>=2.2,<3",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[project.scripts]
|
|
18
|
+
reanimdl = "reanimdl.cli:run"
|
|
19
|
+
|
|
20
|
+
[build-system]
|
|
21
|
+
requires = ["uv_build>=0.13.1,<0.14.0"]
|
|
22
|
+
build-backend = "uv_build"
|
|
23
|
+
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Asynchronous client for the reanime.to public API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import aiohttp
|
|
10
|
+
|
|
11
|
+
from .utils import AnimdlError, _Retryable, retry_async
|
|
12
|
+
|
|
13
|
+
REANIME_BASE = "https://reanime.to"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ApiError(AnimdlError):
|
|
17
|
+
def __init__(self, status: int, detail: str) -> None:
|
|
18
|
+
self.status = status
|
|
19
|
+
self.detail = detail
|
|
20
|
+
super().__init__(f"reanime.to API error ({status}): {detail}")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class AnimeNotFound(ApiError):
|
|
24
|
+
def __init__(self, slug: str) -> None:
|
|
25
|
+
super().__init__(404, f"anime not found: {slug}")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class Server:
|
|
30
|
+
name: str
|
|
31
|
+
data_type: str
|
|
32
|
+
url: str
|
|
33
|
+
softsub: bool = False
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class ServerSet:
|
|
38
|
+
sub: list[Server] = field(default_factory=list)
|
|
39
|
+
dub: list[Server] = field(default_factory=list)
|
|
40
|
+
anilist_id: int | None = None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _anilist_from_anime(anime: dict | None) -> int | None:
|
|
44
|
+
if not anime:
|
|
45
|
+
return None
|
|
46
|
+
for key in ("anilist_id", "anilist"):
|
|
47
|
+
value = anime.get(key)
|
|
48
|
+
if value:
|
|
49
|
+
try:
|
|
50
|
+
return int(value)
|
|
51
|
+
except (TypeError, ValueError):
|
|
52
|
+
pass
|
|
53
|
+
cover = anime.get("cover_image") or {}
|
|
54
|
+
for key in ("extra_large", "large", "medium"):
|
|
55
|
+
match = re.search(r"/bx(\d+)-", cover.get(key, "") or "")
|
|
56
|
+
if match:
|
|
57
|
+
return int(match.group(1))
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class ReAnimeClient:
|
|
62
|
+
def __init__(
|
|
63
|
+
self, session: aiohttp.ClientSession, base: str = REANIME_BASE
|
|
64
|
+
) -> None:
|
|
65
|
+
self.session = session
|
|
66
|
+
self.base = base.rstrip("/")
|
|
67
|
+
|
|
68
|
+
async def _get_json(
|
|
69
|
+
self,
|
|
70
|
+
path: str,
|
|
71
|
+
params: dict[str, Any] | None = None,
|
|
72
|
+
*,
|
|
73
|
+
attempts: int = 4,
|
|
74
|
+
) -> Any:
|
|
75
|
+
url = f"{self.base}{path}"
|
|
76
|
+
|
|
77
|
+
async def attempt() -> Any:
|
|
78
|
+
async with self.session.get(
|
|
79
|
+
url,
|
|
80
|
+
params=params,
|
|
81
|
+
headers={"Accept": "application/json"},
|
|
82
|
+
) as response:
|
|
83
|
+
body = await response.text()
|
|
84
|
+
if response.status == 404:
|
|
85
|
+
raise AnimeNotFound(path.rsplit("/", 1)[-1])
|
|
86
|
+
if response.status in {429, 500, 502, 503, 504}:
|
|
87
|
+
raise _Retryable(ApiError(response.status, body[:200]))
|
|
88
|
+
if not response.ok:
|
|
89
|
+
raise ApiError(response.status, body[:300])
|
|
90
|
+
try:
|
|
91
|
+
return _json_loads(body)
|
|
92
|
+
except ValueError as exc:
|
|
93
|
+
raise ApiError(
|
|
94
|
+
response.status, "invalid JSON response"
|
|
95
|
+
) from exc
|
|
96
|
+
|
|
97
|
+
return await retry_async(
|
|
98
|
+
attempt,
|
|
99
|
+
attempts=attempts,
|
|
100
|
+
final=lambda error: error
|
|
101
|
+
if isinstance(error, ApiError)
|
|
102
|
+
else AnimdlError(f"request to reanime.to failed: {error}"),
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
async def search(self, query: str, *, limit: int = 20, offset: int = 0) -> dict:
|
|
106
|
+
query = query.strip()
|
|
107
|
+
if not query:
|
|
108
|
+
raise AnimdlError("search query must not be empty")
|
|
109
|
+
data = await self._get_json(
|
|
110
|
+
"/api/v1/search",
|
|
111
|
+
{"q": query, "limit": max(1, min(limit, 100)), "offset": max(offset, 0)},
|
|
112
|
+
)
|
|
113
|
+
if not isinstance(data, dict):
|
|
114
|
+
raise AnimdlError("unexpected search response")
|
|
115
|
+
results = data.get("results") or []
|
|
116
|
+
if not isinstance(results, list):
|
|
117
|
+
results = []
|
|
118
|
+
data["results"] = results
|
|
119
|
+
return data
|
|
120
|
+
|
|
121
|
+
async def anime(self, slug: str) -> dict:
|
|
122
|
+
data = await self._get_json(f"/api/v1/anime/{slug}")
|
|
123
|
+
if not isinstance(data, dict):
|
|
124
|
+
raise AnimdlError("unexpected anime response")
|
|
125
|
+
return data
|
|
126
|
+
|
|
127
|
+
async def episodes(self, slug: str) -> list[dict]:
|
|
128
|
+
data = await self._get_json(
|
|
129
|
+
f"/api/v1/anime/{slug}/episodes",
|
|
130
|
+
{"limit": 2000},
|
|
131
|
+
)
|
|
132
|
+
if isinstance(data, list):
|
|
133
|
+
episodes = data
|
|
134
|
+
elif isinstance(data, dict):
|
|
135
|
+
episodes = data.get("data") or data.get("episodes") or []
|
|
136
|
+
else:
|
|
137
|
+
episodes = []
|
|
138
|
+
|
|
139
|
+
cleaned: list[dict] = []
|
|
140
|
+
for episode in episodes:
|
|
141
|
+
if not isinstance(episode, dict):
|
|
142
|
+
continue
|
|
143
|
+
try:
|
|
144
|
+
number = int(episode.get("episode_number"))
|
|
145
|
+
except (TypeError, ValueError):
|
|
146
|
+
continue
|
|
147
|
+
episode["episode_number"] = number
|
|
148
|
+
cleaned.append(episode)
|
|
149
|
+
return sorted(cleaned, key=lambda item: item["episode_number"])
|
|
150
|
+
|
|
151
|
+
async def servers(
|
|
152
|
+
self,
|
|
153
|
+
slug: str,
|
|
154
|
+
episode: int,
|
|
155
|
+
*,
|
|
156
|
+
anilist_id: int | None = None,
|
|
157
|
+
anime: dict | None = None,
|
|
158
|
+
) -> ServerSet:
|
|
159
|
+
if episode < 1:
|
|
160
|
+
raise AnimdlError("episode numbers start at 1")
|
|
161
|
+
|
|
162
|
+
aid = anilist_id or _anilist_from_anime(anime)
|
|
163
|
+
if aid is None:
|
|
164
|
+
anime_data = await self.anime(slug)
|
|
165
|
+
aid = _anilist_from_anime(anime_data)
|
|
166
|
+
if aid is None:
|
|
167
|
+
raise AnimdlError(
|
|
168
|
+
"reanime.to did not provide an AniList ID for this anime, "
|
|
169
|
+
"so its streaming servers cannot be resolved"
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
data = await self._get_json(f"/api/flix/{aid}/{episode}")
|
|
173
|
+
if not isinstance(data, dict) or not data.get("success"):
|
|
174
|
+
raise AnimdlError("the streaming server did not return any playable links")
|
|
175
|
+
|
|
176
|
+
links = data.get("servers") or []
|
|
177
|
+
order = {"HD-1": 0, "HD-2": 1}
|
|
178
|
+
|
|
179
|
+
def build(items: list[dict]) -> list[Server]:
|
|
180
|
+
servers: list[Server] = []
|
|
181
|
+
for item in items:
|
|
182
|
+
url = item.get("dataLink")
|
|
183
|
+
if not url:
|
|
184
|
+
continue
|
|
185
|
+
servers.append(
|
|
186
|
+
Server(
|
|
187
|
+
name=str(item.get("serverName") or "HD-1"),
|
|
188
|
+
data_type=str(item.get("dataType") or ""),
|
|
189
|
+
url=str(url),
|
|
190
|
+
softsub=bool(item.get("softsub")),
|
|
191
|
+
)
|
|
192
|
+
)
|
|
193
|
+
return sorted(
|
|
194
|
+
servers, key=lambda server: (order.get(server.name, 9), server.name)
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
return ServerSet(
|
|
198
|
+
sub=build(
|
|
199
|
+
[
|
|
200
|
+
item
|
|
201
|
+
for item in links
|
|
202
|
+
if isinstance(item, dict)
|
|
203
|
+
and item.get("dataType") in {"sub", "s-sub"}
|
|
204
|
+
]
|
|
205
|
+
),
|
|
206
|
+
dub=build(
|
|
207
|
+
[
|
|
208
|
+
item
|
|
209
|
+
for item in links
|
|
210
|
+
if isinstance(item, dict)
|
|
211
|
+
and item.get("dataType") in {"dub", "s-dub"}
|
|
212
|
+
]
|
|
213
|
+
),
|
|
214
|
+
anilist_id=aid,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _json_loads(body: str) -> Any:
|
|
219
|
+
import json
|
|
220
|
+
|
|
221
|
+
return json.loads(body)
|