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.
Files changed (57) hide show
  1. {pinak_cli-1.2.1/pinak_cli.egg-info → pinak_cli-1.3.0}/PKG-INFO +1 -1
  2. pinak_cli-1.3.0/pinak/__init__.py +2 -0
  3. {pinak_cli-1.2.1 → pinak_cli-1.3.0}/pinak/cli.py +146 -31
  4. {pinak_cli-1.2.1 → pinak_cli-1.3.0}/pinak/commands/info.py +34 -2
  5. {pinak_cli-1.2.1 → pinak_cli-1.3.0}/pinak/commands/run.py +2 -0
  6. {pinak_cli-1.2.1 → pinak_cli-1.3.0}/pinak/config.py +50 -11
  7. {pinak_cli-1.2.1 → pinak_cli-1.3.0}/pinak/doctor.py +27 -5
  8. {pinak_cli-1.2.1 → pinak_cli-1.3.0}/pinak/downloader.py +175 -43
  9. {pinak_cli-1.2.1 → pinak_cli-1.3.0}/pinak/exceptions.py +7 -0
  10. {pinak_cli-1.2.1 → pinak_cli-1.3.0}/pinak/ffmpeg_setup.py +8 -15
  11. {pinak_cli-1.2.1 → pinak_cli-1.3.0}/pinak/history.py +153 -101
  12. {pinak_cli-1.2.1 → pinak_cli-1.3.0}/pinak/logging_setup.py +9 -5
  13. pinak_cli-1.3.0/pinak/notify.py +132 -0
  14. {pinak_cli-1.2.1 → pinak_cli-1.3.0}/pinak/updater.py +66 -1
  15. {pinak_cli-1.2.1 → pinak_cli-1.3.0}/pinak/utils.py +188 -11
  16. {pinak_cli-1.2.1 → pinak_cli-1.3.0/pinak_cli.egg-info}/PKG-INFO +1 -1
  17. {pinak_cli-1.2.1 → pinak_cli-1.3.0}/pyproject.toml +1 -1
  18. {pinak_cli-1.2.1 → pinak_cli-1.3.0}/tests/test_cli.py +113 -0
  19. {pinak_cli-1.2.1 → pinak_cli-1.3.0}/tests/test_config.py +41 -0
  20. {pinak_cli-1.2.1 → pinak_cli-1.3.0}/tests/test_core_extra.py +18 -0
  21. {pinak_cli-1.2.1 → pinak_cli-1.3.0}/tests/test_display.py +29 -1
  22. {pinak_cli-1.2.1 → pinak_cli-1.3.0}/tests/test_doctor_updater.py +49 -0
  23. pinak_cli-1.3.0/tests/test_downloader_unit.py +300 -0
  24. {pinak_cli-1.2.1 → pinak_cli-1.3.0}/tests/test_exceptions_parse.py +19 -0
  25. {pinak_cli-1.2.1 → pinak_cli-1.3.0}/tests/test_history.py +28 -0
  26. {pinak_cli-1.2.1 → pinak_cli-1.3.0}/tests/test_release_readiness.py +60 -0
  27. {pinak_cli-1.2.1 → pinak_cli-1.3.0}/tests/test_self_update.py +62 -0
  28. {pinak_cli-1.2.1 → pinak_cli-1.3.0}/tests/test_utils.py +46 -0
  29. pinak_cli-1.2.1/pinak/__init__.py +0 -2
  30. pinak_cli-1.2.1/pinak/notify.py +0 -71
  31. pinak_cli-1.2.1/tests/test_downloader_unit.py +0 -88
  32. {pinak_cli-1.2.1 → pinak_cli-1.3.0}/CLI_README.md +0 -0
  33. {pinak_cli-1.2.1 → pinak_cli-1.3.0}/LICENSE +0 -0
  34. {pinak_cli-1.2.1 → pinak_cli-1.3.0}/pinak/__main__.py +0 -0
  35. {pinak_cli-1.2.1 → pinak_cli-1.3.0}/pinak/commands/__init__.py +0 -0
  36. {pinak_cli-1.2.1 → pinak_cli-1.3.0}/pinak/commands/panels.py +0 -0
  37. {pinak_cli-1.2.1 → pinak_cli-1.3.0}/pinak/instagram.py +0 -0
  38. {pinak_cli-1.2.1 → pinak_cli-1.3.0}/pinak/retry.py +0 -0
  39. {pinak_cli-1.2.1 → pinak_cli-1.3.0}/pinak/subproc.py +0 -0
  40. {pinak_cli-1.2.1 → pinak_cli-1.3.0}/pinak/uninstall.py +0 -0
  41. {pinak_cli-1.2.1 → pinak_cli-1.3.0}/pinak_cli.egg-info/SOURCES.txt +0 -0
  42. {pinak_cli-1.2.1 → pinak_cli-1.3.0}/pinak_cli.egg-info/dependency_links.txt +0 -0
  43. {pinak_cli-1.2.1 → pinak_cli-1.3.0}/pinak_cli.egg-info/entry_points.txt +0 -0
  44. {pinak_cli-1.2.1 → pinak_cli-1.3.0}/pinak_cli.egg-info/requires.txt +0 -0
  45. {pinak_cli-1.2.1 → pinak_cli-1.3.0}/pinak_cli.egg-info/top_level.txt +0 -0
  46. {pinak_cli-1.2.1 → pinak_cli-1.3.0}/setup.cfg +0 -0
  47. {pinak_cli-1.2.1 → pinak_cli-1.3.0}/tests/test_downloader_e2e.py +0 -0
  48. {pinak_cli-1.2.1 → pinak_cli-1.3.0}/tests/test_engine_flows.py +0 -0
  49. {pinak_cli-1.2.1 → pinak_cli-1.3.0}/tests/test_engine_paths.py +0 -0
  50. {pinak_cli-1.2.1 → pinak_cli-1.3.0}/tests/test_entry.py +0 -0
  51. {pinak_cli-1.2.1 → pinak_cli-1.3.0}/tests/test_fuzz.py +0 -0
  52. {pinak_cli-1.2.1 → pinak_cli-1.3.0}/tests/test_instagram_unit.py +0 -0
  53. {pinak_cli-1.2.1 → pinak_cli-1.3.0}/tests/test_reliability.py +0 -0
  54. {pinak_cli-1.2.1 → pinak_cli-1.3.0}/tests/test_retry.py +0 -0
  55. {pinak_cli-1.2.1 → pinak_cli-1.3.0}/tests/test_run_single.py +0 -0
  56. {pinak_cli-1.2.1 → pinak_cli-1.3.0}/tests/test_subproc.py +0 -0
  57. {pinak_cli-1.2.1 → pinak_cli-1.3.0}/tests/test_uninstall.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pinak-cli
