@codebend3r/che 1.0.0 → 1.2.0

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 (53) hide show
  1. package/README.md +65 -25
  2. package/bin/art.py +123 -0
  3. package/bin/che.py +209 -568
  4. package/bin/commands.py +220 -231
  5. package/bin/doctor.py +220 -0
  6. package/bin/drives/eject-all-drives.py +21 -38
  7. package/bin/drives/mount-all-drives.py +18 -62
  8. package/bin/drives/ping-nas.py +77 -105
  9. package/bin/files/compress-folders.py +7 -11
  10. package/bin/files/delete-by-ext.py +10 -14
  11. package/bin/files/delete-empty-folders.py +33 -37
  12. package/bin/files/delete-smb-files.py +12 -21
  13. package/bin/files/files-under-size.py +5 -11
  14. package/bin/files/find-largest-files.py +21 -25
  15. package/bin/files/list-permission.py +40 -0
  16. package/bin/files/make-alpha-dir.py +2 -0
  17. package/bin/git/all-actions.py +350 -253
  18. package/bin/git/checkout-my-branches.py +42 -63
  19. package/bin/git/clean-stale-branches.py +23 -48
  20. package/bin/git/prune-worktrees.py +12 -51
  21. package/bin/git/sync-all-branches.py +54 -283
  22. package/bin/git/update-local-branches.py +67 -73
  23. package/bin/gitutils.py +263 -0
  24. package/bin/install.py +393 -1188
  25. package/bin/machine.py +345 -0
  26. package/bin/media.py +127 -0
  27. package/bin/menu.py +437 -0
  28. package/bin/nas.py +102 -0
  29. package/bin/rcfile.py +380 -0
  30. package/bin/shellgen.py +508 -428
  31. package/bin/system/btop-launch.py +17 -13
  32. package/bin/system/update-brew.py +57 -68
  33. package/bin/tui.py +156 -153
  34. package/bin/utils.py +165 -237
  35. package/bin/video/delete-duplicate-videos.py +64 -62
  36. package/bin/video/detect-green-magenta-videos.py +105 -95
  37. package/bin/video/find-movie-by-year.py +3 -0
  38. package/bin/video/find-video-mkv-issues.py +48 -65
  39. package/bin/video/fix-codecs.py +25 -30
  40. package/bin/video/largest-tv-shows.py +60 -57
  41. package/bin/video/remove-metadata.py +3 -6
  42. package/bin/video/rename-video-file.py +74 -84
  43. package/bin/video/scan-videos-audio-language.py +16 -33
  44. package/bin/video/show-codecs.py +20 -24
  45. package/bin/video/validate-video-files.py +5 -8
  46. package/bin/video/video-list.py +17 -20
  47. package/package.json +4 -10
  48. package/shell/che.bash +33 -33
  49. package/shell/che.fish +7 -7
  50. package/shell/che.zsh +33 -33
  51. package/shell/completions/_che +19 -19
  52. package/shell/completions/che.bash +15 -15
  53. package/shell/completions/che.fish +34 -50
@@ -20,27 +20,30 @@ needed now.
20
20
  from __future__ import annotations
21
21
 
22
22
  import sys
23
+ from collections import Counter
24
+ from collections.abc import Callable
23
25
  from pathlib import Path
26
+ from typing import NamedTuple
24
27
 
25
28
  sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
26
29
 
30
+ from media import probe_media
27
31
  from utils import (
28
32
  add_bool_flag,
29
33
  build_parser,
30
- ffprobe_entries,
31
34
  format_bytes,
32
35
  info,
33
36
  iter_files,
34
37
  note,
38
+ note_settings,
35
39
  require_binary,
36
- run,
37
40
  run_cli,
38
41
  scan_root,
39
42
  success,
40
43
  warning,
41
44
  )
42
45
 
43
- __version__ = "2.1.0"
46
+ __version__ = "2.1.1"
44
47
 
45
48
  # (score penalty, note). An empty note means "penalise silently", matching the
46
49
  # shell version, which docked a point for ac3/eac3 without explaining why.
@@ -74,6 +77,22 @@ SUBTITLE_PENALTIES: dict[str, tuple[int, str]] = {
74
77
  }
75
78
 
76
79
 
