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/__init__.py +2 -0
- pinak/__main__.py +9 -0
- pinak/cli.py +775 -0
- pinak/commands/__init__.py +1 -0
- pinak/commands/info.py +408 -0
- pinak/commands/panels.py +110 -0
- pinak/commands/run.py +169 -0
- pinak/config.py +195 -0
- pinak/doctor.py +211 -0
- pinak/downloader.py +822 -0
- pinak/exceptions.py +93 -0
- pinak/ffmpeg_setup.py +131 -0
- pinak/history.py +371 -0
- pinak/instagram.py +679 -0
- pinak/logging_setup.py +72 -0
- pinak/notify.py +71 -0
- pinak/retry.py +94 -0
- pinak/subproc.py +60 -0
- pinak/updater.py +130 -0
- pinak/utils.py +301 -0
- pinak_cli-1.0.0.dist-info/METADATA +221 -0
- pinak_cli-1.0.0.dist-info/RECORD +26 -0
- pinak_cli-1.0.0.dist-info/WHEEL +5 -0
- pinak_cli-1.0.0.dist-info/entry_points.txt +2 -0
- pinak_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
- pinak_cli-1.0.0.dist-info/top_level.txt +1 -0
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()
|