@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.
- package/README.md +65 -25
- package/bin/art.py +123 -0
- package/bin/che.py +209 -568
- package/bin/commands.py +220 -231
- package/bin/doctor.py +220 -0
- package/bin/drives/eject-all-drives.py +21 -38
- package/bin/drives/mount-all-drives.py +18 -62
- package/bin/drives/ping-nas.py +77 -105
- package/bin/files/compress-folders.py +7 -11
- package/bin/files/delete-by-ext.py +10 -14
- package/bin/files/delete-empty-folders.py +33 -37
- package/bin/files/delete-smb-files.py +12 -21
- package/bin/files/files-under-size.py +5 -11
- package/bin/files/find-largest-files.py +21 -25
- package/bin/files/list-permission.py +40 -0
- package/bin/files/make-alpha-dir.py +2 -0
- package/bin/git/all-actions.py +350 -253
- package/bin/git/checkout-my-branches.py +42 -63
- package/bin/git/clean-stale-branches.py +23 -48
- package/bin/git/prune-worktrees.py +12 -51
- package/bin/git/sync-all-branches.py +54 -283
- package/bin/git/update-local-branches.py +67 -73
- package/bin/gitutils.py +263 -0
- package/bin/install.py +393 -1188
- package/bin/machine.py +345 -0
- package/bin/media.py +127 -0
- package/bin/menu.py +437 -0
- package/bin/nas.py +102 -0
- package/bin/rcfile.py +380 -0
- package/bin/shellgen.py +508 -428
- package/bin/system/btop-launch.py +17 -13
- package/bin/system/update-brew.py +57 -68
- package/bin/tui.py +156 -153
- package/bin/utils.py +165 -237
- package/bin/video/delete-duplicate-videos.py +64 -62
- package/bin/video/detect-green-magenta-videos.py +105 -95
- package/bin/video/find-movie-by-year.py +3 -0
- package/bin/video/find-video-mkv-issues.py +48 -65
- package/bin/video/fix-codecs.py +25 -30
- package/bin/video/largest-tv-shows.py +60 -57
- package/bin/video/remove-metadata.py +3 -6
- package/bin/video/rename-video-file.py +74 -84
- package/bin/video/scan-videos-audio-language.py +16 -33
- package/bin/video/show-codecs.py +20 -24
- package/bin/video/validate-video-files.py +5 -8
- package/bin/video/video-list.py +17 -20
- package/package.json +4 -10
- package/shell/che.bash +33 -33
- package/shell/che.fish +7 -7
- package/shell/che.zsh +33 -33
- package/shell/completions/_che +19 -19
- package/shell/completions/che.bash +15 -15
- 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
|
|
35
|
-
DRY_RUN=false so interactive use
|
|
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.
|
|
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
|
|
130
|
-
"""
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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=
|
|
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
|
-
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
"
|
|
84
|
-
"
|
|
85
|
-
"
|
|
86
|
-
"
|
|
87
|
-
"
|
|
88
|
-
"
|
|
89
|
-
"
|
|
90
|
-
"
|
|
91
|
-
"
|
|
92
|
-
"
|
|
93
|
-
"
|
|
94
|
-
"
|
|
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
|
|
129
|
-
|
|
130
|
-
|
|
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
|
|
136
|
-
#
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
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
|
-
|
|
191
|
-
|
|
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
|
-
|
|
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
|
-
|
|
212
|
-
|
|
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
|
-
|
|
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
|
-
|
|
220
|
-
|
|
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
|
-
|
|
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
|
-
|
|
229
|
-
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
f"{tag} ratio={
|
|
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
|
-
|
|
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
|
-
|
|
271
|
+
return 1
|
|
272
|
+
return 0
|
|
265
273
|
|
|
266
274
|
|
|
267
275
|
if __name__ == "__main__":
|
|
268
|
-
|
|
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:
|