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.
@@ -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.
@@ -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,9 @@
1
+ """reanimdl - an asynchronous anime downloader for reanime.to."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+
6
+ def main() -> None:
7
+ from .cli import main as cli_main
8
+
9
+ cli_main()
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -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)