@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.
- package/LICENSE +21 -0
- package/README.md +424 -0
- package/bin/che.py +869 -0
- package/bin/commands.py +872 -0
- package/bin/drives/eject-all-drives.py +203 -0
- package/bin/drives/mount-all-drives.py +140 -0
- package/bin/drives/ping-nas.py +242 -0
- package/bin/files/compress-folders.py +188 -0
- package/bin/files/delete-by-ext.py +108 -0
- package/bin/files/delete-empty-folders.py +136 -0
- package/bin/files/delete-smb-files.py +105 -0
- package/bin/files/files-under-size.py +132 -0
- package/bin/files/find-largest-files.py +102 -0
- package/bin/files/make-alpha-dir.py +63 -0
- package/bin/git/all-actions.py +545 -0
- package/bin/git/checkout-my-branches.py +147 -0
- package/bin/git/clean-stale-branches.py +161 -0
- package/bin/git/prune-worktrees.py +143 -0
- package/bin/git/sync-all-branches.py +368 -0
- package/bin/git/update-local-branches.py +195 -0
- package/bin/install.py +1515 -0
- package/bin/shellgen.py +782 -0
- package/bin/system/btop-launch.py +114 -0
- package/bin/system/update-brew.py +146 -0
- package/bin/tui.py +648 -0
- package/bin/utils.py +836 -0
- package/bin/video/delete-duplicate-videos.py +313 -0
- package/bin/video/detect-green-magenta-videos.py +268 -0
- package/bin/video/find-movie-by-year.py +84 -0
- package/bin/video/find-video-mkv-issues.py +242 -0
- package/bin/video/fix-codecs.py +209 -0
- package/bin/video/largest-tv-shows.py +175 -0
- package/bin/video/remove-metadata.py +126 -0
- package/bin/video/rename-video-file.py +329 -0
- package/bin/video/scan-videos-audio-language.py +110 -0
- package/bin/video/show-codecs.py +111 -0
- package/bin/video/validate-video-files.py +114 -0
- package/bin/video/video-list.py +120 -0
- package/package.json +54 -0
- package/shell/che.bash +382 -0
- package/shell/che.fish +282 -0
- package/shell/che.zsh +389 -0
- package/shell/completions/_che +146 -0
- package/shell/completions/che.bash +107 -0
- 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)
|