3
- Version: 1.2.1
3
+ Version: 1.3.0
4
4
  Summary: A powerful, aesthetic command-line media downloader using yt-dlp
5
5
  Author: Pinak Team
6
6
  License-Expression: MIT
@@ -0,0 +1,2 @@
1
+ # Pinak CLI Package
2
+ __version__ = "1.3.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
- @q.application.key_bindings.add(Keys.Escape, eager=True)
61
- def _(event):
62
- event.app.exit(result=None)
61
+ @q.application.key_bindings.add(Keys.Escape, eager=True)
62
+ def _(event):
63
+ event.app.exit(result=None)
63
64
 
64
- except AttributeError:
65
- from prompt_toolkit.key_binding import KeyBindings, merge_key_bindings
65
+ except AttributeError:
66
+ from prompt_toolkit.key_binding import KeyBindings, merge_key_bindings
66
67
 
67
- kb = KeyBindings()
68
+ kb = KeyBindings()
68
69
 
69
- @kb.add(Keys.Escape, eager=True)
70
- def _(event):
71
- event.app.exit(result=None)
70
+ @kb.add(Keys.Escape, eager=True)
71
+ def _(event):
72
+ event.app.exit(result=None)
72
73
 
73
- q.application.key_bindings = merge_key_bindings([q.application.key_bindings, kb])
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
- return q.ask()
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
- console.print("[yellow][!] Before we begin, please tell us your name.[/]")
88
- username = ask_with_esc(questionary.text("Enter your name:"))
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
- session = PromptSession(history=FilteredHistory(history_file))
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
- cmd = session.prompt(
200
- "Pinak 🔱 > ",
201
- default=default_cmd,
202
- completer=commands_completer,
203
- complete_while_typing=True,
204
- style=custom_style,
205
- mouse_support=False,
206
- key_bindings=kb,
207
- ).strip()
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("/audio ")
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(url, extract_audio=extract_audio, force=force, quality=quality)
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 cmd.lower().startswith("http"):
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: /instagram {cmd}{RESET}")
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(url, output, force, audio, quality, doctor, verbose, dry_run, as_json)
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.startfile(path)
30
- else:
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
- if os.path.exists(CONFIG_FILE):
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
- pass
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
- with open(CONFIG_FILE, "w", encoding="utf-8") as f:
184
- f.write(valid_settings.model_dump_json(indent=4))
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 ("https://www.google.com", "https://1.1.1.1"):
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.urlopen(host, timeout=timeout).close() # nosec B310
165
- internet_ok = True
166
- connected_host = host.split("//")[-1]
167
- break
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",