vidsmith 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.
Files changed (95) hide show
  1. vidsmith/__init__.py +3 -0
  2. vidsmith/cli/__init__.py +0 -0
  3. vidsmith/cli/app.py +184 -0
  4. vidsmith/cli/commands/__init__.py +0 -0
  5. vidsmith/cli/doctor.py +218 -0
  6. vidsmith/cli/executor.py +1264 -0
  7. vidsmith/cli/menus.py +186 -0
  8. vidsmith/cli/output.py +61 -0
  9. vidsmith/cli/summary/__init__.py +5 -0
  10. vidsmith/cli/summary/builder.py +298 -0
  11. vidsmith/cli/summary/model.py +24 -0
  12. vidsmith/cli/summary/renderer.py +74 -0
  13. vidsmith/cli/wizard/__init__.py +33 -0
  14. vidsmith/cli/wizard/base.py +160 -0
  15. vidsmith/cli/wizard/chrome.py +138 -0
  16. vidsmith/cli/wizard/dispatcher.py +176 -0
  17. vidsmith/cli/wizard/registry.py +70 -0
  18. vidsmith/cli/wizard/steps/__init__.py +16 -0
  19. vidsmith/cli/wizard/steps/_base.py +39 -0
  20. vidsmith/cli/wizard/steps/choice.py +119 -0
  21. vidsmith/cli/wizard/steps/confirm.py +83 -0
  22. vidsmith/cli/wizard/steps/multi_select.py +114 -0
  23. vidsmith/cli/wizard/steps/numeric.py +86 -0
  24. vidsmith/cli/wizard/steps/text_input.py +86 -0
  25. vidsmith/cli/wizard/steps/toggle.py +71 -0
  26. vidsmith/cli/wizard/wizards/__init__.py +13 -0
  27. vidsmith/cli/wizard/wizards/audio.py +128 -0
  28. vidsmith/cli/wizard/wizards/playlist.py +104 -0
  29. vidsmith/cli/wizard/wizards/settings.py +197 -0
  30. vidsmith/cli/wizard/wizards/subtitles.py +81 -0
  31. vidsmith/cli/wizard/wizards/transcript.py +104 -0
  32. vidsmith/cli/wizard/wizards/video.py +276 -0
  33. vidsmith/config/__init__.py +6 -0
  34. vidsmith/downloader/__init__.py +31 -0
  35. vidsmith/downloader/cleanup.py +172 -0
  36. vidsmith/downloader/engine.py +105 -0
  37. vidsmith/downloader/job.py +151 -0
  38. vidsmith/downloader/manager.py +124 -0
  39. vidsmith/downloader/progress.py +77 -0
  40. vidsmith/downloader/queue.py +96 -0
  41. vidsmith/downloader/validator.py +58 -0
  42. vidsmith/downloader/validators/__init__.py +20 -0
  43. vidsmith/downloader/validators/audio.py +57 -0
  44. vidsmith/downloader/validators/context.py +96 -0
  45. vidsmith/downloader/validators/file.py +24 -0
  46. vidsmith/downloader/validators/metadata.py +33 -0
  47. vidsmith/downloader/validators/models.py +77 -0
  48. vidsmith/downloader/validators/subtitle.py +101 -0
  49. vidsmith/downloader/validators/thumbnail.py +104 -0
  50. vidsmith/main.py +86 -0
  51. vidsmith/metadata/__init__.py +0 -0
  52. vidsmith/metadata/analyzer.py +220 -0
  53. vidsmith/models/__init__.py +3 -0
  54. vidsmith/models/media.py +66 -0
  55. vidsmith/playlist/__init__.py +48 -0
  56. vidsmith/playlist/batch.py +142 -0
  57. vidsmith/playlist/engine.py +163 -0
  58. vidsmith/playlist/exceptions.py +25 -0
  59. vidsmith/playlist/models.py +206 -0
  60. vidsmith/playlist/queue.py +186 -0
  61. vidsmith/processing/__init__.py +37 -0
  62. vidsmith/processing/exceptions.py +29 -0
  63. vidsmith/processing/ffmpeg.py +314 -0
  64. vidsmith/processing/models.py +98 -0
  65. vidsmith/processing/processor.py +23 -0
  66. vidsmith/providers/__init__.py +40 -0
  67. vidsmith/providers/base.py +99 -0
  68. vidsmith/providers/capabilities.py +72 -0
  69. vidsmith/providers/metadata.py +80 -0
  70. vidsmith/providers/results.py +35 -0
  71. vidsmith/providers/youtube.py +2018 -0
  72. vidsmith/settings/__init__.py +35 -0
  73. vidsmith/settings/store.py +193 -0
  74. vidsmith/subtitle/__init__.py +151 -0
  75. vidsmith/thumbnail/__init__.py +0 -0
  76. vidsmith/transcript/__init__.py +44 -0
  77. vidsmith/transcript/cleaner.py +97 -0
  78. vidsmith/transcript/engine.py +133 -0
  79. vidsmith/transcript/exceptions.py +21 -0
  80. vidsmith/transcript/json_export.py +31 -0
  81. vidsmith/transcript/markdown.py +27 -0
  82. vidsmith/transcript/models.py +87 -0
  83. vidsmith/transcript/parser.py +178 -0
  84. vidsmith/transcript/text.py +35 -0
  85. vidsmith/utils/__init__.py +0 -0
  86. vidsmith/utils/console.py +22 -0
  87. vidsmith/utils/environment.py +157 -0
  88. vidsmith/utils/exceptions.py +26 -0
  89. vidsmith/utils/logging.py +45 -0
  90. vidsmith/utils/validators.py +22 -0
  91. vidsmith-1.0.0.dist-info/METADATA +328 -0
  92. vidsmith-1.0.0.dist-info/RECORD +95 -0
  93. vidsmith-1.0.0.dist-info/WHEEL +4 -0
  94. vidsmith-1.0.0.dist-info/entry_points.txt +2 -0
  95. vidsmith-1.0.0.dist-info/licenses/LICENSE +21 -0
