donghua-cli 3.1.0__py3-none-any.whl

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,3 @@
1
+ """Donghua CLI - Wuxia-themed terminal client for streaming Chinese animation."""
2
+
3
+ __version__ = "3.1.0"
@@ -0,0 +1,5 @@
1
+ """Allow running as `python -m donghua_cli`."""
2
+
3
+ from donghua_cli.cli import main
4
+
5
+ main()
donghua_cli/app.py ADDED
@@ -0,0 +1,325 @@
1
+ """Main application logic.
2
+
3
+ Wires together sources, extractor (with server fallback), player, cache, and UI.
4
+ The user never picks a source -- search is global, sources become servers.
5
+ """
6
+
7
+ import threading
8
+ import time
9
+ from typing import List
10
+
11
+ from donghua_cli import config, theme, ui
12
+ from donghua_cli.cache import Preloader, StreamCache
13
+ from donghua_cli.extractor import extract_with_fallback
14
+ from donghua_cli.player import Downloader, Player
15
+ from donghua_cli.scraper import get_episodes, search_all, _is_movie
16
+ from donghua_cli.sources import ALL_SOURCES, get_source
17
+ from donghua_cli.sources.base import Episode, Series
18
+
19
+ console = theme.console
20
+
21
+
22
+ class DonghuaCLI:
23
+ def __init__(self, quality: str | None = None):
24
+ config.ensure_dirs()
25
+ self._cache = StreamCache()
26
+ self._preloader = Preloader(self._cache)
27
+ self._player: Player | None = None
28
+ self._quality = quality or config.get_quality()
29
+
30
+ # ── public entry points ──────────────────────────────────────────────
31
+
32
+ def run_interactive(self) -> None:
33
+ """Fully interactive loop: search -> select -> play/download -> repeat."""
34
+ try:
35
+ while True:
36
+ ui.show_banner()
37
+
38
+ query = ui.get_search_query()
39
+ if not query:
40
+ theme.status("warning", "Please enter a search term")
41
+ time.sleep(1)
42
+ continue
43
+
44
+ series_list = self._search(query)
45
+ if not series_list:
46
+ console.print(theme.tip_box("No Results", "Try a different search term", "gold"))
47
+ time.sleep(3)
48
+ continue
49
+
50
+ series = self._pick_series(series_list)
51
+ episodes = self._get_episodes(series)
52
+ if not episodes:
53
+ console.print(theme.tip_box("No Episodes", "This series has no episodes yet", "gold"))
54
+ time.sleep(3)
55
+ continue
56
+
57
+ selected = ui.select_episodes_from_list(episodes)
58
+ method = ui.choose_method()
59
+
60
+ if method == "download":
61
+ self._download_episodes(selected, series.title)
62
+ else:
63
+ self._play_episodes(selected, series.title)
64
+
65
+ if not ui.ask_continue():
66
+ theme.farewell()
67
+ break
68
+
69
+ except KeyboardInterrupt:
70
+ self._cleanup()
71
+ theme.divider()
72
+ theme.status("info", "Cultivation session interrupted")
73
+ theme.status("success", "May your journey be eternal")
74
+
75
+ def run_direct(self, query: str, download: bool = False) -> None:
76
+ """CLI mode with arguments."""
77
+ ui.show_banner()
78
+
79
+ series_list = self._search(query)
80
+ if not series_list:
81
+ console.print(theme.tip_box("No Results", "Try a different search term", "gold"))
82
+ return
83
+
84
+ series = self._pick_series(series_list)
85
+ episodes = self._get_episodes(series)
86
+ if not episodes:
87
+ console.print(theme.tip_box("No Episodes", "This series has no episodes yet", "gold"))
88
+ return
89
+
90
+ selected = ui.select_episodes_from_list(episodes)
91
+
92
+ if download:
93
+ self._download_episodes(selected, series.title)
94
+ else:
95
+ self._play_episodes(selected, series.title)
96
+
97
+ def clear_cache(self) -> None:
98
+ ui.show_banner()
99
+ theme.status("loading", "Clearing stream cache...")
100
+ if self._cache.clear():
101
+ theme.status("success", "Cache cleared")
102
+ else:
103
+ theme.status("info", "No cache to clear")
104
+
105
+ def show_features(self) -> None:
106
+ ui.show_banner()
107
+ theme.section_header("Core Abilities", "Features & Capabilities", "Everything you need to stream Donghua")
108
+ theme.feature_cards()
109
+ console.print()
110
+ source_names = ", ".join(s.name for s in ALL_SOURCES)
111
+ console.print(f" [steel]Active servers:[/] [gold]{source_names}[/]")
112
+ console.print()
113
+ console.print(theme.tip_box("Quick Start", "Run 'dhua' for interactive mode or 'dhua --help' for all options"))
114
+ console.print()
115
+
116
+ # ── internal ─────────────────────────────────────────────────────────
117
+
118
+ def _search(self, query: str) -> List[Series]:
119
+ theme.divider()
120
+ from donghua_cli.scraper import _SEARCH_SKIP
121
+ search_sources = [s for s in ALL_SOURCES if s.key not in _SEARCH_SKIP]
122
+ source_names = " + ".join(s.name for s in search_sources)
123
+
124
+ from rich.live import Live
125
+ from rich.spinner import Spinner
126
+
127
+ with Live(
128
+ Spinner("dots", text=f"[gold]Searching '{query}' across {source_names}...[/]"),
129
+ console=console,
130
+ transient=True,
131
+ ):
132
+ results = search_all(query)
133
+
134
+ if results:
135
+ theme.status("success", f"Found {len(results)} result(s)")
136
+ # Show how many sources each result is on
137
+ multi = sum(1 for s in results if len(s.sources) > 1)
138
+ if multi:
139
+ theme.status("info", f"{multi} available on multiple servers")
140
+ else:
141
+ theme.status("error", "No results found")
142
+ return results
143
+
144
+ def _pick_series(self, series_list: List[Series]) -> Series:
145
+ """Let user pick a series from unified results."""
146
+ # Build display list with server badges and movie/series type tags
147
+ display_items: list[tuple[str, str]] = []
148
+ for s in series_list:
149
+ type_tag = "[MOVIE]" if _is_movie(s.title) else "[SERIES]"
150
+ badges = " ".join(f"[{k.upper()}]" for k in s.sources)
151
+ display_items.append((s.title, f"{type_tag} {badges}"))
152
+
153
+ idx = ui.select_from_list_with_badges(display_items, "CULTIVATION MANUALS")
154
+ return series_list[idx]
155
+
156
+ def _get_episodes(self, series: Series) -> List[Episode]:
157
+ theme.divider()
158
+
159
+ from rich.live import Live
160
+ from rich.spinner import Spinner
161
+
162
+ with Live(
163
+ Spinner("dots", text="[gold]Fetching episodes from all servers...[/]"),
164
+ console=console,
165
+ transient=True,
166
+ ):
167
+ episodes = get_episodes(series)
168
+
169
+ if episodes:
170
+ multi = sum(1 for e in episodes if len(e.sources) > 1)
171
+ theme.status("success", f"Found {len(episodes)} episode(s)")
172
+ if multi:
173
+ theme.status("info", f"{multi} episodes have multiple servers (auto-fallback enabled)")
174
+ else:
175
+ theme.status("warning", "No episodes found")
176
+ return episodes
177
+
178
+ def _play_episodes(self, episodes: List[Episode], series_title: str) -> None:
179
+ self._player = Player(self._quality)
180
+ theme.divider()
181
+ theme.status("loading", "Preparing playback...")
182
+
183
+ idx = 0
184
+ while idx < len(episodes):
185
+ ep = episodes[idx]
186
+
187
+ ui.clear_screen()
188
+ ui.show_banner()
189
+
190
+ # Resolve stream with automatic server fallback
191
+ from rich.live import Live
192
+ from rich.spinner import Spinner
193
+
194
+ with Live(
195
+ Spinner("dots", text="[gold]Resolving stream...[/]"),
196
+ console=console,
197
+ transient=True,
198
+ ):
199
+ stream_url, source_key = self._preloader.get_stream(ep, extract_with_fallback)
200
+ source = get_source(source_key)
201
+ server_name = source.name if source else source_key.upper()
202
+
203
+ # Start preloading next episodes
204
+ self._preloader.preload(episodes, idx, extract_with_fallback)
205
+
206
+ ui.show_playback(ep.title, idx + 1, len(episodes), server_name, ep.sources)
207
+
208
+ if not self._player.play(stream_url, title=f"{series_title} - {ep.title}"):
209
+ theme.status("error", "No player found -- install mpv or vlc")
210
+ console.print(f" [steel]Stream URL:[/] {stream_url}")
211
+ return
212
+
213
+ theme.status("success", f"Player launched! Server: {server_name}")
214
+
215
+ # Monitor player in background
216
+ finished = threading.Event()
217
+
218
+ def _monitor():
219
+ while self._player and self._player.is_playing():
220
+ time.sleep(0.5)
221
+ finished.set()
222
+ theme.status("success", "Episode finished! Press Enter or type a command.")
223
+
224
+ threading.Thread(target=_monitor, daemon=True).start()
225
+
226
+ # Command loop
227
+ action = None
228
+ while action is None:
229
+ try:
230
+ cmd = console.input(theme.prompt_text("Command [N/P/S/R/D/Q]")).strip().lower()
231
+ except KeyboardInterrupt:
232
+ self._player.stop()
233
+ theme.divider()
234
+ theme.status("info", "Session ended")
235
+ return
236
+
237
+ if cmd in ("n", ""):
238
+ if idx < len(episodes) - 1:
239
+ action = "next"
240
+ elif finished.is_set():
241
+ action = "done"
242
+ else:
243
+ theme.status("warning", "Already at last episode")
244
+ elif cmd == "p":
245
+ if idx > 0:
246
+ action = "prev"
247
+ else:
248
+ theme.status("warning", "Already at first episode")
249
+ elif cmd == "r":
250
+ action = "replay"
251
+ elif cmd == "s":
252
+ try:
253
+ n = int(console.input(theme.prompt_text(f"Skip to [1-{len(episodes)}]")).strip())
254
+ if 1 <= n <= len(episodes):
255
+ action = ("skip", n - 1)
256
+ else:
257
+ theme.status("error", f"Out of range (1-{len(episodes)})")
258
+ except (ValueError, KeyboardInterrupt):
259
+ pass
260
+ elif cmd == "d":
261
+ theme.status("loading", "Downloading...")
262
+ Downloader.download(stream_url, series_title, ep.title, self._quality)
263
+ elif cmd == "q":
264
+ action = "quit"
265
+ else:
266
+ console.print(" [steel]Commands:[/] [light.gold]\\[N]ext \\[P]rev \\[S]kip \\[R]eplay \\[D]ownload \\[Q]uit[/]")
267
+
268
+ self._player.stop()
269
+
270
+ old_idx = idx
271
+ if action == "next":
272
+ idx += 1
273
+ elif action == "prev":
274
+ idx -= 1
275
+ elif action == "replay":
276
+ pass
277
+ elif action == "quit":
278
+ break
279
+ elif action == "done":
280
+ idx += 1
281
+ elif isinstance(action, tuple) and action[0] == "skip":
282
+ idx = action[1]
283
+
284
+ if action != "quit":
285
+ self._preloader.record_navigation(old_idx, idx)
286
+
287
+ theme.divider()
288
+ theme.status("success", "All techniques mastered!")
289
+
290
+ def _download_episodes(self, episodes: List[Episode], series_title: str) -> None:
291
+ theme.divider()
292
+ theme.section_header(
293
+ "Archive Mode",
294
+ "Downloading Episodes",
295
+ f"Saving {len(episodes)} episode(s) to {config.get_download_dir()}",
296
+ )
297
+
298
+ ok = 0
299
+ fail = 0
300
+
301
+ for i, ep in enumerate(episodes, 1):
302
+ display_num = ep.number if ep.number < 999999 else i
303
+ console.print(f"\n [gold]\\[{i:03d}/{len(episodes):03d}][/] Episode {display_num:03d} -- [white]{ep.title[:55]}[/]")
304
+
305
+ stream_url, source_key = self._preloader.get_stream(ep, extract_with_fallback)
306
+ source = get_source(source_key)
307
+ theme.status("loading", f"Downloading from {source.name if source else source_key}...")
308
+
309
+ if Downloader.download(stream_url, series_title, ep.title, self._quality):
310
+ theme.status("success", "Done")
311
+ ok += 1
312
+ else:
313
+ theme.status("error", "Failed")
314
+ fail += 1
315
+
316
+ theme.divider()
317
+ theme.status("success", f"{ok}/{len(episodes)} episodes downloaded")
318
+ if fail:
319
+ theme.status("error", f"{fail} failed")
320
+ theme.status("info", f"Saved to: {config.get_download_dir()}")
321
+
322
+ def _cleanup(self) -> None:
323
+ self._preloader.stop()
324
+ if self._player:
325
+ self._player.stop()
Binary file
donghua_cli/cache.py ADDED
@@ -0,0 +1,167 @@
1
+ """LRU stream cache and adaptive background preloader.
2
+
3
+ The cache persists stream URLs to disk so repeat plays are instant.
4
+ The preloader tracks navigation patterns and adapts:
5
+ - Sequential watching: preload 3-5 episodes ahead
6
+ - Random jumping: reduce to 1-2 or stop entirely
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import threading
14
+ from collections import OrderedDict
15
+ from typing import TYPE_CHECKING, List, Optional
16
+
17
+ from donghua_cli import config
18
+
19
+ if TYPE_CHECKING:
20
+ from donghua_cli.sources.base import Episode
21
+
22
+
23
+ class StreamCache:
24
+ """Persistent LRU cache for resolved stream URLs."""
25
+
26
+ def __init__(self, max_size: int = 100):
27
+ self.max_size = max_size
28
+ self._cache: OrderedDict[str, tuple[str, str]] = OrderedDict() # ep_key -> (stream_url, source_key)
29
+ self._load()
30
+
31
+ def _key(self, episode: Episode) -> str:
32
+ """Cache key from episode's primary URL."""
33
+ return episode.primary_url
34
+
35
+ def get(self, key: str) -> Optional[tuple[str, str]]:
36
+ """Get cached (stream_url, source_key) or None."""
37
+ if key in self._cache:
38
+ self._cache.move_to_end(key)
39
+ return self._cache[key]
40
+ return None
41
+
42
+ def put(self, key: str, stream_url: str, source_key: str) -> None:
43
+ if key in self._cache:
44
+ self._cache.move_to_end(key)
45
+ else:
46
+ if len(self._cache) >= self.max_size:
47
+ self._cache.popitem(last=False)
48
+ self._cache[key] = (stream_url, source_key)
49
+ self._save()
50
+
51
+ def clear(self) -> bool:
52
+ self._cache.clear()
53
+ try:
54
+ if os.path.exists(config.STREAM_CACHE_FILE):
55
+ os.remove(config.STREAM_CACHE_FILE)
56
+ return True
57
+ except OSError:
58
+ pass
59
+ return False
60
+
61
+ def _save(self) -> None:
62
+ try:
63
+ os.makedirs(config.CACHE_DIR, exist_ok=True)
64
+ # Serialize as list of [key, [stream_url, source_key]]
65
+ data = [[k, list(v)] for k, v in self._cache.items()]
66
+ with open(config.STREAM_CACHE_FILE, "w") as f:
67
+ json.dump(data, f)
68
+ except OSError:
69
+ pass
70
+
71
+ def _load(self) -> None:
72
+ try:
73
+ if os.path.exists(config.STREAM_CACHE_FILE):
74
+ with open(config.STREAM_CACHE_FILE, "r") as f:
75
+ items = json.load(f)
76
+ self._cache = OrderedDict()
77
+ for item in items[-self.max_size:]:
78
+ key = item[0]
79
+ val = item[1]
80
+ # Handle old format (string) and new format ([url, source])
81
+ if isinstance(val, str):
82
+ self._cache[key] = (val, "unknown")
83
+ elif isinstance(val, list) and len(val) == 2:
84
+ self._cache[key] = (val[0], val[1])
85
+ except (OSError, json.JSONDecodeError, (IndexError, KeyError)):
86
+ self._cache = OrderedDict()
87
+
88
+
89
+ class Preloader:
90
+ """Adaptive background preloader that adjusts lookahead based on watch pattern."""
91
+
92
+ MIN_LOOKAHEAD = 1
93
+ MAX_LOOKAHEAD = 5
94
+ HISTORY_SIZE = 6
95
+
96
+ def __init__(self, cache: StreamCache):
97
+ self.cache = cache
98
+ self._thread: Optional[threading.Thread] = None
99
+ self._stop = threading.Event()
100
+ self._nav_history: list[int] = []
101
+ self._lookahead = 2
102
+
103
+ def record_navigation(self, prev_idx: int, new_idx: int) -> None:
104
+ """Record a navigation action to adapt preloading depth."""
105
+ delta = new_idx - prev_idx
106
+ self._nav_history.append(delta)
107
+ if len(self._nav_history) > self.HISTORY_SIZE:
108
+ self._nav_history = self._nav_history[-self.HISTORY_SIZE:]
109
+
110
+ if len(self._nav_history) >= 3:
111
+ sequential = sum(1 for d in self._nav_history if d == 1)
112
+ ratio = sequential / len(self._nav_history)
113
+
114
+ if ratio >= 0.8:
115
+ self._lookahead = min(self._lookahead + 1, self.MAX_LOOKAHEAD)
116
+ elif ratio >= 0.5:
117
+ self._lookahead = max(2, self._lookahead)
118
+ else:
119
+ self._lookahead = max(self.MIN_LOOKAHEAD, self._lookahead - 1)
120
+
121
+ @property
122
+ def lookahead(self) -> int:
123
+ return self._lookahead
124
+
125
+ def preload(self, episodes: List[Episode], current_idx: int, extract_fn) -> None:
126
+ """Start preloading upcoming episodes in a daemon thread."""
127
+ self.stop()
128
+ self._stop.clear()
129
+
130
+ end = min(current_idx + 1 + self._lookahead, len(episodes))
131
+ upcoming = episodes[current_idx + 1 : end]
132
+ if not upcoming:
133
+ return
134
+
135
+ def _worker():
136
+ for ep in upcoming:
137
+ if self._stop.is_set():
138
+ break
139
+ key = ep.primary_url
140
+ if self.cache.get(key):
141
+ continue
142
+ try:
143
+ stream_url, source_key = extract_fn(ep)
144
+ if stream_url and stream_url != ep.primary_url:
145
+ self.cache.put(key, stream_url, source_key)
146
+ except Exception:
147
+ pass
148
+
149
+ self._thread = threading.Thread(target=_worker, daemon=True)
150
+ self._thread.start()
151
+
152
+ def get_stream(self, episode: Episode, extract_fn) -> tuple[str, str]:
153
+ """Return (stream_url, source_key) -- cached or freshly extracted."""
154
+ key = episode.primary_url
155
+ cached = self.cache.get(key)
156
+ if cached:
157
+ return cached
158
+ stream_url, source_key = extract_fn(episode)
159
+ if stream_url:
160
+ self.cache.put(key, stream_url, source_key)
161
+ return stream_url, source_key
162
+
163
+ def stop(self) -> None:
164
+ self._stop.set()
165
+ if self._thread and self._thread.is_alive():
166
+ self._thread.join(timeout=1)
167
+ self._thread = None
donghua_cli/cli.py ADDED
@@ -0,0 +1,131 @@
1
+ """CLI entry point -- argument parsing and dispatch."""
2
+
3
+ import logging
4
+ import sys
5
+
6
+ from donghua_cli import __version__, config
7
+
8
+
9
+ def _setup_logging(log_file: str | None = None, verbose: bool = False) -> None:
10
+ """Configure the donghua logger.
11
+
12
+ --logs: writes to ~/.cache/donghua/donghua.log and tails it
13
+ --verbose: prints debug output to stderr
14
+ """
15
+ logger = logging.getLogger("donghua")
16
+ logger.setLevel(logging.DEBUG)
17
+ fmt = logging.Formatter("%(asctime)s [%(levelname)s] %(message)s", datefmt="%H:%M:%S")
18
+
19
+ if log_file:
20
+ import os
21
+ os.makedirs(os.path.dirname(log_file), exist_ok=True)
22
+ fh = logging.FileHandler(log_file, mode="w")
23
+ fh.setLevel(logging.DEBUG)
24
+ fh.setFormatter(fmt)
25
+ logger.addHandler(fh)
26
+
27
+ if verbose:
28
+ sh = logging.StreamHandler(sys.stderr)
29
+ sh.setLevel(logging.DEBUG)
30
+ sh.setFormatter(fmt)
31
+ logger.addHandler(sh)
32
+
33
+
34
+ def main() -> None:
35
+ if config.PLATFORM == "windows":
36
+ try:
37
+ import ctypes
38
+ kernel32 = ctypes.windll.kernel32
39
+ kernel32.SetConsoleMode(kernel32.GetStdHandle(-11), 7)
40
+ except Exception:
41
+ pass
42
+
43
+ import argparse
44
+
45
+ parser = argparse.ArgumentParser(
46
+ prog="donghua",
47
+ description="Donghua CLI -- Wuxia-themed terminal streaming client",
48
+ formatter_class=argparse.RawDescriptionHelpFormatter,
49
+ epilog="""\
50
+ Examples:
51
+ donghua Interactive TUI mode
52
+ donghua "soul land" Search and stream
53
+ donghua "btth" -q 1080 Stream at 1080p
54
+ donghua "martial peak" -d Download mode
55
+ donghua --classic Classic Rich output mode
56
+ donghua --logs Show live debug log in a second terminal
57
+ dhua Interactive TUI (alias)
58
+ """,
59
+ )
60
+
61
+ parser.add_argument("query", nargs="?", help="Series to search for")
62
+ parser.add_argument("-q", "--quality", default=None, help=f"Video quality (default: {config.get_quality()})")
63
+ parser.add_argument("-d", "--download", action="store_true", help="Download instead of stream")
64
+ parser.add_argument("--classic", action="store_true", help="Use classic Rich output instead of TUI")
65
+ parser.add_argument("--logs", action="store_true", help="Write debug log to file and show path")
66
+ parser.add_argument("--verbose", action="store_true", help="Print debug output to stderr")
67
+ parser.add_argument("--clear-cache", action="store_true", help="Clear the stream cache")
68
+ parser.add_argument("--features", action="store_true", help="Show features and capabilities")
69
+ parser.add_argument("-V", "--version", action="version", version=f"%(prog)s {__version__}")
70
+
71
+ args = parser.parse_args()
72
+
73
+ # Set up logging
74
+ import os
75
+ log_file = None
76
+ if args.logs:
77
+ log_file = os.path.join(config.CACHE_DIR, "donghua.log")
78
+ _setup_logging(log_file=log_file, verbose=False)
79
+ print(f"Logging to: {log_file}")
80
+ print(f"Watch live: tail -f {log_file}")
81
+ print()
82
+ elif args.verbose:
83
+ _setup_logging(verbose=True)
84
+ else:
85
+ # Silence logs by default
86
+ logging.getLogger("donghua").addHandler(logging.NullHandler())
87
+
88
+ from donghua_cli.app import DonghuaCLI
89
+ app_core = DonghuaCLI(quality=args.quality)
90
+
91
+ if args.features:
92
+ app_core.show_features()
93
+ return
94
+
95
+ if args.clear_cache:
96
+ app_core.clear_cache()
97
+ if not args.query:
98
+ return
99
+
100
+ # Classic mode or direct query: use Rich console output
101
+ if args.classic or args.query or args.download:
102
+ try:
103
+ if args.query:
104
+ app_core.run_direct(args.query, download=args.download)
105
+ else:
106
+ app_core.run_interactive()
107
+ except KeyboardInterrupt:
108
+ from donghua_cli import theme
109
+ theme.divider()
110
+ theme.status("info", "Farewell, cultivator!")
111
+ sys.exit(0)
112
+ except Exception as e:
113
+ from donghua_cli import theme
114
+ theme.status("error", f"Fatal: {e}")
115
+ sys.exit(1)
116
+ return
117
+
118
+ # Default: Textual TUI mode
119
+ try:
120
+ from donghua_cli.tui import DonghuaTUI
121
+ tui = DonghuaTUI(app_core)
122
+ tui.run()
123
+ except ImportError:
124
+ try:
125
+ app_core.run_interactive()
126
+ except KeyboardInterrupt:
127
+ sys.exit(0)
128
+ except Exception as e:
129
+ from donghua_cli import theme
130
+ theme.status("error", f"TUI error: {e}")
131
+ sys.exit(1)