@codebend3r/che 1.0.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 (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +424 -0
  3. package/bin/che.py +869 -0
  4. package/bin/commands.py +872 -0
  5. package/bin/drives/eject-all-drives.py +203 -0
  6. package/bin/drives/mount-all-drives.py +140 -0
  7. package/bin/drives/ping-nas.py +242 -0
  8. package/bin/files/compress-folders.py +188 -0
  9. package/bin/files/delete-by-ext.py +108 -0
  10. package/bin/files/delete-empty-folders.py +136 -0
  11. package/bin/files/delete-smb-files.py +105 -0
  12. package/bin/files/files-under-size.py +132 -0
  13. package/bin/files/find-largest-files.py +102 -0
  14. package/bin/files/make-alpha-dir.py +63 -0
  15. package/bin/git/all-actions.py +545 -0
  16. package/bin/git/checkout-my-branches.py +147 -0
  17. package/bin/git/clean-stale-branches.py +161 -0
  18. package/bin/git/prune-worktrees.py +143 -0
  19. package/bin/git/sync-all-branches.py +368 -0
  20. package/bin/git/update-local-branches.py +195 -0
  21. package/bin/install.py +1515 -0
  22. package/bin/shellgen.py +782 -0
  23. package/bin/system/btop-launch.py +114 -0
  24. package/bin/system/update-brew.py +146 -0
  25. package/bin/tui.py +648 -0
  26. package/bin/utils.py +836 -0
  27. package/bin/video/delete-duplicate-videos.py +313 -0
  28. package/bin/video/detect-green-magenta-videos.py +268 -0
  29. package/bin/video/find-movie-by-year.py +84 -0
  30. package/bin/video/find-video-mkv-issues.py +242 -0
  31. package/bin/video/fix-codecs.py +209 -0
  32. package/bin/video/largest-tv-shows.py +175 -0
  33. package/bin/video/remove-metadata.py +126 -0
  34. package/bin/video/rename-video-file.py +329 -0
  35. package/bin/video/scan-videos-audio-language.py +110 -0
  36. package/bin/video/show-codecs.py +111 -0
  37. package/bin/video/validate-video-files.py +114 -0
  38. package/bin/video/video-list.py +120 -0
  39. package/package.json +54 -0
  40. package/shell/che.bash +382 -0
  41. package/shell/che.fish +282 -0
  42. package/shell/che.zsh +389 -0
  43. package/shell/completions/_che +146 -0
  44. package/shell/completions/che.bash +107 -0
  45. package/shell/completions/che.fish +371 -0
@@ -0,0 +1,313 @@
1
+ #!/usr/bin/env python3
2
+ """🗑️ Delete duplicate MKV/MP4 files under a root directory.
3
+
4
+ Pipeline:
5
+
6
+ 1. Parse flags and normalize booleans/strategy (env overrides, then CLI).
7
+ 2. Resolve --path to an absolute directory.
8
+ 3. Walk every .mkv/.mp4 recursively, skipping AppleDouble "._*" sidecars.
9
+ 4. Compute a duplicate group key per file (depends on --strategy).
10
+ 5. Bucket files by that key.
11
+ 6. For each bucket, keep one member (largest file; ties break lexically by
12
+ path, so the choice is deterministic) and delete the rest.
13
+
14
+ Usage:
15
+ delete-duplicate-videos.py --path=/path/to/media [--strategy=MODE]
16
+ [--dry-run] [--verbose]
17
+
18
+ Strategies (--strategy):
19
+
20
+ episode Same directory + identical SxxEyy token (tv-style names). Files
21
+ without such a token are ignored.
22
+ filename Same directory + identical basename - exact filename duplicates.
23
+ size Same directory + identical byte size. Fast, but CAN mis-group
24
+ different videos that coincidentally match size.
25
+ hash Globally grouped by SHA-256 (byte-identical copies anywhere under
26
+ root). Reads every file fully - slow on huge trees.
27
+ all Same directory + basename + byte size + SHA-256 must all match.
28
+ Strong confirmation before delete.
29
+
30
+ Defaults:
31
+ --strategy=episode
32
+
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.
36
+
37
+ Options:
38
+ --path=PATH Required directory to scan
39
+ --strategy=MODE episode | filename | size | hash | all
40
+ --dry-run Show removals only - no deletes (same as env DRY_RUN=true)
41
+ --dry-run=BOOL Explicit true|false (aliases: yes|no, 1|0)
42
+ --verbose Extra per-file logging
43
+ --help, -h This help
44
+
45
+ Env:
46
+ STRATEGY=MODE Same spelling as --strategy (default episode)
47
+ DRY_RUN=true|false Overrides the preview-only default
48
+
49
+ Examples:
50
+ delete-duplicate-videos.py --path=./Shows --strategy=filename
51
+ DRY_RUN=false delete-duplicate-videos.py --path=./Shows --strategy=hash
52
+ """
53
+
54
+ from __future__ import annotations
55
+
56
+ import hashlib
57
+ import os
58
+ import re
59
+ import sys
60
+ from pathlib import Path
61
+
62
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
63
+
64
+ from utils import (
65
+ add_bool_flag,
66
+ build_parser,
67
+ env_bool,
68
+ format_bytes,
69
+ info,
70
+ iter_files,
71
+ log,
72
+ note,
73
+ run_cli,
74
+ scan_root,
75
+ warning,
76
+ )
77
+
78
+ __version__ = "2.2.1"
79
+
80
+ # Glue multiple logical fields into one key without a separator that could
81
+ # plausibly appear in a directory name.
82
+ KEY_RS = "\x1e"
83
+
84
+ EPISODE_RE = re.compile(r"[Ss][0-9]{2}[Ee][0-9]{2,3}")
85
+
86
+ VIDEO_EXTENSIONS = ("mkv", "mp4")
87
+
88
+ STRATEGY_ALIASES = {
89
+ "episode": "episode",
90
+ "tv": "episode",
91
+ "filename": "filename",
92
+ "file": "filename",
93
+ "basename": "filename",
94
+ "name": "filename",
95
+ "size": "size",
96
+ "bytes": "size",
97
+ "hash": "hash",
98
+ "checksum": "hash",
99
+ "sha256": "hash",
100
+ "digest": "hash",
101
+ "all": "all",
102
+ "combined": "all",
103
+ }
104
+
105
+ # 1 MiB chunks: large enough that syscall overhead is irrelevant, small enough
106
+ # that a 60 GB remux does not have to fit in memory.
107
+ _HASH_CHUNK = 1024 * 1024
108
+
109
+
110
+ def normalize_strategy(value: str) -> str:
111
+ """Collapse the strategy synonyms, or return "" for an unknown value."""
112
+ return STRATEGY_ALIASES.get(value.strip().lower(), "")
113
+
114
+
115
+ def sha256_hex(path: Path) -> str:
116
+ """SHA-256 of a file, streamed.
117
+
118
+ The shell version shelled out to openssl / sha256sum / shasum and probed
119
+ which one worked. hashlib is in the standard library, so there is no
120
+ backend to detect and no smoke test to run.
121
+ """
122
+ digest = hashlib.sha256()
123
+ with path.open("rb") as handle:
124
+ while chunk := handle.read(_HASH_CHUNK):
125
+ digest.update(chunk)
126
+ return digest.hexdigest()
127
+
128
+
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
149
+
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
+
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
+ 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}")
175
+
176
+
177
+ def main(argv: list[str] | None = None) -> int:
178
+ parser = build_parser(
179
+ prog="delete-duplicate-videos",
180
+ description="🗑️ Delete duplicate MKV/MP4 files under a root directory.",
181
+ epilog=(
182
+ "Examples:\n"
183
+ " delete-duplicate-videos.py --path=./Shows --strategy=filename\n"
184
+ " DRY_RUN=false delete-duplicate-videos.py --path=./Shows --strategy=hash"
185
+ ),
186
+ )
187
+ parser.add_argument("--path", default="", metavar="PATH", help="Directory to scan (required)")
188
+ parser.add_argument(
189
+ "--strategy",
190
+ # `or`, not a get() default: the shell used ${STRATEGY:-episode}, where
191
+ # an exported-but-empty value also falls back.
192
+ default=os.environ.get("STRATEGY") or "episode",
193
+ metavar="MODE",
194
+ help="episode | filename | size | hash | all (default: episode)",
195
+ )
196
+ add_bool_flag(
197
+ parser,
198
+ "--dry-run",
199
+ default=env_bool("DRY_RUN", True),
200
+ help="Show removals only - no deletes",
201
+ )
202
+ add_bool_flag(parser, "--verbose", help="Extra per-file logging")
203
+
204
+ info(f"Running command in {Path.cwd()}")
205
+ args = parser.parse_args(argv)
206
+
207
+ strategy = normalize_strategy(args.strategy)
208
+ if not strategy:
209
+ warning(f"Invalid --strategy value: {args.strategy}")
210
+ parser.print_help()
211
+ return 1
212
+
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
222
+
223
+ # Absolute, with ../ collapsed, so group keys built from the parent
224
+ # directory are stable regardless of how --path was spelled. Note this also
225
+ # resolves symlinks mid-path, so a logged path can differ from the one the
226
+ # user typed; grouping and the choice of keeper are unaffected, since every
227
+ # key shares the same root prefix.
228
+ root = scan_root(args.path).resolve()
229
+
230
+ if strategy == "size":
231
+ info("NOTICE: grouping by SIZE can flag unrelated videos with identical byte lengths.")
232
+ elif strategy in ("hash", "all"):
233
+ info("NOTICE: hashing reads every scanned file entirely — slow on huge libraries.")
234
+
235
+ if args.dry_run:
236
+ info("Running in dry-run mode — no files will be deleted.")
237
+ if args.verbose:
238
+ info("Verbose mode — per-file tracing enabled.")
239
+
240
+ scanned = 0
241
+ groups: dict[str, list[Path]] = {}
242
+
243
+ for path in iter_files(root, extensions=VIDEO_EXTENSIONS):
244
+ scanned += 1
245
+ if args.verbose:
246
+ info(f"SCANNING: {path}")
247
+ key = group_key(path, strategy, verbose=args.verbose)
248
+ if key is not None:
249
+ groups.setdefault(key, []).append(path)
250
+
251
+ deleted = 0
252
+ duplicate_groups = 0
253
+ reclaimed = 0
254
+
255
+ for key in sorted(groups):
256
+ members = groups[key]
257
+ if len(members) <= 1:
258
+ continue
259
+
260
+ sized: list[tuple[int, Path]] = []
261
+ for path in members:
262
+ if not path.is_file():
263
+ continue # a symlink to a missing target, or deleted mid-run
264
+ try:
265
+ sized.append((path.stat().st_size, path))
266
+ except OSError:
267
+ warning(f"SKIP: unreadable size: {path}")
268
+
269
+ if len(sized) <= 1:
270
+ continue
271
+
272
+ # Largest first; same-size ties break alphabetically by path, so the
273
+ # keeper is deterministic across runs and machines.
274
+ sized.sort(key=lambda item: (-item[0], str(item[1])))
275
+ duplicate_groups += 1
276
+
277
+ keep_size, keep_path = sized[0]
278
+ if args.verbose:
279
+ info(f"KEEPING: {keep_path} ({format_bytes(keep_size)})")
280
+
281
+ for size, path in sized[1:]:
282
+ human = format_bytes(size)
283
+ if args.dry_run:
284
+ deleted += 1
285
+ reclaimed += size
286
+ warning(f"[DRY-RUN] ❌ Would delete: {path} ({human})")
287
+ continue
288
+
289
+ warning(f"❌ Deleting: {path} ({human})")
290
+ try:
291
+ path.unlink()
292
+ except OSError as exc:
293
+ warning(f" ✗ Failed to delete {path}: {exc}")
294
+ continue
295
+ deleted += 1
296
+ reclaimed += size
297
+
298
+ if args.dry_run:
299
+ log(f"Total files that would be deleted: {deleted}")
300
+ else:
301
+ log(f"Total files deleted: {deleted}")
302
+
303
+ log(
304
+ f"Duplicate groups merged ({strategy}): {duplicate_groups} "
305
+ f"(~{format_bytes(reclaimed)} freed)"
306
+ )
307
+ log(f"Total files scanned: {scanned}")
308
+ log("Scanning complete.")
309
+ return 0
310
+
311
+
312
+ if __name__ == "__main__":
313
+ run_cli(main)
@@ -0,0 +1,268 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Detect videos with the "green and purple/magenta" decoding artifact.
4
+
5
+ This artifact appears when chroma planes (U/V) are corrupted, swapped,
6
+ or mishandled by the decoder. The result is an image dominated by
7
+ green and magenta hues with almost no other colors -- unwatchable.
8
+
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
13
+
14
+ Requires: opencv-python, numpy
15
+ pip install opencv-python numpy
16
+
17
+ This is the only script in the repo that needs third-party packages. Rather
18
+ than a separate bash launcher, it re-execs itself under a dedicated venv when
19
+ cv2 is missing from the interpreter it was started with - Homebrew Python is
20
+ PEP 668 "externally managed" and refuses plain pip installs, so the packages
21
+ have to live somewhere isolated. Override the location with $DETECT_GM_PYTHON.
22
+ """
23
+
24
+ import argparse
25
+ import os
26
+ import sys
27
+ from pathlib import Path
28
+ from typing import NoReturn
29
+
30
+ VENV_PYTHON = Path(
31
+ os.environ.get("DETECT_GM_PYTHON", Path.home() / ".venvs" / "green-magenta" / "bin" / "python")
32
+ )
33
+
34
+
35
+ def _reexec_under_venv() -> NoReturn:
36
+ """Hand off to the venv interpreter when cv2 is not importable here.
37
+
38
+ ``_DETECT_GM_REEXEC`` guards against a loop if the venv itself is missing
39
+ the packages: the second run reaches the error path instead of re-execing
40
+ forever.
41
+ """
42
+ if os.environ.get("_DETECT_GM_REEXEC"):
43
+ print(
44
+ f"❌ {VENV_PYTHON} is missing cv2 or numpy. Run:\n"
45
+ f" {VENV_PYTHON} -m pip install opencv-python numpy",
46
+ file=sys.stderr,
47
+ )
48
+ raise SystemExit(1)
49
+
50
+ if not os.access(VENV_PYTHON, os.X_OK):
51
+ print(
52
+ f"❌ Python venv not found: {VENV_PYTHON}\n"
53
+ "Bootstrap with:\n"
54
+ " python3 -m venv $HOME/.venvs/green-magenta\n"
55
+ " $HOME/.venvs/green-magenta/bin/pip install opencv-python numpy",
56
+ file=sys.stderr,
57
+ )
58
+ raise SystemExit(1)
59
+
60
+ os.environ["_DETECT_GM_REEXEC"] = "1"
61
+ os.execv( # noqa: S606 - absolute interpreter path, no shell involved
62
+ str(VENV_PYTHON), [str(VENV_PYTHON), str(Path(__file__).resolve()), *sys.argv[1:]]
63
+ )
64
+
65
+
66
+ # --help and bare usage must work on a machine with no venv at all, so the
67
+ # import is skipped entirely for those. The bash launcher checked --help before
68
+ # it checked for the venv, and asking someone to bootstrap OpenCV just to read
69
+ # the usage text is a poor answer to "how do I use this?".
70
+ _WANTS_HELP = len(sys.argv) == 1 or any(a in ("-h", "--help") for a in sys.argv[1:])
71
+
72
+ if not _WANTS_HELP:
73
+ try:
74
+ # ImportError, not ModuleNotFoundError: a half-installed or
75
+ # ABI-mismatched OpenCV - the common opencv-on-macOS failure - raises
76
+ # plain ImportError, which would otherwise escape as a raw traceback.
77
+ import cv2
78
+ import numpy as np
79
+ except ImportError:
80
+ _reexec_under_venv()
81
+
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
+ }
96
+
97
+
98
+ def analyze_frame(frame):
99
+ """
100
+ Return (green_magenta_ratio, mean_saturation) for one BGR frame.
101
+
102
+ green_magenta_ratio = fraction of saturated pixels whose hue falls
103
+ in the green or magenta bands. In a healthy video this is usually
104
+ well under 0.5; in a broken green/magenta video it sits near 1.0.
105
+ """
106
+ hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV)
107
+ h = hsv[..., 0] # OpenCV hue range: 0-179 (half of 0-360 degrees)
108
+ s = hsv[..., 1] # saturation: 0-255
109
+
110
+ # Only look at pixels with real color. Gray/near-gray pixels carry
111
+ # no useful hue information and shouldn't sway the verdict.
112
+ sat_mask = s > 40
113
+ if sat_mask.sum() < 100:
114
+ # Frame is essentially grayscale -- can't judge it.
115
+ return None, float(s.mean())
116
+
117
+ hues = h[sat_mask]
118
+
119
+ # Green band: ~70-170 deg -> OpenCV 35-85
120
+ # Magenta band: ~260-340 deg -> OpenCV 130-170
121
+ green_mask = (hues >= 35) & (hues <= 85)
122
+ magenta_mask = (hues >= 130) & (hues <= 170)
123
+
124
+ ratio = (green_mask.sum() + magenta_mask.sum()) / hues.size
125
+ return float(ratio), float(s.mean())
126
+
127
+
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
134
+
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:
146
+ for _ in range(sample_count):
147
+ ok, frame = cap.read()
148
+ 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)
164
+
165
+ cap.release()
166
+
167
+ if not ratios:
168
+ return {"path": str(path), "error": "no usable (color) frames decoded"}
169
+
170
+ avg_ratio = float(np.mean(ratios))
171
+ avg_sat = float(np.mean(sats))
172
+
173
+ # Flag if the green/magenta dominance is high AND the frames are
174
+ # actually saturated (otherwise we'd flag intentionally desaturated
175
+ # cinematography that happens to lean cool/warm).
176
+ flagged = avg_ratio >= threshold and avg_sat > 35
177
+
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
+ }
185
+
186
+
187
+ def iter_videos(paths):
188
+ """Yield every video under the given files and directories.
189
+
190
+ Streams per directory rather than materialising the whole tree, and skips
191
+ macOS ``._*`` sidecars the way the rest of the repo does.
192
+ """
193
+ for p in paths:
194
+ p = Path(p)
195
+ 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
204
+ elif p.is_file():
205
+ yield p
206
+ else:
207
+ print(f"[skip] {p}: not a file or directory", file=sys.stderr)
208
+
209
+
210
+ def main():
211
+ ap = argparse.ArgumentParser(
212
+ description="Detect videos with the green/magenta chroma artifact."
213
+ )
214
+ ap.add_argument(
215
+ "paths",
216
+ nargs="+",
217
+ help="Video files or directories to scan (directories are searched recursively)",
218
+ )
219
+ ap.add_argument(
220
+ "--samples", type=int, default=20, help="Frames to sample per video (default: 20)"
221
+ )
222
+ ap.add_argument(
223
+ "--threshold",
224
+ type=float,
225
+ default=0.65,
226
+ help="Fraction of saturated pixels that must be green or magenta to flag (default: 0.65)",
227
+ )
228
+ ap.add_argument(
229
+ "--verbose", "-v", action="store_true", help="Print every video, not just flagged ones"
230
+ )
231
+ args = ap.parse_args()
232
+
233
+ # The bash launcher printed these before exec'ing, and the rest of the repo
234
+ # opens the same way. After parse_args, so --help stays clean.
235
+ print(f"Running command in {Path.cwd()}")
236
+ print(f"Scanning: {' '.join(args.paths)}")
237
+ print("----------------------------------------------------")
238
+
239
+ bad = []
240
+ any_seen = False
241
+ for v in iter_videos(args.paths):
242
+ 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)
246
+ 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"]:
254
+ bad.append(str(v))
255
+
256
+ if not any_seen:
257
+ print("No video files found.", file=sys.stderr)
258
+ sys.exit(2)
259
+
260
+ if bad:
261
+ print(f"\n{len(bad)} video(s) flagged as green/magenta-tinted:", file=sys.stderr)
262
+ for b in bad:
263
+ print(f" {b}", file=sys.stderr)
264
+ sys.exit(1)
265
+
266
+
267
+ if __name__ == "__main__":
268
+ main()
@@ -0,0 +1,84 @@
1
+ #!/usr/bin/env python3
2
+ """🎞️ Find movie folders whose name ends with a given year, e.g. "Alien (1979)".
3
+
4
+ Usage:
5
+ find-movie-by-year.py --year=YYYY [--path=/path/to/search]
6
+
7
+ Options:
8
+ --year=YYYY Year to match. Glob metacharacters work, so --year='2*'
9
+ matches any year starting with 2 (required)
10
+ --path=DIR Directory to search (default: .)
11
+ --help Show help
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ import sys
18
+ from fnmatch import fnmatchcase
19
+ from pathlib import Path
20
+
21
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
22
+
23
+ from utils import build_parser, info, run_cli, scan_root, warning
24
+
25
+ __version__ = "3.0.0"
26
+
27
+
28
+ def main(argv: list[str] | None = None) -> int:
29
+ parser = build_parser(
30
+ prog="find-movie-by-year",
31
+ description='🎞️ Find movie folders whose name ends with "(YYYY)".',
32
+ epilog="Example: find-movie-by-year.py --year=2025 --path=/movies",
33
+ )
34
+ parser.add_argument("--year", default="", metavar="YYYY", help="Year to match (required)")
35
+ parser.add_argument(
36
+ "--path", default=".", metavar="DIR", help="Directory to search (default: .)"
37
+ )
38
+
39
+ info(f"Running command in {Path.cwd()}")
40
+ args = parser.parse_args(argv)
41
+
42
+ if not args.year:
43
+ warning("❌ --year is required")
44
+ return 1
45
+
46
+ root = scan_root(args.path)
47
+
48
+ # `find -type d -iname "*(YYYY)"` - a folder name ENDING in the
49
+ # parenthesised year, matched case-insensitively. fnmatch rather than a
50
+ # plain endswith so glob metacharacters in --year still work, as they did
51
+ # when the value was interpolated straight into the -iname pattern.
52
+ pattern = f"*({args.year.lower()})"
53
+
54
+ def emit(path: Path) -> None:
55
+ # `find <dir>` prints paths prefixed with the start directory exactly as
56
+ # the user spelled it, including a leading "./" for the default. pathlib
57
+ # collapses "." away, so join the raw string instead - anything piping
58
+ # this into xargs depends on that prefix.
59
+ if path == root:
60
+ print(args.path, flush=True)
61
+ else:
62
+ # os.path.join, not Path: Path would normalise a leading './'
63
+ # away, and that prefix is exactly what has to survive.
64
+ print(os.path.join(args.path, str(path.relative_to(root))), flush=True) # noqa: PTH118
65
+
66
+ # `find` includes the start directory in its results.
67
+ if fnmatchcase(root.name.lower(), pattern):
68
+ emit(root)
69
+
70
+ # Results are sorted for reproducibility; `find` emitted readdir order.
71
+ for dirpath, dirnames, _ in os.walk(root, followlinks=False):
72
+ dirnames.sort()
73
+ for name in dirnames:
74
+ child = Path(dirpath) / name
75
+ if child.is_symlink():
76
+ continue
77
+ if fnmatchcase(name.lower(), pattern):
78
+ emit(child)
79
+
80
+ return 0
81
+
82
+
83
+ if __name__ == "__main__":
84
+ run_cli(main)