80
+ class Tier(NamedTuple):
81
+ floor: int # the lowest score in the tier
82
+ label: str
83
+ logger: Callable[[str], None]
84
+
85
+
86
+ # Best first: a score lands in the first tier it reaches. The labels double as
87
+ # the summary rows, in this order.
88
+ TIERS: tuple[Tier, ...] = (
89
+ Tier(9, "Excellent", success),
90
+ Tier(7, "Good", info),
91
+ Tier(4, "Poor", note),
92
+ Tier(0, "Bad", warning),
93
+ )
94
+
95
+
77
96
  def rate_video(
78
97
  video_codec: str,
79
98
  audio_codec: str,
@@ -113,9 +132,9 @@ def rate_video(
113
132
  return score, ";".join(reasons) if reasons else "Excellent Plex compatibility"
114
133
 
115
134
 
116
- def as_int(value: str) -> int:
117
- """ffprobe prints 'N/A' for absent fields; treat anything non-numeric as 0."""
118
- return int(value) if value.isdigit() else 0
135
+ def tier(score: int) -> Tier:
136
+ """The ``TIERS`` row for a 0-10 score."""
137
+ return next(row for row in TIERS if score >= row.floor)
119
138
 
120
139
 
121
140
  def main(argv: list[str] | None = None) -> int:
@@ -136,18 +155,14 @@ def main(argv: list[str] | None = None) -> int:
136
155
 
137
156
  require_binary("ffprobe", hint="Install FFmpeg: brew install ffmpeg")
138
157
 
139
- if not args.path:
140
- warning("--path is required")
141
- parser.print_help()
142
- return 1
143
-
158
+ # An empty --path reads "❌ --path is required" without the usage block
159
+ # the shell version printed after it.
144
160
  root = scan_root(args.path)
145
161
 
146
- note(f"Scanning: {args.path}")
147
- note(f"Recursive: {str(args.recursive).lower()}")
148
- note("----------------------------------------------------")
162
+ note_settings(("Scanning", args.path), ("Recursive", args.recursive))
149
163
 
150
- total = excellent = good = poor = bad = 0
164
+ total = 0
165
+ counts: Counter[str] = Counter()
151
166
 
152
167
  # macOS ._ sidecars are skipped: they are never real media, and ffprobing
153
168
  # each one would waste a subprocess per file on a NAS scan. The shell
@@ -159,70 +174,40 @@ def main(argv: list[str] | None = None) -> int:
159
174
  note("Checking:")
160
175
  print(f" {path}", flush=True)
161
176
 
162
- readable = run(["ffprobe", "-v", "error", str(path)], check=False, capture=True)
163
- if readable.returncode != 0:
177
+ probe = probe_media(path)
178
+ if probe is None:
164
179
  warning(" Corrupt or unreadable file")
165
180
  print(flush=True)
166
- bad += 1
181
+ counts["Bad"] += 1
167
182
  continue
168
183
 
169
- video_codec = (
170
- ffprobe_entries(
171
- path, "stream=codec_name", stream="v:0", fmt="default=noprint_wrappers=1:nokey=1"
172
- )
173
- or "unknown"
174
- )
175
- audio_codec = (
176
- ffprobe_entries(
177
- path, "stream=codec_name", stream="a:0", fmt="default=noprint_wrappers=1:nokey=1"
178
- )
179
- or "unknown"
180
- )
181
- subtitle_codec = (
182
- ffprobe_entries(
183
- path, "stream=codec_name", stream="s:0", fmt="default=noprint_wrappers=1:nokey=1"
184
- )
185
- or "none"
186
- )
187
- width_raw = ffprobe_entries(
188
- path, "stream=width", stream="v:0", fmt="default=noprint_wrappers=1:nokey=1"
189
- )
190
- bitrate_raw = ffprobe_entries(
191
- path, "format=bit_rate", fmt="default=noprint_wrappers=1:nokey=1"
192
- )
193
- width = as_int(width_raw)
194
- bitrate = as_int(bitrate_raw)
184
+ video_codec = probe.codec("video") or "unknown"
185
+ audio_codec = probe.codec("audio") or "unknown"
186
+ subtitle_codec = probe.codec("subtitle") or "none"
187
+ width = probe.width()
188
+ bitrate = probe.bit_rate()
195
189
 
196
190
  try:
197
191
  filesize = path.stat().st_size
198
192
  except OSError:
199
193
  filesize = 0
200
194
 
201
- score, notes = rate_video(video_codec, audio_codec, subtitle_codec, width, bitrate)
195
+ score, notes = rate_video(
196
+ video_codec, audio_codec, subtitle_codec, width or 0, bitrate or 0
197
+ )
202
198
 
203
199
  print(f" Size : {format_bytes(filesize)}", flush=True)
204
200
  print(f" Video : {video_codec}", flush=True)
205
201
  print(f" Audio : {audio_codec}", flush=True)
206
202
  print(f" Subtitles : {subtitle_codec}", flush=True)
207
- # `is not None` rather than truthiness: the shell version gated on the
208
- # value being numeric, so a literal 0 still printed its line.
209
- if width_raw.isdigit():
203
+ if width is not None:
210
204
  print(f" Resolution: {width}px", flush=True)
211
- if bitrate_raw.isdigit():
205
+ if bitrate is not None:
212
206
  print(f" Bitrate : {bitrate // 1_000_000} Mbps", flush=True)
213
207
 
214
- if score >= 9:
215
- success(f" Plex Score: {score}/10")
216
- excellent += 1
217
- elif score >= 7:
218
- info(f" Plex Score: {score}/10")
219
- good += 1
220
- elif score >= 4:
221
- note(f" Plex Score: {score}/10")
222
- poor += 1
223
- else:
224
- warning(f" Plex Score: {score}/10")
225
- bad += 1
208
+ rated = tier(score)
209
+ rated.logger(f" Plex Score: {score}/10")
210
+ counts[rated.label] += 1
226
211
 
227
212
  print(f" Notes : {notes}", flush=True)
228
213
  print(flush=True)
@@ -231,10 +216,8 @@ def main(argv: list[str] | None = None) -> int:
231
216
  success("Scan complete")
232
217
  print(flush=True)
233
218
  print(f"Total Files : {total}", flush=True)
234
- print(f"Excellent : {excellent}", flush=True)
235
- print(f"Good : {good}", flush=True)
236
- print(f"Poor : {poor}", flush=True)
237
- print(f"Bad : {bad}", flush=True)
219
+ for row in TIERS:
220
+ print(f"{row.label:<12}: {counts[row.label]}", flush=True)
238
221
  return 0
239
222
 
240
223
 
@@ -25,14 +25,17 @@ from pathlib import Path
25
25
 
26
26
  sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
27
27
 
28
+ from media import CODEC_SCAN_EXTENSIONS
28
29
  from utils import (
29
30
  add_bool_flag,
30
31
  build_parser,
32
+ dry_run_complete,
31
33
  env_bool,
32
34
  info,
33
35
  iter_files,
34
36
  log,
35
- note,
37
+ note_settings,
38
+ remove_or_warn,
36
39
  require_binary,
37
40
  run,
38
41
  run_cli,
@@ -41,9 +44,7 @@ from utils import (
41
44
  warning,
42
45
  )
43
46
 
44
- __version__ = "3.0.0"
45
-
46
- SCAN_EXTENSIONS = ("mp4", "mkv", "mov", "avi")
47
+ __version__ = "3.1.0"
47
48
 
48
49
  FFMPEG_ARGS = (
49
50
  "-map",
@@ -120,7 +121,6 @@ def main(argv: list[str] | None = None) -> int:
120
121
  add_bool_flag(
121
122
  parser,
122
123
  "--delete-original",
123
- dest="delete_original",
124
124
  help="Delete each source file after a successful convert",
125
125
  )
126
126
  add_bool_flag(
@@ -133,25 +133,24 @@ def main(argv: list[str] | None = None) -> int:
133
133
  info(f"Running command in {Path.cwd()}")
134
134
  args = parser.parse_args(argv)
135
135
 
136
- if not args.path:
137
- warning("Error: You must provide a path with --path=")
138
- return 1
139
-
140
- # Binary check before the path check, matching the shell version's order,
141
- # so a missing ffmpeg is reported even when the path is also wrong.
136
+ # Binary check before the directory check, matching the shell version's
137
+ # order, so a missing ffmpeg is reported even when the path is also wrong.
138
+ # An empty --path reads "--path is required" after it, where the shell
139
+ # version said "Error: You must provide a path with --path=" before it.
142
140
  require_binary("ffmpeg", hint="Install FFmpeg first.")
143
141
 
144
142
  root = scan_root(args.path)
145
143
 
146
- note(f"Scanning: {args.path}")
147
- note(f"Delete original after conversion: {str(args.delete_original).lower()}")
148
- note(f"Dry run: {str(args.dry_run).lower()}")
149
- note("----------------------------------------------------")
144
+ note_settings(
145
+ ("Scanning", args.path),
146
+ ("Delete original after conversion", args.delete_original),
147
+ ("Dry run", args.dry_run),
148
+ )
150
149
 
151
150
  converted = 0
152
151
  failed = 0
153
152
 
154
- for path in iter_files(root, extensions=SCAN_EXTENSIONS):
153
+ for path in iter_files(root, extensions=CODEC_SCAN_EXTENSIONS):
155
154
  # Don't re-encode our own output on a second run. Announced rather than
156
155
  # silent, because scene "PROPER/FIXED" repacks legitimately end in
157
156
  # _fixed and would otherwise vanish from the listing with no trace.
@@ -161,19 +160,20 @@ def main(argv: list[str] | None = None) -> int:
161
160
 
162
161
  output = path.with_name(f"{path.stem}_fixed.mp4")
163
162
 
164
- if args.dry_run:
165
- info(f"[DRY RUN] Would re-encode: {path} -> {output}")
166
- if args.delete_original:
167
- info(f"[DRY RUN] Would delete original: {path}")
168
- continue
169
-
170
163
  # Never silently replace an existing conversion; the user may have
171
164
  # produced it deliberately, and there is no way to tell it apart from
172
- # one this run just made for a different same-stem source.
165
+ # one this run just made for a different same-stem source. Checked
166
+ # before the dry-run branch so a preview reports the same skips.
173
167
  if output.exists():
174
168
  warning(f"⏭️ Skipping (output already exists): {output}")
175
169
  continue
176
170
 
171
+ if args.dry_run:
172
+ info(f"[DRY RUN] Would re-encode: {path} -> {output}")
173
+ if args.delete_original:
174
+ info(f"[DRY RUN] Would delete original: {path}")
175
+ continue
176
+
177
177
  log(f"Processing: {path}")
178
178
 
179
179
  if not encode(path, output):
@@ -184,16 +184,11 @@ def main(argv: list[str] | None = None) -> int:
184
184
  log(f"✅ Converted to: {output}")
185
185
  converted += 1
186
186
 
187
- if args.delete_original:
188
- try:
189
- path.unlink()
190
- except OSError as exc:
191
- warning(f" ✗ Could not delete {path}: {exc}")
192
- continue
187
+ if args.delete_original and remove_or_warn(path):
193
188
  warning(f"🗑 Deleted original: {path}")
194
189
 
195
190
  if args.dry_run:
196
- log("💡 Dry run complete. Nothing was actually changed.")
191
+ dry_run_complete()
197
192
  return 0
198
193
 
199
194
  # Summary and the non-zero exit on failure are both new; the shell version
@@ -20,6 +20,7 @@ from __future__ import annotations
20
20
  import os
21
21
  import re
22
22
  import sys
23
+ from collections.abc import Callable
23
24
  from pathlib import Path
24
25
 
25
26
  sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
@@ -30,12 +31,14 @@ from utils import (
30
31
  format_bytes,
31
32
  info,
32
33
  note,
34
+ note_settings,
35
+ parse_count,
33
36
  run_cli,
34
37
  scan_root,
35
38
  warning,
36
39
  )
37
40
 
38
- __version__ = "3.0.0"
41
+ __version__ = "3.1.0"
39
42
 
40
43
  # `^Season[[:space:]]+[0-9]+$` in the shell version. \Z rather than $ because $
41
44
  # also matches before a trailing newline, and an explicit [ \t] rather than \s
@@ -43,34 +46,36 @@ __version__ = "3.0.0"
43
46
  # otherwise match where the shell version's POSIX classes did not.
44
47
  SEASON_RE = re.compile(r"Season[ \t]+[0-9]+\Z")
45
48
 
46
- _NON_NEGATIVE_INT = re.compile(r"[0-9]+")
47
49
 
50
+ def _silent(_message: str) -> None:
51
+ """The ``trace`` used without ``--debug``."""
48
52
 
49
- def is_tv_show_folder(path: Path, *, debug: bool) -> bool:
50
- """True when ``path`` has a direct child folder named ``Season <N>``."""
51
- if debug:
52
- note(f"[DEBUG] Checking folder: {path}")
53
- try:
54
- for child in path.iterdir():
55
- # find -type d does not match a symlink; following one here would
56
- # report a show whose season folder lives elsewhere, and then size
57
- # it at zero because the walk below refuses to descend.
58
- if child.is_symlink() or not child.is_dir():
59
- continue
60
- if debug:
61
- note(f"[DEBUG] Found child folder: {child.name}")
62
- if SEASON_RE.fullmatch(child.name):
63
- if debug:
64
- note(f"[DEBUG] Matched season folder in: {path}")
65
- return True
66
- except OSError:
67
- return False
68
- if debug:
69
- note(f"[DEBUG] No season folders found in: {path}")
53
+
54
+ def is_tv_show_folder(
55
+ path: Path, child_dirs: list[str], *, trace: Callable[[str], None] = _silent
56
+ ) -> bool:
57
+ """True when one of ``child_dirs``, the folders directly in ``path``, is ``Season <N>``.
58
+
59
+ ``child_dirs`` is the listing ``os.walk`` already made, so a folder is
60
+ read once. Taken in its sorted order, which only shows in ``trace``.
61
+ """
62
+ trace(f"[DEBUG] Checking folder: {path}")
63
+ for name in child_dirs:
64
+ # os.walk lists a symlinked folder without descending into it. find
65
+ # -type d does not match a symlink; following one here would report a
66
+ # show whose season folder lives elsewhere, and then size it at zero
67
+ # because the walk below refuses to descend.
68
+ if (path / name).is_symlink():
69
+ continue
70
+ trace(f"[DEBUG] Found child folder: {name}")
71
+ if SEASON_RE.fullmatch(name):
72
+ trace(f"[DEBUG] Matched season folder in: {path}")
73
+ return True
74
+ trace(f"[DEBUG] No season folders found in: {path}")
70
75
  return False
71
76
 
72
77
 
73
- def folder_size(path: Path, seen: set[tuple[int, int]]) -> int:
78
+ def folder_size(path: Path) -> int:
74
79
  """Disk usage under ``path``, matching ``du -sk`` closely enough to rank by.
75
80
 
76
81
  ``st_blocks * 512`` is ALLOCATED size, which is what ``du`` reports - not
@@ -78,12 +83,13 @@ def folder_size(path: Path, seen: set[tuple[int, int]]) -> int:
78
83
  here: a sparse preallocated download reads as its full final size, and a
79
84
  library of many small files reads far under its real footprint.
80
85
 
81
- ``seen`` collects inode identity so a hard-linked file is counted once,
82
- again matching ``du``. Sonarr and Radarr hardlink into the library while
83
- seeding, so without this a show holding both copies would double. The set
84
- is per-show, because the shell version ran one ``du -sk`` per show and each
85
- invocation de-duplicates only within its own tree.
86
+ A hard-linked file is counted once, again matching ``du``. Sonarr and
87
+ Radarr hardlink into the library while seeding, so without this a show
88
+ holding both copies would double. The de-duplication is per-show, because
89
+ the shell version ran one ``du -sk`` per show and each invocation
90
+ de-duplicates only within its own tree.
86
91
  """
92
+ seen: set[tuple[int, int]] = set()
87
93
  total = 0
88
94
  for dirpath, _, filenames in os.walk(path, followlinks=False):
89
95
  for name in filenames:
@@ -106,36 +112,35 @@ def main(argv: list[str] | None = None) -> int:
106
112
  description="📺 Rank TV show folders in a library by total size on disk.",
107
113
  )
108
114
  parser.add_argument("--path", default="", metavar="DIR", help="TV library root (required)")
109
- # Kept as a string so "+5" is rejected the way the shell version's
110
- # ^[0-9]+$ test rejected it, rather than silently accepted by int().
115
+ # Kept as a string and checked by parse_count, so "+5" is rejected the way
116
+ # the shell version's ^[0-9]+$ test rejected it, not accepted by int().
111
117
  parser.add_argument("--limit", default="20", metavar="N", help="How many to list")
112
- add_bool_flag(parser, "--full-path", dest="full_path", help="Print the whole path")
118
+ add_bool_flag(parser, "--full-path", help="Print the whole path")
113
119
  add_bool_flag(parser, "--debug", help="Print traversal detail")
114
120
 
115
121
  info(f"Running command in {Path.cwd()}")
116
122
  args = parser.parse_args(argv)
117
123
 
118
- note(f"Searching in: {args.path}")
119
- note(f"Limit: {args.limit}")
120
- note(f"Show full path: {str(args.full_path).lower()}")
121
- note(f"Debug: {str(args.debug).lower()}")
122
-
123
- if not args.path:
124
- info(
125
- "📋 Usage: largest-tv-shows.py --path=/path/to/tv [--limit=20] [--full-path] [--debug]"
126
- )
127
- return 1
124
+ note_settings(
125
+ ("Searching in", args.path),
126
+ ("Limit", args.limit),
127
+ ("Show full path", args.full_path),
128
+ ("Debug", args.debug),
129
+ rule=False,
130
+ )
128
131
 
132
+ # An empty --path reads "--path is required", where the shell version
133
+ # printed a one-line usage.
129
134
  root = scan_root(args.path)
130
135
 
131
- if not _NON_NEGATIVE_INT.fullmatch(args.limit) or int(args.limit) < 1:
132
- warning("Error: --limit must be a positive integer")
133
- return 1
134
- limit = int(args.limit)
136
+ # Same test as the shell version's ^[0-9]+$ and -lt 1. The message is the
137
+ # repo-wide parse_count one, which also echoes the rejected value.
138
+ limit = parse_count(args.limit, "--limit", minimum=1)
135
139
 
136
140
  note("Scanning for TV show folders...")
137
141
 
138
- shows: list[tuple[int, str, Path]] = []
142
+ trace = note if args.debug else _silent
143
+ shows: list[tuple[int, Path]] = []
139
144
 
140
145
  # Nested show folders are listed too (a "Doctor Who Confidential" inside
141
146
  # "Doctor Who" is its own row), matching the shell version. Its bytes still
@@ -143,14 +148,12 @@ def main(argv: list[str] | None = None) -> int:
143
148
  for dirpath, dirnames, _ in os.walk(root, followlinks=False):
144
149
  dirnames.sort()
145
150
  directory = Path(dirpath)
146
- if args.debug:
147
- note(f"[DEBUG] Scanning candidate: {directory}")
148
- if not is_tv_show_folder(directory, debug=args.debug):
151
+ trace(f"[DEBUG] Scanning candidate: {directory}")
152
+ if not is_tv_show_folder(directory, dirnames, trace=trace):
149
153
  continue
150
- size = folder_size(directory, set())
151
- shows.append((size, directory.name, directory))
152
- if args.debug:
153
- note(f"[DEBUG] Detected TV show folder: {directory.name} | Size: {size}")
154
+ size = folder_size(directory)
155
+ shows.append((size, directory))
156
+ trace(f"[DEBUG] Detected TV show folder: {directory.name} | Size: {size}")
154
157
 
155
158
  info(f"Detected {len(shows)} TV show folder(s)")
156
159
 
@@ -161,11 +164,11 @@ def main(argv: list[str] | None = None) -> int:
161
164
  # `sort -rn` reverses the whole comparison, including its last-resort
162
165
  # tiebreak on the full line - so equal-sized shows come out in DESCENDING
163
166
  # name order. Reversing both key fields reproduces that.
164
- shows.sort(key=lambda item: (item[0], item[1]), reverse=True)
167
+ shows.sort(key=lambda item: (item[0], item[1].name), reverse=True)
165
168
 
166
169
  note(f"Top {limit} largest TV show folders:")
167
- for size, name, path in shows[:limit]:
168
- label = str(path) if args.full_path else name
170
+ for size, path in shows[:limit]:
171
+ label = str(path) if args.full_path else path.name
169
172
  print(f"{format_bytes(size):<12} {label}", flush=True)
170
173
 
171
174
  return 0
@@ -32,7 +32,7 @@ from utils import (
32
32
  warning,
33
33
  )
34
34
 
35
- __version__ = "2.1.0"
35
+ __version__ = "2.1.1"
36
36
 
37
37
  DEFAULT_EXTS = (
38
38
  "mp4",
@@ -68,11 +68,8 @@ def main(argv: list[str] | None = None) -> int:
68
68
  info(f"Running command in {Path.cwd()}")
69
69
  args = parser.parse_args(argv)
70
70
 
71
- if not args.path:
72
- warning("❌ --path must be specified")
73
- parser.print_help()
74
- return 1
75
-
71
+ # An empty --path reads "--path is required", where the shell version
72
+ # printed "--path must be specified" and the full usage.
76
73
  root = scan_root(args.path)
77
74
 
78
75
  require_binary("exiftool", hint="Install exiftool and retry.")