indonime 1.0.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.
indonime/__init__.py ADDED
@@ -0,0 +1,369 @@
1
+ """Indonime TUI — search → select → play loop."""
2
+ import argparse
3
+ import importlib
4
+ import os
5
+ import pkgutil
6
+ import time
7
+
8
+ from . import player
9
+ from .ui import (
10
+ console, print_banner, print_header, print_step,
11
+ print_success, print_error, print_warning, print_info, print_separator,
12
+ make_episode_table, make_postplay_actions, make_footer,
13
+ make_progress_bar, styled_status, make_style, Palette,
14
+ )
15
+
16
+ from . import plugins
17
+ import requests
18
+ from InquirerPy import inquirer
19
+ from .ext import pdrain, megaNZ
20
+
21
+ _SESSION = requests.Session()
22
+
23
+
24
+ def _play_episode(episode_url, plugin, custom_style):
25
+ """Resolve stream and play. Returns True if played successfully."""
26
+ with console.status(styled_status("🔍 Resolving stream...")):
27
+ dl_links = plugin.downloads(episode_url)
28
+
29
+ options = []
30
+ opt_map = {}
31
+ for res, servers in dl_links.items():
32
+ for s_name, s_url in servers.items():
33
+ if any(x in s_name.lower() for x in ['pdrain', 'pixeldrain', 'mega']):
34
+ label = f'[{res}] {s_name}'
35
+ options.append({'name': label, 'value': s_url})
36
+ opt_map[s_url] = label
37
+
38
+ if not options:
39
+ print_warning("No compatible servers found.")
40
+ return False
41
+
42
+ selected_opt = inquirer.select(
43
+ message='📥 Select quality & server:',
44
+ choices=options,
45
+ style=custom_style,
46
+ ).execute()
47
+ if not selected_opt:
48
+ return False
49
+ server_url = selected_opt
50
+ last_selected_server_name = opt_map[server_url]
51
+
52
+ final_target = None
53
+ is_temp = False
54
+
55
+ # ── Indeterminate pulse bar while resolving ──
56
+ if 'mega' in last_selected_server_name.lower():
57
+ final_mega_url = None
58
+ with make_progress_bar() as progress:
59
+ task = progress.add_task(
60
+ f"[{Palette.primary}]🔓 Resolving Mega link...",
61
+ total=None,
62
+ )
63
+
64
+ try:
65
+ resp = _SESSION.get(server_url, allow_redirects=True, timeout=15)
66
+ curr = resp.url
67
+ if "mega.nz" in curr and ("#" in curr or "#!" in curr):
68
+ final_mega_url = curr
69
+ else:
70
+ print_error("Redirect tidak mengarah ke Mega.")
71
+ return False
72
+ except Exception as e:
73
+ print_error(f"Requests Error: {e}")
74
+ time.sleep(3)
75
+ return False
76
+
77
+ if not final_mega_url:
78
+ print_error("Timeout: Gagal mendapatkan link Mega.")
79
+ return False
80
+
81
+ if "#!" in final_mega_url:
82
+ final_mega_url = final_mega_url.replace("#!", "file/").replace("!", "#", 1)
83
+
84
+ progress.update(task,
85
+ description=f"[{Palette.highlight}]📥 Buffering stream...")
86
+
87
+ # ponytail: sequential streaming (resolve_mega_file_stream) over parallel Range requests.
88
+ # parallel approach fails on some MEGA CDNs that don't support Range or silently drop
89
+ # connections. Streaming is one GET, no Range, more compatible.
90
+ try:
91
+ f_id = final_mega_url.split("file/")[1].split("#")[0]
92
+ stream = megaNZ.resolve_mega_file_stream(final_mega_url, f_id, console)
93
+ if stream is None:
94
+ return False
95
+ path, ready, stop, dl_thread = stream
96
+
97
+ _stall_t0 = time.time()
98
+ while not ready.is_set():
99
+ if time.time() - _stall_t0 > 60:
100
+ print_warning("Buffering timed out (>60s). Check connection or retry.")
101
+ return False
102
+ time.sleep(0.15)
103
+ except Exception as e:
104
+ print_error(f"Gagal Streaming: {e}")
105
+ time.sleep(3)
106
+ return False
107
+
108
+ # Buffering complete -> launch mpv
109
+ print_step("🚀 Launching mpv player...")
110
+ player.play_with_mpv(path, is_temp_file=True, cleanup=False)
111
+ stop.set()
112
+ dl_thread.join(timeout=10)
113
+ if os.path.exists(path):
114
+ os.remove(path)
115
+ return True
116
+
117
+ else:
118
+ # PixelDrain path (indeterminate pulse bar)
119
+ with make_progress_bar() as progress:
120
+ task = progress.add_task(
121
+ f"[{Palette.secondary}]🌀 Bypassing PixelDrain link...",
122
+ total=None,
123
+ )
124
+ import threading as _th
125
+ result_holder = [None]
126
+ def _do_scrape():
127
+ result_holder[0] = pdrain.scrape(server_url)
128
+ scrape_thread = _th.Thread(target=_do_scrape, daemon=True)
129
+ scrape_thread.start()
130
+ while scrape_thread.is_alive():
131
+ time.sleep(0.1)
132
+ final_target = result_holder[0]
133
+
134
+ if final_target:
135
+ print_step("🚀 Launching mpv player...")
136
+ return player.play_with_mpv(final_target, is_temp_file=is_temp)
137
+ else:
138
+ print_error("Stream resolution failed.")
139
+ return False
140
+
141
+
142
+ def _episode_nav(episode_list, plugin, custom_style, back_label='<< BACK', show_banner=True):
143
+ """Episode pick → play → post-play loop. Returns 'back' or 'quit'."""
144
+ idx = 0
145
+ while True:
146
+ if show_banner:
147
+ print_banner()
148
+ print_header("📋 EPISODES", "🎬")
149
+ console.print(make_episode_table(episode_list))
150
+ print_separator()
151
+
152
+ ep_choices = [
153
+ {'name': f' EP{i+1:02d} — {ep["title"][:50]}', 'value': i}
154
+ for i, ep in enumerate(episode_list)
155
+ ]
156
+ ep_choices.append({'name': f' ↩ {back_label}', 'value': 'back'})
157
+
158
+ selected = inquirer.select(
159
+ message='▶ Select episode:',
160
+ choices=ep_choices,
161
+ default=idx,
162
+ style=custom_style,
163
+ qmark="",
164
+ ).execute()
165
+
166
+ if selected == 'back' or selected is None:
167
+ from .plugins._base import cache_clear
168
+ cache_clear()
169
+ return 'back'
170
+ idx = selected
171
+
172
+ # ponytail: inner loop keeps QUALITY/REPLAY on same episode instead of reopening episode list
173
+ while True:
174
+ print_header("🎬 NOW PLAYING", "▶")
175
+ if not _play_episode(episode_list[idx]['url'], plugin, custom_style):
176
+ time.sleep(2)
177
+ break
178
+
179
+ print_separator()
180
+
181
+ post_choices = make_postplay_actions(idx, len(episode_list))
182
+ cmd = inquirer.select(
183
+ message="🎮 Command:",
184
+ choices=post_choices,
185
+ style=custom_style,
186
+ qmark="",
187
+ ).execute()
188
+
189
+ if cmd == '▶ NEXT':
190
+ if idx + 1 < len(episode_list):
191
+ idx += 1
192
+ continue
193
+ break
194
+ elif cmd == '◀ PREV':
195
+ if idx > 0:
196
+ idx -= 1
197
+ continue
198
+ break
199
+ elif cmd in ('↺ REPLAY', '⚙ QUALITY'):
200
+ continue # replay same episode / choose different quality
201
+ else:
202
+ return 'quit'
203
+
204
+
205
+ def _tui_loop():
206
+ p_name = 'otakudesu'
207
+ _last_plugin_name = None
208
+ _plugin = None
209
+ custom_style = make_style()
210
+ available_providers = [
211
+ m.name for m in pkgutil.iter_modules(plugins.__path__)
212
+ if not m.name.startswith('_')
213
+ ]
214
+
215
+ while True:
216
+ print_banner()
217
+
218
+ if p_name != _last_plugin_name:
219
+ _plugin = importlib.import_module(f'indonime.plugins.{p_name}')
220
+ _last_plugin_name = p_name
221
+
222
+ is_switching = False
223
+ try:
224
+ prompt = inquirer.text(
225
+ message='🔍 Search anime:',
226
+ qmark='',
227
+ instruction='[alt+p] switch provider',
228
+ style=custom_style,
229
+ validate=lambda x: True if is_switching else len(x) > 0,
230
+ )
231
+ @prompt.register_kb('alt-p')
232
+ def _(event):
233
+ nonlocal is_switching
234
+ is_switching = True
235
+ event.app.exit(result='/switch')
236
+ result = prompt.execute()
237
+ except KeyboardInterrupt:
238
+ break
239
+
240
+ if result == '/switch':
241
+ new_p = inquirer.select(
242
+ message='📡 Select provider:',
243
+ choices=available_providers,
244
+ qmark='',
245
+ style=custom_style,
246
+ ).execute()
247
+ if new_p:
248
+ p_name = new_p
249
+ continue
250
+ if not result:
251
+ break
252
+
253
+ print_header("🔎 SEARCHING", "🔎")
254
+ with console.status(styled_status(f'Searching for "{result}"...')):
255
+ results = _plugin.search_anime(result)
256
+
257
+ if not results:
258
+ print_warning("No results found.")
259
+ time.sleep(2)
260
+ continue
261
+
262
+ choices = [item['title'] for item in results] + ['-- ABORT --']
263
+ selected_title = inquirer.fuzzy(
264
+ message='📺 Select title:',
265
+ choices=choices,
266
+ style=custom_style,
267
+ ).execute()
268
+
269
+ if selected_title == '-- ABORT --' or not selected_title:
270
+ continue
271
+
272
+ selected_url = next(
273
+ item['url'] for item in results
274
+ if item['title'] == selected_title
275
+ )
276
+
277
+ print_header("📋 EPISODES", "🎬")
278
+ with console.status(styled_status("Fetching episode list...")):
279
+ episode_list = _plugin.episodes(selected_url)
280
+
281
+ if not episode_list:
282
+ print_warning("No episodes found.")
283
+ time.sleep(2)
284
+ continue
285
+
286
+ if _episode_nav(
287
+ episode_list, _plugin, custom_style,
288
+ back_label='<< BACK TO SEARCH', show_banner=True
289
+ ) == 'quit':
290
+ break
291
+
292
+ print_banner()
293
+ make_footer()
294
+ print_success("Thanks for using Indonime! ~ Sayonara ~")
295
+
296
+ def _search_mode(query, provider='otakudesu'):
297
+ """One-shot search → play → exit."""
298
+ custom_style = make_style()
299
+ print_banner()
300
+
301
+ try:
302
+ plugin = importlib.import_module(f'indonime.plugins.{provider}')
303
+ except Exception as e:
304
+ print_error(f"Plugin error: {e}")
305
+ input('[Press Enter]')
306
+ return
307
+
308
+ print_header("🔎 SEARCHING", "🔎")
309
+ with console.status(styled_status(f'Searching for "{query}"...')):
310
+ results = plugin.search_anime(query)
311
+
312
+ if not results:
313
+ print_warning("No results found.")
314
+ input('[Press Enter]')
315
+ return
316
+
317
+ choices = [item['title'] for item in results] + ['-- ABORT --']
318
+ selected_title = inquirer.fuzzy(
319
+ message='📺 Select title:',
320
+ choices=choices,
321
+ style=custom_style,
322
+ ).execute()
323
+
324
+ if selected_title == '-- ABORT --' or not selected_title:
325
+ return
326
+
327
+ selected_url = next(
328
+ item['url'] for item in results
329
+ if item['title'] == selected_title
330
+ )
331
+
332
+ print_header("📋 EPISODES", "🎬")
333
+ with console.status(styled_status("Fetching episode list...")):
334
+ episode_list = plugin.episodes(selected_url)
335
+
336
+ if not episode_list:
337
+ print_warning("No episodes found.")
338
+ input('[Press Enter]')
339
+ return
340
+
341
+ _episode_nav(
342
+ episode_list, plugin, custom_style,
343
+ back_label='<< QUIT', show_banner=False
344
+ )
345
+
346
+ if player.current_mpv_process and player.current_mpv_process.poll() is None:
347
+ player.current_mpv_process.wait()
348
+
349
+
350
+ def main():
351
+ parser = argparse.ArgumentParser(
352
+ description='Indonime — Subtitle Indonesia Anime Searcher'
353
+ )
354
+ parser.add_argument(
355
+ 'mode', nargs='?', default='tui',
356
+ help='Mode: tui (interactive, default) or search <query>'
357
+ )
358
+ parser.add_argument('query', nargs='*', help='Search query')
359
+ parser.add_argument(
360
+ '-p', '--provider', default='otakudesu',
361
+ choices=['otakudesu', 'anoboy'],
362
+ help='Provider (default: otakudesu)'
363
+ )
364
+ args = parser.parse_args()
365
+
366
+ if args.mode == 'search' and args.query:
367
+ _search_mode(' '.join(args.query), args.provider)
368
+ else:
369
+ _tui_loop()
indonime/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ from indonime import main
2
+
3
+ try:
4
+ main()
5
+ except KeyboardInterrupt:
6
+ from rich.console import Console
7
+ Console().print(f'\n[yellow]Sayonara![/yellow]')
@@ -0,0 +1,132 @@
1
+ #!/usr/bin/env python3
2
+ """Cross-platform mpv installer for Indonime."""
3
+ import os
4
+ import sys
5
+ import shutil
6
+ import platform
7
+ import subprocess
8
+ import urllib.request
9
+ from pathlib import Path
10
+ from rich.console import Console
11
+ from rich.progress import (
12
+ Progress, BarColumn, DownloadColumn,
13
+ TransferSpeedColumn, TimeRemainingColumn,
14
+ )
15
+
16
+ console = Console()
17
+
18
+ BASE = Path(__file__).resolve().parent.parent # project root (indonime/)
19
+ MPV_DIR = BASE / "mpv"
20
+
21
+
22
+ def _download(url, dest, desc="Downloading"):
23
+ dest = Path(dest)
24
+ try:
25
+ resp = urllib.request.urlopen(url)
26
+ total = int(resp.headers.get("content-length", 0))
27
+ with Progress(
28
+ "[progress.description]{task.description}", BarColumn(),
29
+ DownloadColumn(), TransferSpeedColumn(), TimeRemainingColumn(),
30
+ console=console,
31
+ ) as p:
32
+ task = p.add_task(f"[cyan]{desc}", total=total)
33
+ with open(dest, "wb") as f:
34
+ for chunk in iter(lambda: resp.read(64 * 1024), b""):
35
+ f.write(chunk)
36
+ p.update(task, advance=len(chunk))
37
+ return True
38
+ except Exception as e:
39
+ console.print(f"[red]✘ Download failed: {e}[/red]")
40
+ return False
41
+
42
+
43
+ def _install_windows():
44
+ """Download mpv.7z, extract via 7zr.exe (standalone)."""
45
+ mpv_url = "https://github.com/salsa-ram/indonime/releases/download/v1.0.0-mpv/mpv.7z"
46
+ mpv_7z = BASE / "mpv.7z"
47
+
48
+ if not _download(mpv_url, mpv_7z, "Downloading MPV"):
49
+ return False
50
+
51
+ console.print("[cyan]Extracting...[/cyan]")
52
+ MPV_DIR.mkdir(exist_ok=True)
53
+
54
+ for exe, args in [
55
+ (r"C:\Program Files\7-Zip\7z.exe", [r"C:\Program Files\7-Zip\7z.exe", "x", str(mpv_7z), f"-o{MPV_DIR}", "-y"]),
56
+ (r"C:\Program Files\WinRAR\WinRAR.exe", [r"C:\Program Files\WinRAR\WinRAR.exe", "x", str(mpv_7z), f"{MPV_DIR}\\"]),
57
+ ]:
58
+ if os.path.exists(exe):
59
+ subprocess.run(args, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
60
+ if (MPV_DIR / "mpv.exe").exists():
61
+ break
62
+ else:
63
+ console.print("[cyan]Downloading 7-Zip standalone extractor...[/cyan]")
64
+ if not _download("https://www.7-zip.org/a/7zr.exe", BASE / "7zr.exe"):
65
+ return False
66
+ subprocess.run([str(BASE / "7zr.exe"), "x", str(mpv_7z), f"-o{MPV_DIR}", "-y"],
67
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
68
+ (BASE / "7zr.exe").unlink(missing_ok=True) # ponytail: BCJ2 archive, needs 7z
69
+
70
+ mpv_7z.unlink(missing_ok=True)
71
+ ok = (MPV_DIR / "mpv.exe").exists()
72
+ console.print("[green]✓ mpv installed[/green]" if ok else "[red]✘ Extraction failed[/red]")
73
+ return ok
74
+
75
+
76
+ def _install_macos():
77
+ if shutil.which("brew"):
78
+ console.print("[cyan]Installing mpv via Homebrew...[/cyan]")
79
+ r = subprocess.run(["brew", "install", "mpv"], capture_output=True, text=True)
80
+ if r.returncode == 0:
81
+ console.print("[green]✓ mpv installed via Homebrew[/green]")
82
+ return True
83
+ console.print(f"[yellow]⚠ Homebrew failed: {r.stderr.strip()}[/yellow]")
84
+ console.print("[yellow]Install manually: brew install mpv[/yellow]")
85
+ return False
86
+
87
+
88
+ def _install_linux():
89
+ for pm, cmd in [
90
+ ("apt", ["apt", "install", "-y", "mpv"]),
91
+ ("pacman", ["pacman", "-S", "--noconfirm", "mpv"]),
92
+ ("dnf", ["dnf", "install", "-y", "mpv"]),
93
+ ("zypper", ["zypper", "install", "-y", "mpv"]),
94
+ ]:
95
+ if shutil.which(pm):
96
+ console.print(f"[cyan]Installing mpv via {pm}...[/cyan]")
97
+ r = subprocess.run(cmd, capture_output=True, text=True)
98
+ if r.returncode == 0:
99
+ console.print(f"[green]✓ mpv installed via {pm}[/green]")
100
+ return True
101
+ console.print(f"[yellow]⚠ {pm} failed[/yellow]")
102
+ console.print("[yellow]Install manually: apt install mpv[/yellow]")
103
+ return False
104
+
105
+
106
+ def main():
107
+ console.print("[bold cyan]📺 Indonime - MPV Setup[/bold cyan]\n")
108
+
109
+ if shutil.which("mpv"):
110
+ console.print("[green]✓ mpv already in PATH[/green]")
111
+ return
112
+
113
+ system = platform.system()
114
+ console.print(f"[dim]OS: {system}[/dim]")
115
+
116
+ ok = {
117
+ "Windows": _install_windows,
118
+ "Darwin": _install_macos,
119
+ }.get(system, _install_linux)()
120
+
121
+ if not ok:
122
+ console.print("\n[yellow]Install mpv manually:[/yellow]")
123
+ console.print(" Windows: https://mpv.io/installation/")
124
+ console.print(" macOS: brew install mpv")
125
+ console.print(" Linux: apt install mpv")
126
+ sys.exit(1)
127
+
128
+ console.print("[bold green]✓ Setup complete![/bold green]")
129
+
130
+
131
+ if __name__ == "__main__":
132
+ main()
File without changes