pinak-cli 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.
pinak/cli.py ADDED
@@ -0,0 +1,775 @@
1
+ import contextlib
2
+ import os
3
+ import re
4
+ import sys
5
+
6
+ # Configure UTF-8 encoding for stdout/stderr to prevent emoji rendering errors on Windows
7
+ if hasattr(sys.stdout, "reconfigure"):
8
+ with contextlib.suppress(Exception):
9
+ sys.stdout.reconfigure(encoding="utf-8")
10
+ if hasattr(sys.stderr, "reconfigure"):
11
+ with contextlib.suppress(Exception):
12
+ sys.stderr.reconfigure(encoding="utf-8")
13
+
14
+ import questionary
15
+ import typer
16
+ from prompt_toolkit import PromptSession
17
+ from prompt_toolkit.completion import WordCompleter
18
+ from prompt_toolkit.history import FileHistory
19
+ from prompt_toolkit.key_binding import KeyBindings
20
+ from prompt_toolkit.keys import Keys
21
+ from prompt_toolkit.styles import Style
22
+ from rich.console import Console
23
+
24
+ from . import updater
25
+ from .commands.info import (
26
+ open_output_folder,
27
+ print_help,
28
+ show_config,
29
+ show_history_rendered,
30
+ show_platforms,
31
+ show_stats,
32
+ )
33
+ from .commands.panels import (
34
+ print_banner,
35
+ print_exit_screen,
36
+ print_separator,
37
+ print_welcome_screen,
38
+ )
39
+ from .config import CONFIG_FILE, config, init_config, save_config
40
+ from .doctor import run_doctor
41
+ from .downloader import handle_download, is_downloading, parse_download_text
42
+ from .history import (
43
+ clear_all_history,
44
+ delete_history_by_ids,
45
+ get_history_ids_desc,
46
+ init_db,
47
+ initialize_session,
48
+ )
49
+ from .logging_setup import setup_logging
50
+ from .notify import print_offline_notice
51
+ from .utils import BOLD, CYAN, GREEN, RED, RESET, YELLOW, normalize_quality
52
+
53
+ console = Console()
54
+
55
+
56
+ def ask_with_esc(q):
57
+ """Wrapper to allow Esc key to cancel questionary prompts."""
58
+ try:
59
+
60
+ @q.application.key_bindings.add(Keys.Escape, eager=True)
61
+ def _(event):
62
+ event.app.exit(result=None)
63
+
64
+ except AttributeError:
65
+ from prompt_toolkit.key_binding import KeyBindings, merge_key_bindings
66
+
67
+ kb = KeyBindings()
68
+
69
+ @kb.add(Keys.Escape, eager=True)
70
+ def _(event):
71
+ event.app.exit(result=None)
72
+
73
+ q.application.key_bindings = merge_key_bindings([q.application.key_bindings, kb])
74
+
75
+ # Catch internal KeyboardInterrupt from Ctrl+C to avoid redundant messages
76
+ try:
77
+ return q.ask(kbi_msg="")
78
+ except TypeError:
79
+ # Fallback if kbi_msg is not supported in the user's questionary version
80
+ return q.ask()
81
+
82
+
83
+ def repl_loop(): # pragma: no cover - interactive TTY loop, exercised manually
84
+ print_banner()
85
+ username = config.get("username", "").strip()
86
+ if not username:
87
+ console.print("[yellow][!] Before we begin, please tell us your name.[/]")
88
+ username = ask_with_esc(questionary.text("Enter your name:"))
89
+ if not username:
90
+ print(f"\n{CYAN}[*] Goodbye!{RESET}")
91
+ sys.exit(0)
92
+ config["username"] = username.strip()
93
+ save_config()
94
+ console.print() # Add empty line for neatness instead of clearing
95
+ print_welcome_screen()
96
+ commands_meta = {
97
+ "/help": "Show this help menu",
98
+ "/doctor": "Check dependencies and output folder",
99
+ "/config": "Show saved preferences",
100
+ "/stats": "Show download analytics and insights",
101
+ "/setout": "Change and save the output directory",
102
+ "/open": "Open the current output folder",
103
+ "/history": "View download history",
104
+ "/batch": "Download multiple URLs from a text file",
105
+ "/instagram": "Download post, reel, igtv from Instagram",
106
+ "/video": "Download video from a URL",
107
+ "/audio": "Download video/song directly as MP3",
108
+ "/video_quality": "Set video quality (interactive if value is omitted)",
109
+ "/audio_quality": "Set audio quality in kbps (interactive if value is omitted)",
110
+ "/video_format": "Set video format (e.g. mp4, mkv) (interactive if value is omitted)",
111
+ "/audio_format": "Set audio format (e.g. mp3, m4a) (interactive if value is omitted)",
112
+ "/platform": "List or search supported platforms",
113
+ "/trim": "Trim video/audio (e.g. /trim 01:15-02:30 <url>)",
114
+ "/subs": "Toggle automatic subtitle/lyric extraction",
115
+ "/sort": "Toggle auto-sorting into platform directories",
116
+ "/delete_his": "Delete history: all, index (e.g. 3), or range (e.g. 2-4)",
117
+ "/ffmpeg": "Download a static FFmpeg build automatically",
118
+ "/update": "Update yt-dlp and Pinak itself to the latest versions",
119
+ "/username": "Change and save your username",
120
+ "/greeting": "Change and save your greeting statement",
121
+ "/exit": "Exit the CLI safely",
122
+ }
123
+
124
+ commands_completer = WordCompleter(
125
+ sorted(commands_meta),
126
+ meta_dict=commands_meta,
127
+ ignore_case=True,
128
+ pattern=re.compile(r"[a-zA-Z0-9_]*\/?"),
129
+ )
130
+
131
+ custom_style = Style.from_dict(
132
+ {
133
+ "prompt": "ansicyan bold",
134
+ "completion-menu.completion": "bg:#005588 #ffffff",
135
+ "completion-menu.completion.current": "bg:#0088ff #ffffff",
136
+ "completion-menu.meta.completion": "bg:#003355 #cccccc",
137
+ "completion-menu.meta.completion.current": "bg:#005588 #ffffff",
138
+ }
139
+ )
140
+
141
+ class FilteredHistory(FileHistory):
142
+ def append_string(self, string: str) -> None:
143
+ lower_str = string.strip().lower()
144
+ if lower_str in [
145
+ "/video",
146
+ "/audio",
147
+ "/trim",
148
+ "/setout",
149
+ "/batch",
150
+ "/delete_his",
151
+ "/instagram",
152
+ ]:
153
+ return
154
+ super().append_string(string)
155
+
156
+ history_file = os.path.expanduser("~/.pinak_cli_history")
157
+ session = PromptSession(history=FilteredHistory(history_file))
158
+
159
+ kb = KeyBindings()
160
+
161
+ @kb.add("enter")
162
+ def _(event):
163
+ buffer = event.current_buffer
164
+ text = buffer.text.strip().lower()
165
+ if text in [
166
+ "/video",
167
+ "/audio",
168
+ "/trim",
169
+ "/setout",
170
+ "/batch",
171
+ "/delete_his",
172
+ "/instagram",
173
+ ]:
174
+ buffer.text = buffer.text.strip() + " "
175
+ buffer.cursor_position = len(buffer.text)
176
+ else:
177
+ buffer.validate_and_handle()
178
+
179
+ default_cmd = ""
180
+ has_shown_update = False
181
+ has_shown_pinak_update = False
182
+ updater.start_background_update_check()
183
+
184
+ while True:
185
+ try:
186
+ if updater.UPDATE_AVAILABLE and not has_shown_update:
187
+ console.print(
188
+ f"\n[yellow]💡 A new version of {updater.UPDATE_PACKAGE} ({updater.UPDATE_AVAILABLE}) is available! Type [bold]/update[/bold] to install it.[/]"
189
+ )
190
+ has_shown_update = True
191
+
192
+ if updater.PINAK_UPDATE_AVAILABLE and not has_shown_pinak_update:
193
+ from .notify import print_update_notice
194
+
195
+ print_update_notice(updater.PINAK_PACKAGE, updater.PINAK_UPDATE_AVAILABLE)
196
+ has_shown_pinak_update = True
197
+
198
+ cmd = session.prompt(
199
+ "Pinak 🔱 > ",
200
+ default=default_cmd,
201
+ completer=commands_completer,
202
+ complete_while_typing=True,
203
+ style=custom_style,
204
+ mouse_support=False,
205
+ key_bindings=kb,
206
+ ).strip()
207
+
208
+ default_cmd = ""
209
+
210
+ if not cmd:
211
+ continue
212
+
213
+ lower_cmd = cmd.lower()
214
+
215
+ # Fallback if somehow submitted (e.g. mouse double click)
216
+ if lower_cmd in [
217
+ "/video",
218
+ "/audio",
219
+ "/trim",
220
+ "/setout",
221
+ "/batch",
222
+ "/delete_his",
223
+ "/instagram",
224
+ ]:
225
+ default_cmd = f"{cmd} "
226
+ continue
227
+
228
+ if lower_cmd in ["/exit", "exit", "quit"]:
229
+ print_exit_screen(interrupted=False)
230
+ print_separator()
231
+ break
232
+
233
+ if lower_cmd == "/ffmpeg":
234
+ from .ffmpeg_setup import ensure_ffmpeg, install_ffmpeg_extra
235
+
236
+ existing = ensure_ffmpeg()
237
+ import shutil as _shutil
238
+
239
+ if existing and (os.path.exists(existing) or _shutil.which(existing)):
240
+ console.print(f"[green][+] FFmpeg already available: {existing}[/]")
241
+ else:
242
+ console.print("[cyan]Fetching a static FFmpeg build (pinak-cli[ffmpeg])...[/]")
243
+ fetched = install_ffmpeg_extra()
244
+ if fetched:
245
+ console.print(f"[green][+] FFmpeg ready at: {fetched}[/]")
246
+ else:
247
+ console.print(
248
+ "[red][X] Automatic install failed. Install FFmpeg from "
249
+ "https://ffmpeg.org/download.html, add it to PATH, or run: "
250
+ 'pip install "pinak-cli[ffmpeg]"[/]'
251
+ )
252
+ continue
253
+
254
+ if lower_cmd == "/update":
255
+ console.print("[cyan]🔄 Updating the downloader engine and Pinak itself...[/]")
256
+ try:
257
+ with console.status("[cyan]Downloading and installing...[/]", spinner="dots"):
258
+ results = updater.perform_update()
259
+ for package, message in results.items():
260
+ if message.startswith("Updated ") or "already the latest" in message:
261
+ console.print(f"[green][+] {message}[/]")
262
+ else:
263
+ console.print(f"[red][X] {package}: {message}[/]")
264
+ # "→" only appears on a real pinak version bump.
265
+ if "→" in results.get(updater.PINAK_PACKAGE, ""):
266
+ console.print(
267
+ "[yellow]Pinak itself was updated — "
268
+ "restart the CLI (/exit, then run pinak again) "
269
+ "to use the new version.[/]"
270
+ )
271
+ except Exception as e:
272
+ console.print(f"[red][X] Update error: {e}[/]")
273
+ continue
274
+
275
+ if lower_cmd == "/delete_his" or lower_cmd.startswith("/delete_his "):
276
+ arg = cmd[11:].strip()
277
+ if not arg:
278
+ console.print(
279
+ "[yellow][!] Usage: /delete_his all | <number> | <range> (e.g. /delete_his 3 or /delete_his 2-4)[/]"
280
+ )
281
+ continue
282
+
283
+ # 1. /delete_his all
284
+ if arg.lower() == "all":
285
+ clear_all_history()
286
+ console.print(
287
+ "[green][+] Successfully cleared entire downloads history log.[/]"
288
+ )
289
+ continue
290
+
291
+ # 2. /delete_his X-Y (Range)
292
+ match_range = re.match(r"^(\d+)-(\d+)$", arg)
293
+ if match_range:
294
+ start_sno = int(match_range.group(1))
295
+ end_sno = int(match_range.group(2))
296
+ if start_sno <= 0 or end_sno <= 0 or start_sno > end_sno:
297
+ console.print(
298
+ f"[red][X] Error: Invalid range '{arg}'. Start and end values must be positive integers, and start <= end.[/]"
299
+ )
300
+ continue
301
+
302
+ # Fetch history ids (newest first)
303
+ ids = get_history_ids_desc()
304
+ total_records = len(ids)
305
+ if start_sno > total_records:
306
+ console.print(
307
+ f"[red][X] Error: Range start {start_sno} exceeds total history count ({total_records}).[/]"
308
+ )
309
+ continue
310
+
311
+ actual_end_sno = min(end_sno, total_records)
312
+ ids_to_delete = ids[start_sno - 1 : actual_end_sno]
313
+
314
+ if not ids_to_delete:
315
+ console.print(f"[red][X] Error: No records found in range '{arg}'.[/]")
316
+ continue
317
+
318
+ delete_history_by_ids(ids_to_delete)
319
+ console.print(
320
+ f"[green][+] Successfully deleted log entries in range {start_sno} to {actual_end_sno} (Total: {len(ids_to_delete)}).[/]"
321
+ )
322
+ continue
323
+
324
+ # 3. /delete_his X (Single log number)
325
+ match_single = re.match(r"^(\d+)$", arg)
326
+ if match_single:
327
+ sno = int(match_single.group(1))
328
+ if sno <= 0:
329
+ console.print("[red][X] Error: Log number must be a positive integer.[/]")
330
+ continue
331
+
332
+ ids = get_history_ids_desc()
333
+ total_records = len(ids)
334
+ if sno > total_records:
335
+ console.print(
336
+ f"[red][X] Error: Log number {sno} exceeds total history count ({total_records}).[/]"
337
+ )
338
+ continue
339
+
340
+ id_to_delete = ids[sno - 1]
341
+ delete_history_by_ids([id_to_delete])
342
+ console.print(f"[green][+] Successfully deleted log entry #{sno}.[/]")
343
+ continue
344
+
345
+ console.print(
346
+ f"[red][X] Error: Unknown input '{arg}'. Use /delete_his all, or a number (e.g. 3), or a range (e.g. 2-4).[/]"
347
+ )
348
+ continue
349
+
350
+ if lower_cmd in ("/", "/help"):
351
+ print_help()
352
+ continue
353
+
354
+ if lower_cmd == "/doctor" or lower_cmd.startswith("/doctor "):
355
+ _doc_verbose = "--verbose" in lower_cmd or " -v" in lower_cmd
356
+ run_doctor(verbose=_doc_verbose)
357
+ continue
358
+
359
+ if lower_cmd == "/config":
360
+ show_config()
361
+ continue
362
+
363
+ if lower_cmd == "/stats":
364
+ show_stats()
365
+ continue
366
+
367
+ if lower_cmd == "/open":
368
+ open_output_folder()
369
+ continue
370
+
371
+ if lower_cmd.startswith("/history"):
372
+ show_history_rendered(cmd[8:].strip())
373
+ continue
374
+
375
+ if lower_cmd == "/username" or lower_cmd.startswith("/username "):
376
+ new_username = cmd[9:].strip()
377
+ if not new_username:
378
+ try:
379
+ new_username = input(
380
+ f"Enter new username [current: {config.get('username')}]: "
381
+ ).strip()
382
+ except (KeyboardInterrupt, EOFError):
383
+ print(f"{YELLOW}[*] Canceled.{RESET}")
384
+ continue
385
+ if new_username:
386
+ config["username"] = new_username
387
+ save_config()
388
+ print(f"{GREEN}[+] Username updated to: {new_username}{RESET}")
389
+ else:
390
+ print(f"{YELLOW}[*] Username unchanged.{RESET}")
391
+ continue
392
+
393
+ if (
394
+ lower_cmd == "/greeting"
395
+ or lower_cmd.startswith("/greeting ")
396
+ or lower_cmd == "/gretting"
397
+ or lower_cmd.startswith("/gretting ")
398
+ ):
399
+ # Determine prefix length based on the actual command typed
400
+ if lower_cmd.startswith("/greeting"):
401
+ arg_start = len("/greeting")
402
+ else:
403
+ arg_start = len("/gretting")
404
+ new_greeting = cmd[arg_start:].strip()
405
+ if not new_greeting:
406
+ try:
407
+ new_greeting = input(
408
+ f"Enter new greeting [current: {config.get('greeting')}]: "
409
+ ).strip()
410
+ except (KeyboardInterrupt, EOFError):
411
+ print(f"{YELLOW}[*] Canceled.{RESET}")
412
+ continue
413
+ if new_greeting:
414
+ config["greeting"] = new_greeting
415
+ save_config()
416
+ print(f"{GREEN}[+] Greeting updated to: {new_greeting}{RESET}")
417
+ else:
418
+ print(f"{YELLOW}[*] Greeting unchanged.{RESET}")
419
+ continue
420
+
421
+ if lower_cmd == "/video_quality" or lower_cmd.startswith("/video_quality "):
422
+ arg = cmd[14:].strip()
423
+ if not arg:
424
+ options = ["best", "1080p", "720p", "480p", "360p", "worst"]
425
+ current = config.get("quality", "best")
426
+ choice = ask_with_esc(
427
+ questionary.select(
428
+ "Select video/media quality:",
429
+ choices=options,
430
+ default=current,
431
+ instruction="(Press Esc to cancel)",
432
+ )
433
+ )
434
+ if choice:
435
+ config["quality"] = choice
436
+ save_config()
437
+ print(f"{GREEN}[+] Quality set to: {choice}{RESET}")
438
+ else:
439
+ print(f"{YELLOW}[*] Canceled.{RESET}")
440
+ continue
441
+ else:
442
+ new_quality = normalize_quality(arg)
443
+ if not new_quality:
444
+ print(
445
+ f"{RED}[!] Error: Invalid quality. Use best, worst, audio, 360p, 720p, 1080p.{RESET}"
446
+ )
447
+ continue
448
+ config["quality"] = new_quality
449
+ save_config()
450
+ print(f"{GREEN}[+] Quality set to: {new_quality}{RESET}")
451
+ continue
452
+
453
+ if lower_cmd == "/audio_quality" or lower_cmd.startswith("/audio_quality "):
454
+ arg = cmd[14:].strip()
455
+ if not arg:
456
+ options = ["320", "256", "192", "128", "96"]
457
+ current = str(config.get("audio_quality", "192"))
458
+ choice = ask_with_esc(
459
+ questionary.select(
460
+ "Select audio quality (kbps):",
461
+ choices=options,
462
+ default=current,
463
+ instruction="(Press Esc to cancel)",
464
+ )
465
+ )
466
+ if choice:
467
+ config["audio_quality"] = choice
468
+ save_config()
469
+ print(f"{GREEN}[+] Audio quality set to: {choice} kbps{RESET}")
470
+ else:
471
+ print(f"{YELLOW}[*] Canceled.{RESET}")
472
+ continue
473
+ else:
474
+ if arg in ["320", "256", "192", "128", "96"]:
475
+ config["audio_quality"] = arg
476
+ save_config()
477
+ print(f"{GREEN}[+] Audio quality set to: {arg} kbps{RESET}")
478
+ else:
479
+ print(
480
+ f"{RED}[!] Error: Invalid audio quality. Use 320, 256, 192, 128, 96.{RESET}"
481
+ )
482
+ continue
483
+
484
+ if lower_cmd == "/video_format" or lower_cmd.startswith("/video_format "):
485
+ arg = cmd[14:].strip()
486
+ if not arg:
487
+ options = ["mp4", "mkv", "webm", "flv", "avi"]
488
+ current = config.get("video_format", "mp4")
489
+ choice = ask_with_esc(
490
+ questionary.select(
491
+ "Select video format:",
492
+ choices=options,
493
+ default=current,
494
+ instruction="(Press Esc to cancel)",
495
+ )
496
+ )
497
+ if choice:
498
+ config["video_format"] = choice
499
+ save_config()
500
+ print(f"{GREEN}[+] Video format set to: {choice}{RESET}")
501
+ else:
502
+ print(f"{YELLOW}[*] Canceled.{RESET}")
503
+ continue
504
+ else:
505
+ if arg.lower() in ["mp4", "mkv", "webm", "flv", "avi"]:
506
+ config["video_format"] = arg.lower()
507
+ save_config()
508
+ print(f"{GREEN}[+] Video format set to: {arg.lower()}{RESET}")
509
+ else:
510
+ print(
511
+ f"{RED}[!] Error: Invalid format. Supported: mp4, mkv, webm, flv, avi.{RESET}"
512
+ )
513
+ continue
514
+
515
+ if lower_cmd == "/audio_format" or lower_cmd.startswith("/audio_format "):
516
+ arg = cmd[14:].strip()
517
+ if not arg:
518
+ options = ["mp3", "m4a", "wav", "flac", "opus"]
519
+ current = config.get("audio_format", "mp3")
520
+ choice = ask_with_esc(
521
+ questionary.select(
522
+ "Select audio format:",
523
+ choices=options,
524
+ default=current,
525
+ instruction="(Press Esc to cancel)",
526
+ )
527
+ )
528
+ if choice:
529
+ config["audio_format"] = choice
530
+ save_config()
531
+ print(f"{GREEN}[+] Audio format set to: {choice}{RESET}")
532
+ else:
533
+ print(f"{YELLOW}[*] Canceled.{RESET}")
534
+ continue
535
+ else:
536
+ if arg.lower() in ["mp3", "m4a", "wav", "flac", "opus"]:
537
+ config["audio_format"] = arg.lower()
538
+ save_config()
539
+ print(f"{GREEN}[+] Audio format set to: {arg.lower()}{RESET}")
540
+ else:
541
+ print(
542
+ f"{RED}[!] Error: Invalid format. Supported: mp3, m4a, wav, flac, opus.{RESET}"
543
+ )
544
+ continue
545
+ if lower_cmd == "/subs":
546
+ config["embed_subs"] = not config.get("embed_subs", False)
547
+ save_config()
548
+ state = "enabled" if config["embed_subs"] else "disabled"
549
+ console.print(f"[green][+] Subtitle extraction {state}.[/]")
550
+ continue
551
+
552
+ if lower_cmd == "/sort":
553
+ config["auto_sort"] = not config.get("auto_sort", False)
554
+ save_config()
555
+ state = "enabled" if config["auto_sort"] else "disabled"
556
+ console.print(f"[green][+] Auto-sorting by platform {state}.[/]")
557
+ continue
558
+
559
+ if lower_cmd.startswith("/trim "):
560
+ parts = cmd[6:].strip().split(" ", 1)
561
+ if len(parts) < 2:
562
+ console.print(
563
+ "[yellow]Usage: /trim <start>-<end> <url> (e.g. /trim 00:15-01:30 https://...)[/]"
564
+ )
565
+ continue
566
+ trim_str, raw_url_text = parts[0], parts[1]
567
+ url, extract_audio, force, req_quality = parse_download_text(raw_url_text)
568
+ final_quality = req_quality or config.get("quality", "best")
569
+ handle_download(
570
+ url,
571
+ extract_audio=extract_audio,
572
+ force=force,
573
+ quality=final_quality,
574
+ interactive=True,
575
+ trim_range=trim_str,
576
+ )
577
+ continue
578
+
579
+ if lower_cmd.startswith("/setout "):
580
+ new_dir = cmd[8:].strip()
581
+ if new_dir:
582
+ config["output_dir"] = new_dir
583
+ save_config()
584
+ print(f"{GREEN}[+] Output directory updated to: {new_dir}{RESET}")
585
+ else:
586
+ print(f"{RED}[!] Error: Please specify a folder path.{RESET}")
587
+ continue
588
+
589
+ if lower_cmd.startswith("/platform"):
590
+ search_term = cmd[9:].strip()
591
+ show_platforms(search_term)
592
+ continue
593
+
594
+ if lower_cmd.startswith("/batch "):
595
+ filename = cmd[7:].strip()
596
+ if not os.path.exists(filename):
597
+ print(f"{RED}[!] Error: Batch file '{filename}' not found.{RESET}")
598
+ continue
599
+ with open(filename, encoding="utf-8") as f:
600
+ lines = [
601
+ line.strip() for line in f if line.strip() and not line.startswith("#")
602
+ ]
603
+
604
+ print(f"\n[*] Found {len(lines)} URLs in {filename}.")
605
+ # Rate limits have been removed as per user request.
606
+ for idx, batch_url in enumerate(lines):
607
+ url, extract_audio, force, quality = parse_download_text(batch_url)
608
+ print(f"\n{BOLD}--- Batch Item {idx + 1}/{len(lines)} ---{RESET}")
609
+ handle_download(
610
+ url,
611
+ extract_audio=extract_audio,
612
+ force=force,
613
+ quality=quality,
614
+ interactive=False,
615
+ )
616
+ continue
617
+
618
+ if cmd.startswith("/"):
619
+ if not (
620
+ lower_cmd.startswith("/audio ")
621
+ or lower_cmd.startswith("/video ")
622
+ or lower_cmd.startswith("/instagram ")
623
+ ):
624
+ print(f"{YELLOW}[!] Unknown command: {cmd}. Type /help for assistance.{RESET}")
625
+ continue
626
+ url, extract_audio, force, quality = parse_download_text(cmd)
627
+ handle_download(url, extract_audio=extract_audio, force=force, quality=quality)
628
+ else:
629
+ if cmd.lower().startswith("http"):
630
+ print(
631
+ f"{YELLOW}[!] Direct URL detected. Use /video, /audio, or /instagram before the link."
632
+ )
633
+ print(f" Example: /instagram {cmd}{RESET}")
634
+ else:
635
+ print(
636
+ f"{YELLOW}[!] Unknown input: '{cmd}'. Type /help to see all commands.{RESET}"
637
+ )
638
+ continue
639
+
640
+ except KeyboardInterrupt:
641
+ if is_downloading:
642
+ print(
643
+ f"\n{YELLOW}[!] Download in progress. Exiting now might corrupt files. Please wait...{RESET}"
644
+ )
645
+ else:
646
+ print_exit_screen(interrupted=True)
647
+ break
648
+
649
+
650
+ app = typer.Typer(add_completion=True)
651
+
652
+
653
+ def _version_callback(value: bool) -> None:
654
+ if value:
655
+ from . import __version__
656
+
657
+ console.print(f"pinak-cli {__version__}")
658
+ raise typer.Exit()
659
+
660
+
661
+ def _install_ffmpeg_callback(value: bool) -> None:
662
+ if value:
663
+ from .ffmpeg_setup import install_ffmpeg_extra
664
+
665
+ console.print("[cyan]Fetching a static FFmpeg build (pinak-cli[ffmpeg])...[/]")
666
+ path = install_ffmpeg_extra()
667
+ if path:
668
+ console.print(f"[green][+] FFmpeg ready at: {path}[/]")
669
+ raise typer.Exit()
670
+ console.print(
671
+ "[red][X] Automatic FFmpeg install failed.[/]\n"
672
+ "[yellow]Manual options: install FFmpeg from https://ffmpeg.org/download.html "
673
+ 'and add it to PATH, or run: pip install "pinak-cli[ffmpeg]"[/]'
674
+ )
675
+ raise typer.Exit(code=1)
676
+
677
+
678
+ def _config_path_callback(value: bool) -> None:
679
+ if value:
680
+ console.print(f"Config file: {os.path.abspath(CONFIG_FILE)}")
681
+ raise typer.Exit()
682
+
683
+
684
+ def maybe_print_offline_notice() -> bool:
685
+ """Print an offline notice when the device has no internet. Returns online state."""
686
+ from .utils import is_online
687
+
688
+ if is_online():
689
+ return True
690
+ print_offline_notice()
691
+ return False
692
+
693
+
694
+ @app.command()
695
+ def main(
696
+ url: str | None = typer.Argument(None, help="The URL to download from"),
697
+ output: str | None = typer.Option(None, "-o", "--output", help="Output directory"),
698
+ force: bool = typer.Option(False, "--force", help="Force download despite duplicates"),
699
+ audio: bool = typer.Option(False, "--audio", help="Extract audio directly as MP3"),
700
+ quality: str | None = typer.Option(
701
+ None, "--quality", help="Quality: best, worst, audio, 360p, 720p, 1080p"
702
+ ),
703
+ doctor: bool = typer.Option(False, "--doctor", help="Run environment checks and exit"),
704
+ verbose: bool = typer.Option(False, "--verbose", "-v", help="Enable verbose debug output"),
705
+ log_file: str | None = typer.Option(
706
+ None, "--log-file", help="Write detailed logs to this file"
707
+ ),
708
+ dry_run: bool = typer.Option(
709
+ False, "--dry-run", help="Show what would be downloaded without downloading"
710
+ ),
711
+ as_json: bool = typer.Option(
712
+ False, "--json", help="Emit machine-readable JSON output (pairs with --dry-run)"
713
+ ),
714
+ version: bool = typer.Option(
715
+ False,
716
+ "--version",
717
+ "-V",
718
+ callback=_version_callback,
719
+ is_eager=True,
720
+ help="Show version and exit",
721
+ ),
722
+ install_ffmpeg: bool = typer.Option(
723
+ False,
724
+ "--install-ffmpeg",
725
+ callback=_install_ffmpeg_callback,
726
+ is_eager=True,
727
+ help="Download a static FFmpeg build (pinak-cli[ffmpeg]) and exit",
728
+ ),
729
+ config_path: bool = typer.Option(
730
+ False,
731
+ "--config-path",
732
+ callback=_config_path_callback,
733
+ is_eager=True,
734
+ help="Print the config file path and exit",
735
+ ),
736
+ ):
737
+ init_config()
738
+ init_db()
739
+ initialize_session()
740
+ # Persist CLI-level logging prefs so retries/errors also land in the file.
741
+ if log_file:
742
+ config["log_file"] = log_file
743
+ save_config()
744
+ setup_logging(verbose=verbose, log_file=log_file or config.get("log_file") or None)
745
+ updater.start_background_update_check()
746
+ maybe_print_offline_notice()
747
+
748
+ has_args = bool(
749
+ url
750
+ or output
751
+ or force
752
+ or audio
753
+ or quality
754
+ or doctor
755
+ or verbose
756
+ or dry_run
757
+ or as_json
758
+ or log_file
759
+ )
760
+ # Testing tools like CliRunner don't mock sys.argv, so this is safer
761
+ if has_args or (len(sys.argv) > 1 and "pytest" not in sys.argv[0]):
762
+ from .commands.run import run_single
763
+
764
+ raise typer.Exit(
765
+ code=run_single(url, output, force, audio, quality, doctor, verbose, dry_run, as_json)
766
+ )
767
+ repl_loop()
768
+
769
+
770
+ def run_cli():
771
+ app()
772
+
773
+
774
+ if __name__ == "__main__":
775
+ run_cli()