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.
- donghua_cli/__init__.py +3 -0
- donghua_cli/__main__.py +5 -0
- donghua_cli/app.py +325 -0
- donghua_cli/banner_frames.json.gz +0 -0
- donghua_cli/cache.py +167 -0
- donghua_cli/cli.py +131 -0
- donghua_cli/config.py +139 -0
- donghua_cli/extractor.py +124 -0
- donghua_cli/player.py +161 -0
- donghua_cli/scraper.py +209 -0
- donghua_cli/sources/__init__.py +33 -0
- donghua_cli/sources/animexin.py +95 -0
- donghua_cli/sources/base.py +63 -0
- donghua_cli/sources/hdonghua.py +85 -0
- donghua_cli/sources/lmanime.py +95 -0
- donghua_cli/sources/luciferdonghua.py +78 -0
- donghua_cli/sources/misterdonghua.py +86 -0
- donghua_cli/theme.py +415 -0
- donghua_cli/tui.py +780 -0
- donghua_cli/ui.py +220 -0
- donghua_cli/utils.py +125 -0
- donghua_cli-3.1.0.dist-info/METADATA +222 -0
- donghua_cli-3.1.0.dist-info/RECORD +26 -0
- donghua_cli-3.1.0.dist-info/WHEEL +4 -0
- donghua_cli-3.1.0.dist-info/entry_points.txt +3 -0
- donghua_cli-3.1.0.dist-info/licenses/LICENSE +21 -0
donghua_cli/__init__.py
ADDED
donghua_cli/__main__.py
ADDED
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)
|