vidsmith/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """VidSmith — production-grade terminal media processing tool."""
2
+
3
+ __version__ = "0.1.0"
File without changes
vidsmith/cli/app.py ADDED
@@ -0,0 +1,184 @@
1
+ """
2
+ Interactive application loop.
3
+ Entry: run()
4
+ Flow: banner → URL prompt → analyse (spinner) → type-dispatch → menu → wizard loop
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from rich.panel import Panel
10
+ from rich.prompt import Prompt
11
+ from rich.status import Status
12
+ from rich.text import Text
13
+
14
+ from vidsmith.cli.menus import (
15
+ ACTION_BACK,
16
+ show_playlist_menu,
17
+ show_video_menu,
18
+ )
19
+ from vidsmith.cli.output import print_banner, print_rule
20
+ from vidsmith.cli.wizard.dispatcher import dispatch_wizard
21
+ from vidsmith.metadata.analyzer import analyze
22
+ from vidsmith.models.media import AnalysisResult, MediaType
23
+ from vidsmith.utils.console import console
24
+ from vidsmith.utils.exceptions import AnalysisError, UnsupportedURLError
25
+ from vidsmith.utils.validators import is_youtube_url
26
+
27
+ # ── URL collection ────────────────────────────────────────────────────────────
28
+
29
+
30
+ def _prompt_url() -> str | None:
31
+ """Show URL input prompt. Returns None when the user quits.
32
+
33
+ Returns the sentinel ``"__settings__"`` when the user asks for settings.
34
+ """
35
+ console.print()
36
+ print_rule()
37
+ console.print(
38
+ Panel(
39
+ Text.from_markup(
40
+ "[dim]Paste a YouTube URL and press[/] [bold cyan]Enter[/]\n"
41
+ "[dim]Type[/] [bold]s[/] [dim]for Settings ·[/] [bold]q[/] [dim]to quit[/]"
42
+ ),
43
+ border_style="dim",
44
+ padding=(1, 4),
45
+ )
46
+ )
47
+ console.print()
48
+
49
+ raw = Prompt.ask(" [bold cyan]URL[/]").strip()
50
+ if raw.lower() in {"q", "quit", "exit"}:
51
+ return None
52
+ if raw.lower() in {"s", "settings"}:
53
+ return "__settings__"
54
+ return raw
55
+
56
+
57
+ # ── analysis ──────────────────────────────────────────────────────────────────
58
+
59
+
60
+ def _analyse_url(url: str) -> AnalysisResult | None:
61
+ """
62
+ Run URL analysis under a Rich spinner.
63
+ Returns None and prints an error on failure.
64
+ """
65
+ if not is_youtube_url(url):
66
+ console.print(
67
+ Panel(
68
+ Text.from_markup(
69
+ "[error]Not a recognised YouTube URL.[/]\n"
70
+ "[dim]Supported: youtube.com/watch, youtu.be, /shorts/, /playlist[/]"
71
+ ),
72
+ border_style="red",
73
+ title="[error]Invalid URL[/]",
74
+ padding=(0, 2),
75
+ )
76
+ )
77
+ return None
78
+
79
+ with Status(
80
+ "[bold cyan]Analysing URL…[/]",
81
+ spinner="dots",
82
+ spinner_style="cyan",
83
+ console=console,
84
+ ):
85
+ try:
86
+ return analyze(url)
87
+ except UnsupportedURLError as exc:
88
+ _print_error("Unsupported URL", str(exc))
89
+ return None
90
+ except AnalysisError as exc:
91
+ _print_error("Analysis Failed", str(exc))
92
+ return None
93
+ except Exception as exc:
94
+ _print_error("Unexpected Error", str(exc))
95
+ return None
96
+
97
+
98
+ def _print_error(title: str, body: str) -> None:
99
+ console.print(
100
+ Panel(
101
+ f"[error]{body}[/]",
102
+ title=f"[error]{title}[/]",
103
+ border_style="red",
104
+ padding=(0, 2),
105
+ )
106
+ )
107
+
108
+
109
+ # ── type-dispatch ─────────────────────────────────────────────────────────────
110
+
111
+
112
+ def _show_menu(result: AnalysisResult) -> str | None:
113
+ if result.media_type == MediaType.PLAYLIST:
114
+ return show_playlist_menu(result)
115
+ return show_video_menu(result) # VIDEO and SHORTS share the same menu
116
+
117
+
118
+ # ── action handler ────────────────────────────────────────────────────────────
119
+
120
+
121
+ def _handle_action(action: str, result: AnalysisResult) -> None:
122
+ dispatch_wizard(action, result)
123
+
124
+
125
+ # ── main loop ─────────────────────────────────────────────────────────────────
126
+
127
+
128
+ def run() -> None:
129
+ console.clear()
130
+ print_banner()
131
+
132
+ from vidsmith.settings.store import reload_settings
133
+
134
+ reload_settings()
135
+
136
+ try:
137
+ while True:
138
+ try:
139
+ url = _prompt_url()
140
+ except KeyboardInterrupt:
141
+ _goodbye()
142
+ break
143
+
144
+ if url is None:
145
+ _goodbye()
146
+ break
147
+
148
+ if url == "__settings__":
149
+ dispatch_wizard("settings")
150
+ continue
151
+
152
+ result = _analyse_url(url)
153
+ if result is None:
154
+ continue # bad URL → re-prompt
155
+
156
+ # Inner menu loop: stay on the same result until user goes Back or Quits
157
+ while True:
158
+ try:
159
+ action = _show_menu(result)
160
+
161
+ if action is None: # user chose Quit
162
+ _goodbye()
163
+ return
164
+
165
+ if action == ACTION_BACK: # user chose Back → outer loop
166
+ break
167
+
168
+ _handle_action(action, result)
169
+ except KeyboardInterrupt:
170
+ console.print("\n [yellow]Action cancelled.[/]")
171
+ except KeyboardInterrupt:
172
+ _goodbye()
173
+
174
+
175
+ def _goodbye() -> None:
176
+ console.print()
177
+ console.print(
178
+ Panel(
179
+ Text.from_markup("[bold cyan]Thanks for using VidSmith. Goodbye![/]"),
180
+ border_style="cyan",
181
+ padding=(0, 4),
182
+ )
183
+ )
184
+ console.print()
File without changes
vidsmith/cli/doctor.py ADDED
@@ -0,0 +1,218 @@
1
+ """``vidsmith doctor`` — environment diagnostics.
2
+
3
+ Inspects every optional and required tool VidSmith relies on and prints a
4
+ clear, colour-coded report with actionable install hints. Modelled on
5
+ ``brew doctor`` / ``gh`` status output. It never raises: any probe that fails
6
+ is reported as a failed check rather than crashing the command.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import platform
12
+ import shutil
13
+ import socket
14
+ import sys
15
+ from dataclasses import dataclass
16
+ from importlib.metadata import PackageNotFoundError, version
17
+ from importlib.util import find_spec
18
+
19
+ from rich.panel import Panel
20
+ from rich.table import Table
21
+ from rich.text import Text
22
+
23
+ from vidsmith.config import APP_NAME, APP_VERSION
24
+ from vidsmith.utils.console import console
25
+
26
+ _OK = "ok"
27
+ _WARN = "warn"
28
+ _FAIL = "fail"
29
+
30
+ _MARKS = {_OK: "[green]✓[/]", _WARN: "[yellow]⚠[/]", _FAIL: "[red]✗[/]"}
31
+
32
+
33
+ @dataclass(slots=True)
34
+ class Check:
35
+ """A single diagnostic row."""
36
+
37
+ name: str
38
+ status: str
39
+ detail: str = ""
40
+ hint: str = ""
41
+
42
+
43
+ # ── individual probes (each returns a Check, never raises) ────────────────────
44
+
45
+
46
+ def _check_python() -> Check:
47
+ version_text = platform.python_version()
48
+ if sys.version_info >= (3, 12):
49
+ return Check("Python", _OK, version_text)
50
+ return Check(
51
+ "Python",
52
+ _FAIL,
53
+ f"{version_text} (3.12+ required)",
54
+ "Install Python 3.12 or newer from https://python.org",
55
+ )
56
+
57
+
58
+ def _check_package(
59
+ name: str,
60
+ module: str,
61
+ dist: str,
62
+ *,
63
+ required: bool,
64
+ hint: str,
65
+ ) -> Check:
66
+ """Report on an importable Python package by distribution name."""
67
+ spec_present = find_spec(module) is not None
68
+ if not spec_present:
69
+ return Check(name, _FAIL if required else _WARN, "Not installed", hint)
70
+ try:
71
+ detail = version(dist)
72
+ except PackageNotFoundError:
73
+ detail = "installed"
74
+ return Check(name, _OK, detail)
75
+
76
+
77
+ def _check_binary(
78
+ name: str,
79
+ candidates: tuple[str, ...],
80
+ *,
81
+ required: bool,
82
+ hint: str,
83
+ ) -> Check:
84
+ """Report on an external executable resolved from PATH."""
85
+ for candidate in candidates:
86
+ path = shutil.which(candidate)
87
+ if path:
88
+ return Check(name, _OK, path)
89
+ return Check(name, _FAIL if required else _WARN, "Not installed", hint)
90
+
91
+
92
+ def _check_ffmpeg() -> Check:
93
+ check = _check_binary(
94
+ "FFmpeg",
95
+ ("ffmpeg",),
96
+ required=False,
97
+ hint="Install FFmpeg from https://ffmpeg.org (or `pip install imageio-ffmpeg`).",
98
+ )
99
+ if check.status != _OK:
100
+ # imageio-ffmpeg provides a bundled binary as a fallback.
101
+ try:
102
+ import imageio_ffmpeg
103
+
104
+ return Check("FFmpeg", _OK, f"bundled: {imageio_ffmpeg.get_ffmpeg_exe()}")
105
+ except Exception:
106
+ pass
107
+ return check
108
+
109
+
110
+ def _check_socket(host: str, name: str, ok_detail: str) -> Check:
111
+ try:
112
+ socket.setdefaulttimeout(6)
113
+ with socket.create_connection((host, 443)):
114
+ return Check(name, _OK, ok_detail)
115
+ except OSError as exc:
116
+ return Check(
117
+ name, _WARN, f"Unreachable ({exc.__class__.__name__})", "Check your network connection."
118
+ )
119
+
120
+
121
+ def collect_checks(*, network: bool = True) -> list[Check]:
122
+ """Run every diagnostic and return the ordered result list."""
123
+ checks: list[Check] = [
124
+ _check_python(),
125
+ _check_package(
126
+ "yt-dlp",
127
+ "yt_dlp",
128
+ "yt-dlp",
129
+ required=True,
130
+ hint="Reinstall VidSmith or run `pip install -U yt-dlp`.",
131
+ ),
132
+ _check_ffmpeg(),
133
+ _check_package(
134
+ "curl_cffi",
135
+ "curl_cffi",
136
+ "curl_cffi",
137
+ required=False,
138
+ hint="`pip install curl_cffi` — enables browser impersonation and reduces HTTP 429.",
139
+ ),
140
+ _check_package(
141
+ "mutagen",
142
+ "mutagen",
143
+ "mutagen",
144
+ required=False,
145
+ hint="`pip install mutagen` — writes MP4/M4A cover-art atoms visible in Explorer.",
146
+ ),
147
+ _check_binary(
148
+ "AtomicParsley",
149
+ ("AtomicParsley", "atomicparsley"),
150
+ required=False,
151
+ hint="Optional alternative to mutagen for MP4 cover art.",
152
+ ),
153
+ _check_binary(
154
+ "Node.js",
155
+ ("node",),
156
+ required=False,
157
+ hint="Optional JavaScript runtime; Deno is preferred.",
158
+ ),
159
+ _check_binary(
160
+ "Deno",
161
+ ("deno",),
162
+ required=False,
163
+ hint="`https://deno.com` — recommended JS runtime for full YouTube format parity.",
164
+ ),
165
+ ]
166
+ if network:
167
+ checks.append(_check_socket("one.one.one.one", "Internet", "Connected"))
168
+ checks.append(_check_socket("www.youtube.com", "YouTube Access", "Reachable"))
169
+ return checks
170
+
171
+
172
+ def run_doctor(*, network: bool = True) -> int:
173
+ """Render the diagnostics report. Returns a process exit code.
174
+
175
+ Exit code is non-zero only when a *required* check fails, so scripts and CI
176
+ can gate on it; optional-tool warnings keep the exit code at 0.
177
+ """
178
+ checks = collect_checks(network=network)
179
+
180
+ table = Table.grid(padding=(0, 2))
181
+ table.add_column(justify="center", no_wrap=True)
182
+ table.add_column(style="bold", no_wrap=True)
183
+ table.add_column(style="white")
184
+ for check in checks:
185
+ table.add_row(_MARKS.get(check.status, "?"), check.name, check.detail)
186
+
187
+ console.print()
188
+ console.print(
189
+ Panel(
190
+ table,
191
+ title=f"[bold cyan] {APP_NAME} Doctor · v{APP_VERSION} [/]",
192
+ border_style="cyan",
193
+ padding=(1, 2),
194
+ )
195
+ )
196
+
197
+ hints = [check for check in checks if check.status != _OK and check.hint]
198
+ if hints:
199
+ lines = [
200
+ f"{_MARKS[check.status]} [bold]{check.name}[/] — {check.detail or 'unavailable'}\n"
201
+ f" [dim]{check.hint}[/]"
202
+ for check in hints
203
+ ]
204
+ console.print(
205
+ Panel(
206
+ Text.from_markup("\n".join(lines)),
207
+ title="[yellow] Recommendations [/]",
208
+ border_style="yellow",
209
+ padding=(1, 2),
210
+ )
211
+ )
212
+ else:
213
+ console.print("[green]Everything looks good — VidSmith is fully equipped.[/]\n")
214
+
215
+ has_required_failure = any(
216
+ check.status == _FAIL and check.name in {"Python", "yt-dlp"} for check in checks
217
+ )
218
+ return 1 if has_required_failure else 0