@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
@@ -31,8 +31,9 @@ Defaults:
31
31
  --strategy=episode
32
32
 
33
33
  Security:
34
- Defaults to dry-run when invoked by path. The .zshrc wrapper sources it with
35
- DRY_RUN=false so interactive use deletes; delete-duplicate-videos-dr previews.
34
+ Defaults to dry-run when invoked by path. The generated
35
+ delete-duplicate-videos wrapper runs it with DRY_RUN=false so interactive use
36
+ deletes; delete-duplicate-videos-dr previews.
36
37
 
37
38
  Options:
38
39
  --path=PATH Required directory to scan
@@ -61,6 +62,7 @@ from pathlib import Path
61
62
 
62
63
  sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
63
64
 
65
+ from media import LIBRARY_EXTENSIONS
64
66
  from utils import (
65
67
  add_bool_flag,
66
68
  build_parser,
@@ -70,12 +72,14 @@ from utils import (
70
72
  iter_files,
71
73
  log,
72
74
  note,
75
+ note_settings,
76
+ remove_or_warn,
73
77
  run_cli,
74
78
  scan_root,
75
79
  warning,
76
80
  )
77
81
 
78
- __version__ = "2.2.1"
82
+ __version__ = "2.2.2"
79
83
 
80
84
  # Glue multiple logical fields into one key without a separator that could
81
85
  # plausibly appear in a directory name.
@@ -83,8 +87,6 @@ KEY_RS = "\x1e"
83
87
 
84
88
  EPISODE_RE = re.compile(r"[Ss][0-9]{2}[Ee][0-9]{2,3}")
85
89
 
86
- VIDEO_EXTENSIONS = ("mkv", "mp4")
87
-
88
90
  STRATEGY_ALIASES = {
89
91
  "episode": "episode",
90
92
  "tv": "episode",
@@ -102,6 +104,17 @@ STRATEGY_ALIASES = {
102
104
  "combined": "all",
103
105
  }
104
106
 
107
+ # The fields each strategy's group key is joined from, in order. Size comes
108
+ # before the digest for `all`, so a file whose size cannot be read is skipped
109
+ # before anything spends a full read hashing it.
110
+ STRATEGY_FIELDS: dict[str, tuple[str, ...]] = {
111
+ "episode": ("dir", "episode"),
112
+ "filename": ("dir", "name"),
113
+ "size": ("dir", "size"),
114
+ "hash": ("digest",),
115
+ "all": ("dir", "name", "size", "digest"),
116
+ }
117
+
105
118
  # 1 MiB chunks: large enough that syscall overhead is irrelevant, small enough
106
119
  # that a 60 GB remux does not have to fit in memory.
107
120
  _HASH_CHUNK = 1024 * 1024
@@ -126,52 +139,44 @@ def sha256_hex(path: Path) -> str:
126
139
  return digest.hexdigest()
127
140
 
128
141
 
129
- def group_key(path: Path, strategy: str, *, verbose: bool) -> str | None:
130
- """The duplicate-group key for one file, or None to skip it."""
131
- directory = str(path.parent)
132
- name = path.name
133
-
134
- if strategy == "episode":
135
- match = EPISODE_RE.search(name)
136
- if match is None:
137
- return None # not TV-shaped; ignored, as in the shell version
138
- return f"{directory}{KEY_RS}{match.group(0)}"
139
-
140
- if strategy == "filename":
141
- return f"{directory}{KEY_RS}{name}"
142
-
143
- if strategy == "size":
144
- try:
145
- return f"{directory}{KEY_RS}{path.stat().st_size}"
146
- except OSError:
147
- warning(f"SKIP: unreadable size: {path}")
148
- return None
142
+ def key_field(path: Path, field: str, *, verbose: bool) -> str | None:
143
+ """One field of a file's group key, or None when the file must be skipped."""
144
+ match field:
145
+ case "dir":
146
+ return str(path.parent)
147
+ case "name":
148
+ return path.name
149
+ case "episode":
150
+ # None when the name is not TV-shaped: ignored, as in the shell version.
151
+ found = EPISODE_RE.search(path.name)
152
+ return found.group(0) if found else None
153
+ case "size":
154
+ try:
155
+ return str(path.stat().st_size)
156
+ except OSError:
157
+ warning(f"SKIP: unreadable size: {path}")
158
+ return None
159
+ case "digest":
160
+ if verbose:
161
+ note(f"HASH: {path}")
162
+ try:
163
+ return sha256_hex(path)
164
+ except OSError:
165
+ warning(f"SKIP: could not hash: {path}")
166
+ return None
167
+ case _:
168
+ raise ValueError(f"unknown key field: {field}")
149
169
 
150
- if strategy == "hash":
151
- if verbose:
152
- note(f"HASH: {path}")
153
- try:
154
- return sha256_hex(path)
155
- except OSError:
156
- warning(f"SKIP: could not hash: {path}")
157
- return None
158
170
 
159
- if strategy == "all":
160
- try:
161
- size = path.stat().st_size
162
- except OSError:
163
- warning(f"SKIP: unreadable size: {path}")
164
- return None
165
- if verbose:
166
- note(f"HASH: {path}")
167
- try:
168
- digest = sha256_hex(path)
169
- except OSError:
170
- warning(f"SKIP: could not hash: {path}")
171
+ def group_key(path: Path, strategy: str, *, verbose: bool) -> str | None:
172
+ """The duplicate-group key for one file, or None to skip it."""
173
+ fields: list[str] = []
174
+ for field in STRATEGY_FIELDS[strategy]:
175
+ value = key_field(path, field, verbose=verbose)
176
+ if value is None:
171
177
  return None
172
- return f"{directory}{KEY_RS}{name}{KEY_RS}{size}{KEY_RS}{digest}"
173
-
174
- raise ValueError(f"Internal error: unknown strategy {strategy!r}")
178
+ fields.append(value)
179
+ return KEY_RS.join(fields)
175
180
 
176
181
 
177
182
  def main(argv: list[str] | None = None) -> int:
@@ -210,16 +215,16 @@ def main(argv: list[str] | None = None) -> int:
210
215
  parser.print_help()
211
216
  return 1
212
217
 
213
- note(f"Scanning: {args.path}")
214
- note(f"Strategy: {strategy}")
215
- note(f"Dry run: {str(args.dry_run).lower()}")
216
- note(f"Verbose: {str(args.verbose).lower()}")
217
- note("----------------------------------------------------")
218
-
219
- if not args.path:
220
- warning("Missing required argument: --path")
221
- return 1
218
+ note_settings(
219
+ ("Scanning", args.path),
220
+ ("Strategy", strategy),
221
+ ("Dry run", args.dry_run),
222
+ ("Verbose", args.verbose),
223
+ )
222
224
 
225
+ # An empty --path reads "--path is required", where the shell version
226
+ # reported "Missing required argument: --path".
227
+ #
223
228
  # Absolute, with ../ collapsed, so group keys built from the parent
224
229
  # directory are stable regardless of how --path was spelled. Note this also
225
230
  # resolves symlinks mid-path, so a logged path can differ from the one the
@@ -240,7 +245,7 @@ def main(argv: list[str] | None = None) -> int:
240
245
  scanned = 0
241
246
  groups: dict[str, list[Path]] = {}
242
247
 
243
- for path in iter_files(root, extensions=VIDEO_EXTENSIONS):
248
+ for path in iter_files(root, extensions=LIBRARY_EXTENSIONS):
244
249
  scanned += 1
245
250
  if args.verbose:
246
251
  info(f"SCANNING: {path}")
@@ -287,10 +292,7 @@ def main(argv: list[str] | None = None) -> int:
287
292
  continue
288
293
 
289
294
  warning(f"❌ Deleting: {path} ({human})")
290
- try:
291
- path.unlink()
292
- except OSError as exc:
293
- warning(f" ✗ Failed to delete {path}: {exc}")
295
+ if not remove_or_warn(path):
294
296
  continue
295
297
  deleted += 1
296
298
  reclaimed += size
@@ -7,9 +7,9 @@ or mishandled by the decoder. The result is an image dominated by
7
7
  green and magenta hues with almost no other colors -- unwatchable.
8
8
 
9
9
  Usage:
10
- python detect_green_magenta.py path/to/video.mp4
11
- python detect_green_magenta.py /path/to/folder --verbose
12
- python detect_green_magenta.py vids/ --samples 40 --threshold 0.7
10
+ detect-green-magenta-videos.py path/to/video.mp4
11
+ detect-green-magenta-videos.py /path/to/folder --verbose
12
+ detect-green-magenta-videos.py vids/ --samples=40 --threshold=0.7
13
13
 
14
14
  Requires: opencv-python, numpy
15
15
  pip install opencv-python numpy
@@ -21,11 +21,17 @@ PEP 668 "externally managed" and refuses plain pip installs, so the packages
21
21
  have to live somewhere isolated. Override the location with $DETECT_GM_PYTHON.
22
22
  """
23
23
 
24
- import argparse
25
24
  import os
26
25
  import sys
27
26
  from pathlib import Path
28
- from typing import NoReturn
27
+ from typing import NamedTuple, NoReturn
28
+
29
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
30
+
31
+ # Standard library only, so this imports the same under the venv interpreter.
32
+ from utils import add_bool_flag, build_parser, iter_files, parse_count, run_cli
33
+
34
+ __version__ = "2.0.0"
29
35
 
30
36
  VENV_PYTHON = Path(
31
37
  os.environ.get("DETECT_GM_PYTHON", Path.home() / ".venvs" / "green-magenta" / "bin" / "python")
@@ -79,20 +85,30 @@ if not _WANTS_HELP:
79
85
  except ImportError:
80
86
  _reexec_under_venv()
81
87
 
82
- VIDEO_EXTS = {
83
- ".mp4",
84
- ".mkv",
85
- ".avi",
86
- ".mov",
87
- ".webm",
88
- ".m4v",
89
- ".flv",
90
- ".wmv",
91
- ".mpg",
92
- ".mpeg",
93
- ".ts",
94
- ".m2ts",
95
- }
88
+ VIDEO_EXTS = (
89
+ "mp4",
90
+ "mkv",
91
+ "avi",
92
+ "mov",
93
+ "webm",
94
+ "m4v",
95
+ "flv",
96
+ "wmv",
97
+ "mpg",
98
+ "mpeg",
99
+ "ts",
100
+ "m2ts",
101
+ )
102
+
103
+
104
+ class SkipVideo(Exception):
105
+ """A video that cannot be judged; the message says why."""
106
+
107
+
108
+ class Verdict(NamedTuple):
109
+ ratio: float # mean green/magenta fraction, to 3 places
110
+ saturation: float # mean saturation, to 1 place
111
+ flagged: bool
96
112
 
97
113
 
98
114
  def analyze_frame(frame):
@@ -125,47 +141,45 @@ def analyze_frame(frame):
125
141
  return float(ratio), float(s.mean())
126
142
 
127
143
 
128
- def analyze_video(path, sample_count=20, threshold=0.65):
129
- cap = cv2.VideoCapture(str(path))
130
- if not cap.isOpened():
131
- return {"path": str(path), "error": "could not open"}
132
-
133
- total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) or 0
144
+ def sample_frames(cap, sample_count):
145
+ """Yield up to ``sample_count`` decoded frames from an open capture."""
146
+ total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
134
147
 
135
- if total > sample_count * 2:
136
- # Sample evenly, skipping the first/last 5% (intros, credits, black frames)
137
- start = int(total * 0.05)
138
- end = int(total * 0.95)
139
- positions = np.linspace(start, end, sample_count, dtype=int)
140
- else:
141
- positions = None # short or unknown-length file -- just read sequentially
142
-
143
- ratios, sats = [], []
144
-
145
- if positions is None:
148
+ if total <= sample_count * 2:
149
+ # Short or unknown-length file -- just read sequentially.
146
150
  for _ in range(sample_count):
147
151
  ok, frame = cap.read()
148
152
  if not ok:
149
- break
150
- r, s = analyze_frame(frame)
151
- if r is not None:
152
- ratios.append(r)
153
- sats.append(s)
154
- else:
155
- for pos in positions:
156
- cap.set(cv2.CAP_PROP_POS_FRAMES, int(pos))
157
- ok, frame = cap.read()
158
- if not ok:
159
- continue
160
- r, s = analyze_frame(frame)
161
- if r is not None:
162
- ratios.append(r)
163
- sats.append(s)
153
+ return
154
+ yield frame
155
+ return
156
+
157
+ # Sample evenly, skipping the first/last 5% (intros, credits, black frames)
158
+ start = int(total * 0.05)
159
+ end = int(total * 0.95)
160
+ for pos in np.linspace(start, end, sample_count, dtype=int):
161
+ cap.set(cv2.CAP_PROP_POS_FRAMES, int(pos))
162
+ ok, frame = cap.read()
163
+ if ok:
164
+ yield frame
165
+
166
+
167
+ def analyze_video(path, sample_count=20, threshold=0.65) -> Verdict:
168
+ """Judge one video, or raise ``SkipVideo`` when it cannot be judged."""
169
+ cap = cv2.VideoCapture(str(path))
170
+ if not cap.isOpened():
171
+ raise SkipVideo("could not open")
164
172
 
173
+ ratios, sats = [], []
174
+ for frame in sample_frames(cap, sample_count):
175
+ r, s = analyze_frame(frame)
176
+ if r is not None:
177
+ ratios.append(r)
178
+ sats.append(s)
165
179
  cap.release()
166
180
 
167
181
  if not ratios:
168
- return {"path": str(path), "error": "no usable (color) frames decoded"}
182
+ raise SkipVideo("no usable (color) frames decoded")
169
183
 
170
184
  avg_ratio = float(np.mean(ratios))
171
185
  avg_sat = float(np.mean(sats))
@@ -175,60 +189,55 @@ def analyze_video(path, sample_count=20, threshold=0.65):
175
189
  # cinematography that happens to lean cool/warm).
176
190
  flagged = avg_ratio >= threshold and avg_sat > 35
177
191
 
178
- return {
179
- "path": str(path),
180
- "frames_sampled": len(ratios),
181
- "green_magenta_ratio": round(avg_ratio, 3),
182
- "avg_saturation": round(avg_sat, 1),
183
- "flagged": flagged,
184
- }
192
+ return Verdict(round(avg_ratio, 3), round(avg_sat, 1), flagged)
185
193
 
186
194
 
187
195
  def iter_videos(paths):
188
196
  """Yield every video under the given files and directories.
189
197
 
190
- Streams per directory rather than materialising the whole tree, and skips
191
- macOS ``._*`` sidecars the way the rest of the repo does.
198
+ Directories go through the repo's ``iter_files``, where this script used
199
+ to walk them itself. DELIBERATE DIVERGENCE from that walk: a file named
200
+ just ``.mkv`` now matches, FIFOs and sockets with a video extension are
201
+ no longer yielded (opening a FIFO blocks forever), and a folder that
202
+ cannot be read is reported on stderr rather than silently passed over.
192
203
  """
193
- for p in paths:
194
- p = Path(p)
204
+ for p in map(Path, paths):
195
205
  if p.is_dir():
196
- for dirpath, dirnames, filenames in os.walk(p, followlinks=False):
197
- dirnames.sort()
198
- for name in sorted(filenames):
199
- if name.startswith("._"):
200
- continue
201
- f = Path(dirpath) / name
202
- if f.suffix.lower() in VIDEO_EXTS and not f.is_symlink():
203
- yield f
206
+ yield from iter_files(p, extensions=VIDEO_EXTS)
204
207
  elif p.is_file():
205
208
  yield p
206
209
  else:
207
210
  print(f"[skip] {p}: not a file or directory", file=sys.stderr)
208
211
 
209
212
 
210
- def main():
211
- ap = argparse.ArgumentParser(
212
- description="Detect videos with the green/magenta chroma artifact."
213
+ def main(argv=None):
214
+ # build_parser like every other script, so a usage error exits 1 with the
215
+ # full help, where plain argparse exited 2. `-v` is gone with it: the repo
216
+ # takes long flags only, and neither the menu nor the completions offered it.
217
+ parser = build_parser(
218
+ prog="detect-green-magenta-videos",
219
+ description="Detect videos with the green/magenta chroma artifact.",
213
220
  )
214
- ap.add_argument(
221
+ parser.add_argument(
215
222
  "paths",
216
223
  nargs="+",
217
224
  help="Video files or directories to scan (directories are searched recursively)",
218
225
  )
219
- ap.add_argument(
220
- "--samples", type=int, default=20, help="Frames to sample per video (default: 20)"
226
+ # A string checked by parse_count: `type=int` let 0 through, which skipped
227
+ # every video, and a negative count, which crashed numpy on the first one.
228
+ parser.add_argument(
229
+ "--samples", default="20", metavar="N", help="Frames to sample per video (default: 20)"
221
230
  )
222
- ap.add_argument(
231
+ parser.add_argument(
223
232
  "--threshold",
224
233
  type=float,
225
234
  default=0.65,
235
+ metavar="RATIO",
226
236
  help="Fraction of saturated pixels that must be green or magenta to flag (default: 0.65)",
227
237
  )
228
- ap.add_argument(
229
- "--verbose", "-v", action="store_true", help="Print every video, not just flagged ones"
230
- )
231
- args = ap.parse_args()
238
+ add_bool_flag(parser, "--verbose", help="Print every video, not just flagged ones")
239
+ args = parser.parse_args(argv)
240
+ samples = parse_count(args.samples, "--samples", minimum=1)
232
241
 
233
242
  # The bash launcher printed these before exec'ing, and the rest of the repo
234
243
  # opens the same way. After parse_args, so --help stays clean.
@@ -240,29 +249,30 @@ def main():
240
249
  any_seen = False
241
250
  for v in iter_videos(args.paths):
242
251
  any_seen = True
243
- res = analyze_video(v, sample_count=args.samples, threshold=args.threshold)
244
- if "error" in res:
245
- print(f"[skip] {v}: {res['error']}", file=sys.stderr)
252
+ try:
253
+ verdict = analyze_video(v, sample_count=samples, threshold=args.threshold)
254
+ except SkipVideo as exc:
255
+ print(f"[skip] {v}: {exc}", file=sys.stderr)
246
256
  continue
247
- tag = "BAD " if res["flagged"] else "ok "
248
- line = (
249
- f"{tag} ratio={res['green_magenta_ratio']:.2f} sat={res['avg_saturation']:>5.1f} {v}"
250
- )
251
- if res["flagged"] or args.verbose:
252
- print(line)
253
- if res["flagged"]:
257
+ if verdict.flagged or args.verbose:
258
+ tag = "BAD " if verdict.flagged else "ok "
259
+ print(f"{tag} ratio={verdict.ratio:.2f} sat={verdict.saturation:>5.1f} {v}")
260
+ if verdict.flagged:
254
261
  bad.append(str(v))
255
262
 
256
263
  if not any_seen:
257
264
  print("No video files found.", file=sys.stderr)
258
- sys.exit(2)
265
+ return 2
259
266
 
260
267
  if bad:
261
268
  print(f"\n{len(bad)} video(s) flagged as green/magenta-tinted:", file=sys.stderr)
262
269
  for b in bad:
263
270
  print(f" {b}", file=sys.stderr)
264
- sys.exit(1)
271
+ return 1
272
+ return 0
265
273
 
266
274
 
267
275
  if __name__ == "__main__":
268
- main()
276
+ # run_cli, like every other script: Ctrl-C now exits 130 quietly where it
277
+ # used to dump a traceback.
278
+ run_cli(main)
@@ -68,6 +68,9 @@ def main(argv: list[str] | None = None) -> int:
68
68
  emit(root)
69
69
 
70
70
  # Results are sorted for reproducibility; `find` emitted readdir order.
71
+ # The order also differs in shape: each folder's matching children print
72
+ # before the walk descends into any of them, so matches `A`, `A/B` and `C`
73
+ # come out `A, C, A/B` where find's depth-first walk printed `A, A/B, C`.
71
74
  for dirpath, dirnames, _ in os.walk(root, followlinks=False):
72
75
  dirnames.sort()
73
76
  for name in dirnames: