pinak-cli 1.2.1__tar.gz → 1.3.0__tar.gz
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-1.2.1/pinak_cli.egg-info → pinak_cli-1.3.0}/PKG-INFO +1 -1
- pinak_cli-1.3.0/pinak/__init__.py +2 -0
- {pinak_cli-1.2.1 → pinak_cli-1.3.0}/pinak/cli.py +146 -31
- {pinak_cli-1.2.1 → pinak_cli-1.3.0}/pinak/commands/info.py +34 -2
- {pinak_cli-1.2.1 → pinak_cli-1.3.0}/pinak/commands/run.py +2 -0
- {pinak_cli-1.2.1 → pinak_cli-1.3.0}/pinak/config.py +50 -11
- {pinak_cli-1.2.1 → pinak_cli-1.3.0}/pinak/doctor.py +27 -5
- {pinak_cli-1.2.1 → pinak_cli-1.3.0}/pinak/downloader.py +175 -43
- {pinak_cli-1.2.1 → pinak_cli-1.3.0}/pinak/exceptions.py +7 -0
- {pinak_cli-1.2.1 → pinak_cli-1.3.0}/pinak/ffmpeg_setup.py +8 -15
- {pinak_cli-1.2.1 → pinak_cli-1.3.0}/pinak/history.py +153 -101
- {pinak_cli-1.2.1 → pinak_cli-1.3.0}/pinak/logging_setup.py +9 -5
- pinak_cli-1.3.0/pinak/notify.py +132 -0
- {pinak_cli-1.2.1 → pinak_cli-1.3.0}/pinak/updater.py +66 -1
- {pinak_cli-1.2.1 → pinak_cli-1.3.0}/pinak/utils.py +188 -11
- {pinak_cli-1.2.1 → pinak_cli-1.3.0/pinak_cli.egg-info}/PKG-INFO +1 -1
- {pinak_cli-1.2.1 → pinak_cli-1.3.0}/pyproject.toml +1 -1
- {pinak_cli-1.2.1 → pinak_cli-1.3.0}/tests/test_cli.py +113 -0
- {pinak_cli-1.2.1 → pinak_cli-1.3.0}/tests/test_config.py +41 -0
- {pinak_cli-1.2.1 → pinak_cli-1.3.0}/tests/test_core_extra.py +18 -0
- {pinak_cli-1.2.1 → pinak_cli-1.3.0}/tests/test_display.py +29 -1
- {pinak_cli-1.2.1 → pinak_cli-1.3.0}/tests/test_doctor_updater.py +49 -0
- pinak_cli-1.3.0/tests/test_downloader_unit.py +300 -0
- {pinak_cli-1.2.1 → pinak_cli-1.3.0}/tests/test_exceptions_parse.py +19 -0
- {pinak_cli-1.2.1 → pinak_cli-1.3.0}/tests/test_history.py +28 -0
- {pinak_cli-1.2.1 → pinak_cli-1.3.0}/tests/test_release_readiness.py +60 -0
- {pinak_cli-1.2.1 → pinak_cli-1.3.0}/tests/test_self_update.py +62 -0
- {pinak_cli-1.2.1 → pinak_cli-1.3.0}/tests/test_utils.py +46 -0
- pinak_cli-1.2.1/pinak/__init__.py +0 -2
- pinak_cli-1.2.1/pinak/notify.py +0 -71
- pinak_cli-1.2.1/tests/test_downloader_unit.py +0 -88
- {pinak_cli-1.2.1 → pinak_cli-1.3.0}/CLI_README.md +0 -0
- {pinak_cli-1.2.1 → pinak_cli-1.3.0}/LICENSE +0 -0
- {pinak_cli-1.2.1 → pinak_cli-1.3.0}/pinak/__main__.py +0 -0
- {pinak_cli-1.2.1 → pinak_cli-1.3.0}/pinak/commands/__init__.py +0 -0
- {pinak_cli-1.2.1 → pinak_cli-1.3.0}/pinak/commands/panels.py +0 -0
- {pinak_cli-1.2.1 → pinak_cli-1.3.0}/pinak/instagram.py +0 -0
- {pinak_cli-1.2.1 → pinak_cli-1.3.0}/pinak/retry.py +0 -0
- {pinak_cli-1.2.1 → pinak_cli-1.3.0}/pinak/subproc.py +0 -0
- {pinak_cli-1.2.1 → pinak_cli-1.3.0}/pinak/uninstall.py +0 -0
- {pinak_cli-1.2.1 → pinak_cli-1.3.0}/pinak_cli.egg-info/SOURCES.txt +0 -0
- {pinak_cli-1.2.1 → pinak_cli-1.3.0}/pinak_cli.egg-info/dependency_links.txt +0 -0
- {pinak_cli-1.2.1 → pinak_cli-1.3.0}/pinak_cli.egg-info/entry_points.txt +0 -0
- {pinak_cli-1.2.1 → pinak_cli-1.3.0}/pinak_cli.egg-info/requires.txt +0 -0
- {pinak_cli-1.2.1 → pinak_cli-1.3.0}/pinak_cli.egg-info/top_level.txt +0 -0
- {pinak_cli-1.2.1 → pinak_cli-1.3.0}/setup.cfg +0 -0
- {pinak_cli-1.2.1 → pinak_cli-1.3.0}/tests/test_downloader_e2e.py +0 -0
- {pinak_cli-1.2.1 → pinak_cli-1.3.0}/tests/test_engine_flows.py +0 -0
- {pinak_cli-1.2.1 → pinak_cli-1.3.0}/tests/test_engine_paths.py +0 -0
- {pinak_cli-1.2.1 → pinak_cli-1.3.0}/tests/test_entry.py +0 -0
- {pinak_cli-1.2.1 → pinak_cli-1.3.0}/tests/test_fuzz.py +0 -0
- {pinak_cli-1.2.1 → pinak_cli-1.3.0}/tests/test_instagram_unit.py +0 -0
- {pinak_cli-1.2.1 → pinak_cli-1.3.0}/tests/test_reliability.py +0 -0
- {pinak_cli-1.2.1 → pinak_cli-1.3.0}/tests/test_retry.py +0 -0
- {pinak_cli-1.2.1 → pinak_cli-1.3.0}/tests/test_run_single.py +0 -0
- {pinak_cli-1.2.1 → pinak_cli-1.3.0}/tests/test_subproc.py +0 -0
- {pinak_cli-1.2.1 → pinak_cli-1.3.0}/tests/test_uninstall.py +0 -0
|
@@ -56,36 +56,58 @@ console = Console()
|
|
|
56
56
|
def ask_with_esc(q):
|
|
57
57
|
"""Wrapper to allow Esc key to cancel questionary prompts."""
|
|
58
58
|
try:
|
|
59
|
+
try:
|
|
59
60
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
61
|
+
@q.application.key_bindings.add(Keys.Escape, eager=True)
|
|
62
|
+
def _(event):
|
|
63
|
+
event.app.exit(result=None)
|
|
63
64
|
|
|
64
|
-
|
|
65
|
-
|
|
65
|
+
except AttributeError:
|
|
66
|
+
from prompt_toolkit.key_binding import KeyBindings, merge_key_bindings
|
|
66
67
|
|
|
67
|
-
|
|
68
|
+
kb = KeyBindings()
|
|
68
69
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
70
|
+
@kb.add(Keys.Escape, eager=True)
|
|
71
|
+
def _(event):
|
|
72
|
+
event.app.exit(result=None)
|
|
72
73
|
|
|
73
|
-
|
|
74
|
+
q.application.key_bindings = merge_key_bindings([q.application.key_bindings, kb])
|
|
75
|
+
except Exception:
|
|
76
|
+
pass
|
|
74
77
|
|
|
75
78
|
# Catch internal KeyboardInterrupt from Ctrl+C to avoid redundant messages
|
|
76
79
|
try:
|
|
77
80
|
return q.ask(kbi_msg="")
|
|
78
81
|
except TypeError:
|
|
79
82
|
# Fallback if kbi_msg is not supported in the user's questionary version
|
|
80
|
-
|
|
83
|
+
try:
|
|
84
|
+
return q.ask()
|
|
85
|
+
except Exception:
|
|
86
|
+
try:
|
|
87
|
+
msg = getattr(q, "message", "Enter value: ")
|
|
88
|
+
return input(str(msg)).strip()
|
|
89
|
+
except (EOFError, KeyboardInterrupt):
|
|
90
|
+
return None
|
|
91
|
+
except Exception:
|
|
92
|
+
# Fallback to standard input if prompt_toolkit / terminal fails
|
|
93
|
+
try:
|
|
94
|
+
msg = getattr(q, "message", "Enter value: ")
|
|
95
|
+
return input(str(msg)).strip()
|
|
96
|
+
except (EOFError, KeyboardInterrupt):
|
|
97
|
+
return None
|
|
81
98
|
|
|
82
99
|
|
|
83
100
|
def repl_loop(): # pragma: no cover - interactive TTY loop, exercised manually
|
|
84
101
|
print_banner()
|
|
85
102
|
username = config.get("username", "").strip()
|
|
86
103
|
if not username:
|
|
87
|
-
|
|
88
|
-
|
|
104
|
+
try:
|
|
105
|
+
username = ask_with_esc(questionary.text("Enter your name:"))
|
|
106
|
+
except Exception:
|
|
107
|
+
try:
|
|
108
|
+
username = input("Enter your name: ").strip()
|
|
109
|
+
except (EOFError, KeyboardInterrupt):
|
|
110
|
+
username = None
|
|
89
111
|
if not username:
|
|
90
112
|
print(f"\n{CYAN}[*] Goodbye!{RESET}")
|
|
91
113
|
sys.exit(0)
|
|
@@ -102,6 +124,7 @@ def repl_loop(): # pragma: no cover - interactive TTY loop, exercised manually
|
|
|
102
124
|
"/open": "Open the current output folder",
|
|
103
125
|
"/history": "View download history",
|
|
104
126
|
"/batch": "Download multiple URLs from a text file",
|
|
127
|
+
"/any": "Download content from any platform supported by yt-dlp",
|
|
105
128
|
"/instagram": "Download post, reel, igtv from Instagram",
|
|
106
129
|
"/video": "Download video from a URL",
|
|
107
130
|
"/audio": "Download video/song directly as MP3",
|
|
@@ -143,6 +166,7 @@ def repl_loop(): # pragma: no cover - interactive TTY loop, exercised manually
|
|
|
143
166
|
def append_string(self, string: str) -> None:
|
|
144
167
|
lower_str = string.strip().lower()
|
|
145
168
|
if lower_str in [
|
|
169
|
+
"/any",
|
|
146
170
|
"/video",
|
|
147
171
|
"/audio",
|
|
148
172
|
"/trim",
|
|
@@ -155,7 +179,10 @@ def repl_loop(): # pragma: no cover - interactive TTY loop, exercised manually
|
|
|
155
179
|
super().append_string(string)
|
|
156
180
|
|
|
157
181
|
history_file = os.path.expanduser("~/.pinak_cli_history")
|
|
158
|
-
|
|
182
|
+
try:
|
|
183
|
+
session = PromptSession(history=FilteredHistory(history_file))
|
|
184
|
+
except Exception:
|
|
185
|
+
session = None
|
|
159
186
|
|
|
160
187
|
kb = KeyBindings()
|
|
161
188
|
|
|
@@ -164,6 +191,7 @@ def repl_loop(): # pragma: no cover - interactive TTY loop, exercised manually
|
|
|
164
191
|
buffer = event.current_buffer
|
|
165
192
|
text = buffer.text.strip().lower()
|
|
166
193
|
if text in [
|
|
194
|
+
"/any",
|
|
167
195
|
"/video",
|
|
168
196
|
"/audio",
|
|
169
197
|
"/trim",
|
|
@@ -196,25 +224,44 @@ def repl_loop(): # pragma: no cover - interactive TTY loop, exercised manually
|
|
|
196
224
|
print_update_notice(updater.PINAK_PACKAGE, updater.PINAK_UPDATE_AVAILABLE)
|
|
197
225
|
has_shown_pinak_update = True
|
|
198
226
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
227
|
+
if session is not None:
|
|
228
|
+
try:
|
|
229
|
+
cmd = session.prompt(
|
|
230
|
+
"Pinak 🔱 > ",
|
|
231
|
+
default=default_cmd,
|
|
232
|
+
completer=commands_completer,
|
|
233
|
+
complete_while_typing=True,
|
|
234
|
+
style=custom_style,
|
|
235
|
+
mouse_support=False,
|
|
236
|
+
key_bindings=kb,
|
|
237
|
+
).strip()
|
|
238
|
+
except (EOFError, KeyboardInterrupt):
|
|
239
|
+
print_exit_screen(interrupted=True)
|
|
240
|
+
break
|
|
241
|
+
except Exception:
|
|
242
|
+
try:
|
|
243
|
+
cmd = input("Pinak 🔱 > ").strip()
|
|
244
|
+
except (EOFError, KeyboardInterrupt):
|
|
245
|
+
print_exit_screen(interrupted=True)
|
|
246
|
+
break
|
|
247
|
+
else:
|
|
248
|
+
try:
|
|
249
|
+
cmd = input("Pinak 🔱 > ").strip()
|
|
250
|
+
except (EOFError, KeyboardInterrupt):
|
|
251
|
+
print_exit_screen(interrupted=True)
|
|
252
|
+
break
|
|
208
253
|
|
|
209
254
|
default_cmd = ""
|
|
255
|
+
cmd = cmd.strip().lstrip("\ufeff")
|
|
210
256
|
|
|
211
257
|
if not cmd:
|
|
212
258
|
continue
|
|
213
259
|
|
|
214
260
|
lower_cmd = cmd.lower()
|
|
215
261
|
|
|
216
|
-
# Fallback if somehow submitted (e.g. mouse double click)
|
|
217
|
-
if lower_cmd in [
|
|
262
|
+
# Fallback if somehow submitted (e.g. mouse double click in prompt_toolkit)
|
|
263
|
+
if session is not None and lower_cmd in [
|
|
264
|
+
"/any",
|
|
218
265
|
"/video",
|
|
219
266
|
"/audio",
|
|
220
267
|
"/trim",
|
|
@@ -626,20 +673,66 @@ def repl_loop(): # pragma: no cover - interactive TTY loop, exercised manually
|
|
|
626
673
|
|
|
627
674
|
if cmd.startswith("/"):
|
|
628
675
|
if not (
|
|
629
|
-
lower_cmd.startswith("/
|
|
676
|
+
lower_cmd.startswith("/any ")
|
|
677
|
+
or lower_cmd.startswith("/audio ")
|
|
630
678
|
or lower_cmd.startswith("/video ")
|
|
631
679
|
or lower_cmd.startswith("/instagram ")
|
|
680
|
+
or lower_cmd in ("/any", "/video", "/audio", "/instagram")
|
|
632
681
|
):
|
|
633
682
|
print(f"{YELLOW}[!] Unknown command: {cmd}. Type /help for assistance.{RESET}")
|
|
634
683
|
continue
|
|
684
|
+
if lower_cmd in ("/any", "/video", "/audio", "/instagram"):
|
|
685
|
+
try:
|
|
686
|
+
raw_url = input("Enter URL to download: ").strip()
|
|
687
|
+
except (KeyboardInterrupt, EOFError):
|
|
688
|
+
print(f"{YELLOW}[*] Canceled.{RESET}")
|
|
689
|
+
continue
|
|
690
|
+
if not raw_url:
|
|
691
|
+
print(f"{RED}[!] Error: No URL provided.{RESET}")
|
|
692
|
+
continue
|
|
693
|
+
cmd = f"{cmd} {raw_url}"
|
|
694
|
+
lower_cmd = cmd.lower()
|
|
695
|
+
is_any = lower_cmd.startswith("/any ")
|
|
635
696
|
url, extract_audio, force, quality = parse_download_text(cmd)
|
|
636
|
-
handle_download(
|
|
697
|
+
handle_download(
|
|
698
|
+
url,
|
|
699
|
+
extract_audio=extract_audio,
|
|
700
|
+
force=force,
|
|
701
|
+
quality=quality,
|
|
702
|
+
is_any_command=is_any,
|
|
703
|
+
)
|
|
637
704
|
else:
|
|
638
|
-
if
|
|
705
|
+
if lower_cmd.startswith("any "):
|
|
706
|
+
url, extract_audio, force, quality = parse_download_text(cmd)
|
|
707
|
+
handle_download(
|
|
708
|
+
url,
|
|
709
|
+
extract_audio=extract_audio,
|
|
710
|
+
force=force,
|
|
711
|
+
quality=quality,
|
|
712
|
+
is_any_command=True,
|
|
713
|
+
)
|
|
714
|
+
elif lower_cmd == "any":
|
|
715
|
+
try:
|
|
716
|
+
raw_url = input("Enter URL to download: ").strip()
|
|
717
|
+
except (KeyboardInterrupt, EOFError):
|
|
718
|
+
print(f"{YELLOW}[*] Canceled.{RESET}")
|
|
719
|
+
continue
|
|
720
|
+
if not raw_url:
|
|
721
|
+
print(f"{RED}[!] Error: No URL provided.{RESET}")
|
|
722
|
+
continue
|
|
723
|
+
url, extract_audio, force, quality = parse_download_text(f"/any {raw_url}")
|
|
724
|
+
handle_download(
|
|
725
|
+
url,
|
|
726
|
+
extract_audio=extract_audio,
|
|
727
|
+
force=force,
|
|
728
|
+
quality=quality,
|
|
729
|
+
is_any_command=True,
|
|
730
|
+
)
|
|
731
|
+
elif cmd.lower().startswith("http"):
|
|
639
732
|
print(
|
|
640
|
-
f"{YELLOW}[!] Direct URL detected. Use /video, /audio, or /instagram before the link."
|
|
733
|
+
f"{YELLOW}[!] Direct URL detected. Use /any, /video, /audio, or /instagram before the link."
|
|
641
734
|
)
|
|
642
|
-
print(f" Example: /
|
|
735
|
+
print(f" Example: /any {cmd}{RESET}")
|
|
643
736
|
else:
|
|
644
737
|
print(
|
|
645
738
|
f"{YELLOW}[!] Unknown input: '{cmd}'. Type /help to see all commands.{RESET}"
|
|
@@ -711,12 +804,18 @@ def maybe_print_offline_notice() -> bool:
|
|
|
711
804
|
@app.command()
|
|
712
805
|
def main(
|
|
713
806
|
url: str | None = typer.Argument(None, help="The URL to download from"),
|
|
807
|
+
extra_arg: str | None = typer.Argument(
|
|
808
|
+
None, help="Optional secondary URL or command argument (e.g. pinak any <url>)"
|
|
809
|
+
),
|
|
714
810
|
output: str | None = typer.Option(None, "-o", "--output", help="Output directory"),
|
|
715
811
|
force: bool = typer.Option(False, "--force", help="Force download despite duplicates"),
|
|
716
812
|
audio: bool = typer.Option(False, "--audio", help="Extract audio directly as MP3"),
|
|
717
813
|
quality: str | None = typer.Option(
|
|
718
814
|
None, "--quality", help="Quality: best, worst, audio, 360p, 720p, 1080p"
|
|
719
815
|
),
|
|
816
|
+
any_link: bool = typer.Option(
|
|
817
|
+
False, "--any", help="Allow any type of link supported by yt-dlp"
|
|
818
|
+
),
|
|
720
819
|
doctor: bool = typer.Option(False, "--doctor", help="Run environment checks and exit"),
|
|
721
820
|
verbose: bool = typer.Option(False, "--verbose", "-v", help="Enable verbose debug output"),
|
|
722
821
|
log_file: str | None = typer.Option(
|
|
@@ -769,8 +868,13 @@ def main(
|
|
|
769
868
|
updater.start_background_update_check()
|
|
770
869
|
maybe_print_offline_notice()
|
|
771
870
|
|
|
871
|
+
if url in ("any", "/any"):
|
|
872
|
+
any_link = True
|
|
873
|
+
url = extra_arg
|
|
874
|
+
|
|
772
875
|
has_args = bool(
|
|
773
876
|
url
|
|
877
|
+
or any_link
|
|
774
878
|
or output
|
|
775
879
|
or force
|
|
776
880
|
or audio
|
|
@@ -786,7 +890,18 @@ def main(
|
|
|
786
890
|
from .commands.run import run_single
|
|
787
891
|
|
|
788
892
|
raise typer.Exit(
|
|
789
|
-
code=run_single(
|
|
893
|
+
code=run_single(
|
|
894
|
+
url,
|
|
895
|
+
output,
|
|
896
|
+
force,
|
|
897
|
+
audio,
|
|
898
|
+
quality,
|
|
899
|
+
doctor,
|
|
900
|
+
verbose,
|
|
901
|
+
dry_run,
|
|
902
|
+
as_json,
|
|
903
|
+
is_any=any_link,
|
|
904
|
+
)
|
|
790
905
|
)
|
|
791
906
|
repl_loop()
|
|
792
907
|
|
|
@@ -23,12 +23,40 @@ def open_output_folder() -> None:
|
|
|
23
23
|
os.makedirs(output_dir, exist_ok=True)
|
|
24
24
|
path = os.path.abspath(output_dir)
|
|
25
25
|
try:
|
|
26
|
+
import shutil
|
|
27
|
+
import subprocess
|
|
26
28
|
import webbrowser
|
|
27
29
|
|
|
30
|
+
opened = False
|
|
28
31
|
if sys.platform.startswith("win"):
|
|
29
|
-
os
|
|
30
|
-
|
|
32
|
+
if hasattr(os, "startfile"):
|
|
33
|
+
try:
|
|
34
|
+
os.startfile(path)
|
|
35
|
+
opened = True
|
|
36
|
+
except Exception:
|
|
37
|
+
pass
|
|
38
|
+
if not opened and shutil.which("explorer"):
|
|
39
|
+
try:
|
|
40
|
+
subprocess.run(["explorer", path], check=False) # nosec B603, B607
|
|
41
|
+
opened = True
|
|
42
|
+
except Exception:
|
|
43
|
+
pass
|
|
44
|
+
elif sys.platform == "darwin" and shutil.which("open"):
|
|
45
|
+
try:
|
|
46
|
+
subprocess.run(["open", path], check=False) # nosec B603, B607
|
|
47
|
+
opened = True
|
|
48
|
+
except Exception:
|
|
49
|
+
pass
|
|
50
|
+
elif shutil.which("xdg-open"):
|
|
51
|
+
try:
|
|
52
|
+
subprocess.run(["xdg-open", path], check=False) # nosec B603, B607
|
|
53
|
+
opened = True
|
|
54
|
+
except Exception:
|
|
55
|
+
pass
|
|
56
|
+
|
|
57
|
+
if not opened:
|
|
31
58
|
webbrowser.open(Path(path).as_uri())
|
|
59
|
+
|
|
32
60
|
print(f"{GREEN}[+] Opened: {path}{RESET}")
|
|
33
61
|
except Exception as e:
|
|
34
62
|
print(f"{RED}[X] Could not open folder: {e}{RESET}")
|
|
@@ -51,6 +79,10 @@ def print_help() -> None:
|
|
|
51
79
|
"View history; filters: failed, audio, media, search <text>",
|
|
52
80
|
),
|
|
53
81
|
("/batch <file>", "Download multiple URLs from a text file"),
|
|
82
|
+
(
|
|
83
|
+
"/any <url>",
|
|
84
|
+
"Download content from any platform supported by yt-dlp",
|
|
85
|
+
),
|
|
54
86
|
(
|
|
55
87
|
"/instagram <url>",
|
|
56
88
|
"Download post, reel, igtv from Instagram with description & metadata",
|
|
@@ -117,6 +117,7 @@ def run_single(
|
|
|
117
117
|
verbose: bool,
|
|
118
118
|
dry_run: bool,
|
|
119
119
|
as_json: bool,
|
|
120
|
+
is_any: bool = False,
|
|
120
121
|
) -> int:
|
|
121
122
|
"""Execute single-command mode. Returns the process exit code."""
|
|
122
123
|
if output:
|
|
@@ -161,6 +162,7 @@ def run_single(
|
|
|
161
162
|
force=force,
|
|
162
163
|
quality=norm_quality,
|
|
163
164
|
interactive=False,
|
|
165
|
+
is_any_command=is_any,
|
|
164
166
|
)
|
|
165
167
|
if as_json:
|
|
166
168
|
print(json.dumps(result_to_jsonable(result), indent=2))
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import contextlib
|
|
3
4
|
import json
|
|
4
5
|
import os
|
|
5
6
|
import shutil
|
|
@@ -148,17 +149,34 @@ def init_config() -> dict[str, Any]:
|
|
|
148
149
|
elif os.path.exists(LOCAL_CONFIG):
|
|
149
150
|
shutil.copy(LOCAL_CONFIG, CONFIG_FILE)
|
|
150
151
|
|
|
152
|
+
def _load_from_path(path: str) -> bool:
|
|
153
|
+
if not os.path.exists(path):
|
|
154
|
+
return False
|
|
155
|
+
with open(path, encoding="utf-8") as f:
|
|
156
|
+
raw = f.read().strip()
|
|
157
|
+
if not raw:
|
|
158
|
+
return False
|
|
159
|
+
data = json.loads(raw)
|
|
160
|
+
if isinstance(data, dict):
|
|
161
|
+
data = _migrate_legacy_keys(data)
|
|
162
|
+
settings = PinakSettings(**data)
|
|
163
|
+
config.update(settings.model_dump())
|
|
164
|
+
return True
|
|
165
|
+
return False
|
|
166
|
+
|
|
167
|
+
loaded = False
|
|
151
168
|
try:
|
|
152
|
-
|
|
153
|
-
with open(CONFIG_FILE, encoding="utf-8") as f:
|
|
154
|
-
data = json.load(f)
|
|
155
|
-
if isinstance(data, dict):
|
|
156
|
-
data = _migrate_legacy_keys(data)
|
|
157
|
-
# Load with Pydantic for validation
|
|
158
|
-
settings = PinakSettings(**data)
|
|
159
|
-
config.update(settings.model_dump())
|
|
169
|
+
loaded = _load_from_path(CONFIG_FILE)
|
|
160
170
|
except Exception:
|
|
161
|
-
|
|
171
|
+
loaded = False
|
|
172
|
+
|
|
173
|
+
# Fallback to backup file if primary CONFIG_FILE was missing, empty, or corrupted
|
|
174
|
+
if not loaded:
|
|
175
|
+
bak_file = CONFIG_FILE + ".bak"
|
|
176
|
+
with contextlib.suppress(Exception):
|
|
177
|
+
if _load_from_path(bak_file):
|
|
178
|
+
with contextlib.suppress(Exception):
|
|
179
|
+
shutil.copy2(bak_file, CONFIG_FILE)
|
|
162
180
|
|
|
163
181
|
return config
|
|
164
182
|
|
|
@@ -177,11 +195,28 @@ def save_config(config_data: dict[str, Any] | None = None) -> bool:
|
|
|
177
195
|
if config_data is not None:
|
|
178
196
|
config.update(config_data)
|
|
179
197
|
|
|
198
|
+
temp_file = CONFIG_FILE + ".tmp"
|
|
199
|
+
bak_file = CONFIG_FILE + ".bak"
|
|
180
200
|
try:
|
|
181
201
|
valid_settings = validate_settings(config)
|
|
182
202
|
config.update(valid_settings.model_dump())
|
|
183
|
-
|
|
184
|
-
|
|
203
|
+
serialized = valid_settings.model_dump_json(indent=4)
|
|
204
|
+
|
|
205
|
+
parent_dir = os.path.dirname(CONFIG_FILE)
|
|
206
|
+
if parent_dir:
|
|
207
|
+
os.makedirs(parent_dir, exist_ok=True)
|
|
208
|
+
|
|
209
|
+
with open(temp_file, "w", encoding="utf-8") as f:
|
|
210
|
+
f.write(serialized)
|
|
211
|
+
f.flush()
|
|
212
|
+
with contextlib.suppress(AttributeError, OSError):
|
|
213
|
+
os.fsync(f.fileno())
|
|
214
|
+
|
|
215
|
+
if os.path.exists(CONFIG_FILE):
|
|
216
|
+
with contextlib.suppress(Exception):
|
|
217
|
+
shutil.copy2(CONFIG_FILE, bak_file)
|
|
218
|
+
|
|
219
|
+
os.replace(temp_file, CONFIG_FILE)
|
|
185
220
|
return True
|
|
186
221
|
except ConfigError as ce:
|
|
187
222
|
print(f"\033[91m[X] Could not write {CONFIG_FILE}: {ce}\033[0m")
|
|
@@ -193,3 +228,7 @@ def save_config(config_data: dict[str, Any] | None = None) -> bool:
|
|
|
193
228
|
"\033[93m[!] Tip: check that values are valid (e.g. quality=best/720p, video_format=mp4). Run `pinak --doctor` for details.\033[0m"
|
|
194
229
|
)
|
|
195
230
|
return False
|
|
231
|
+
finally:
|
|
232
|
+
if os.path.exists(temp_file):
|
|
233
|
+
with contextlib.suppress(Exception):
|
|
234
|
+
os.remove(temp_file)
|
|
@@ -158,15 +158,37 @@ def run_doctor(verbose: bool = False) -> bool:
|
|
|
158
158
|
timeout = 10.0
|
|
159
159
|
internet_ok = False
|
|
160
160
|
connected_host = ""
|
|
161
|
-
for host in (
|
|
161
|
+
for host in (
|
|
162
|
+
"https://www.google.com",
|
|
163
|
+
"https://1.1.1.1",
|
|
164
|
+
"https://www.cloudflare.com",
|
|
165
|
+
"https://httpbin.org/status/200",
|
|
166
|
+
):
|
|
162
167
|
try:
|
|
163
168
|
# Fixed HTTPS connectivity probe — no user-controlled scheme. nosec B310
|
|
164
|
-
urllib.request.
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
169
|
+
req = urllib.request.Request(host, headers={"User-Agent": "PinakCLI"})
|
|
170
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp: # nosec B310
|
|
171
|
+
if hasattr(resp, "close"):
|
|
172
|
+
resp.close()
|
|
173
|
+
internet_ok = True
|
|
174
|
+
connected_host = host.split("//")[-1].split("/")[0]
|
|
175
|
+
break
|
|
168
176
|
except Exception:
|
|
169
177
|
continue
|
|
178
|
+
|
|
179
|
+
if not internet_ok:
|
|
180
|
+
import socket
|
|
181
|
+
|
|
182
|
+
sock_timeout = min(timeout, 2.0)
|
|
183
|
+
for ip, port in (("1.1.1.1", 53), ("8.8.8.8", 53)):
|
|
184
|
+
try:
|
|
185
|
+
with socket.create_connection((ip, port), timeout=sock_timeout):
|
|
186
|
+
internet_ok = True
|
|
187
|
+
connected_host = f"{ip}:{port} (socket fallback)"
|
|
188
|
+
break
|
|
189
|
+
except Exception:
|
|
190
|
+
continue
|
|
191
|
+
|
|
170
192
|
checks.append(
|
|
171
193
|
(
|
|
172
194
|
"Internet Connection",
|