@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,126 @@
1
+ #!/usr/bin/env python3
2
+ """๐Ÿงผ Strip all metadata from video files recursively, using exiftool.
3
+
4
+ Runs: exiftool -all= -overwrite_original -r -progress -i '*/._*' -ext <ext>...
5
+
6
+ Usage:
7
+ remove-metadata.py --path=/path/to/files [--exts=comma,separated,exts]
8
+
9
+ Options:
10
+ --path=DIR Directory to scan (required)
11
+ --exts=LIST Comma-separated extensions to process
12
+ (default: mp4 mov m4v mkv avi wmv mpg mpeg webm flv ts m2ts 3gp 3g2 ogv)
13
+ --help Show help
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import re
19
+ import sys
20
+ from pathlib import Path
21
+
22
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
23
+
24
+ from utils import (
25
+ build_parser,
26
+ info,
27
+ log,
28
+ require_binary,
29
+ run,
30
+ run_cli,
31
+ scan_root,
32
+ warning,
33
+ )
34
+
35
+ __version__ = "2.1.0"
36
+
37
+ DEFAULT_EXTS = (
38
+ "mp4",
39
+ "mov",
40
+ "m4v",
41
+ "mkv",
42
+ "avi",
43
+ "wmv",
44
+ "mpg",
45
+ "mpeg",
46
+ "webm",
47
+ "flv",
48
+ "ts",
49
+ "m2ts",
50
+ "3gp",
51
+ "3g2",
52
+ "ogv",
53
+ )
54
+
55
+
56
+ def main(argv: list[str] | None = None) -> int:
57
+ parser = build_parser(
58
+ prog="remove-metadata",
59
+ description="๐Ÿงผ Remove all metadata from video files recursively (exiftool).",
60
+ )
61
+ parser.add_argument("--path", default="", metavar="DIR", help="Directory to scan (required)")
62
+ parser.add_argument(
63
+ "--exts",
64
+ default="",
65
+ metavar="LIST",
66
+ help=f"Comma-separated extensions (default: {' '.join(DEFAULT_EXTS)})",
67
+ )
68
+ info(f"Running command in {Path.cwd()}")
69
+ args = parser.parse_args(argv)
70
+
71
+ if not args.path:
72
+ warning("โŒ --path must be specified")
73
+ parser.print_help()
74
+ return 1
75
+
76
+ root = scan_root(args.path)
77
+
78
+ require_binary("exiftool", hint="Install exiftool and retry.")
79
+
80
+ if args.exts:
81
+ # All whitespace stripped, not just the ends: the shell version used
82
+ # `tr -d '[:space:]'`, so "m p4" became a usable "mp4".
83
+ exts = [re.sub(r"\s+", "", e) for e in args.exts.split(",")]
84
+ exts = [e for e in exts if e]
85
+ else:
86
+ exts = list(DEFAULT_EXTS)
87
+
88
+ if not exts:
89
+ warning("โŒ No valid extensions provided.")
90
+ return 1
91
+
92
+ ext_args: list[str] = []
93
+ for ext in exts:
94
+ ext_args += ["-ext", ext]
95
+
96
+ info(f"Removing metadata from extensions: {' '.join(exts)}")
97
+ info(f"Target path: {args.path}")
98
+
99
+ # Preview: list the files exiftool will touch. '-i */._*' makes it ignore
100
+ # the AppleDouble sidecars, which otherwise get rewritten pointlessly.
101
+ info("Listing candidate files:")
102
+ run(
103
+ ["exiftool", "-r", "-i", "*/._*", *ext_args, "-q", "-p", "$FilePath", str(root)],
104
+ check=False,
105
+ )
106
+
107
+ run(
108
+ [
109
+ "exiftool",
110
+ "-all=",
111
+ "-overwrite_original",
112
+ "-r",
113
+ "-progress",
114
+ "-i",
115
+ "*/._*",
116
+ *ext_args,
117
+ str(root),
118
+ ]
119
+ )
120
+
121
+ log("Done.")
122
+ return 0
123
+
124
+
125
+ if __name__ == "__main__":
126
+ run_cli(main)
@@ -0,0 +1,329 @@
1
+ #!/usr/bin/env python3
2
+ """๐Ÿ“ Title-case video filenames (and optionally their folders).
3
+
4
+ Usage:
5
+ rename-video-file.py [--path=/path/to/dir] [--recursive] [--rename-folders]
6
+ [--capitalize-preps] [--dry-run] [--ignore-words=W1,W2]
7
+
8
+ Options:
9
+ --path=DIR Directory to scan (required)
10
+ --recursive Recurse into subdirectories (default: true)
11
+ --rename-folders Rename folders too (default: true)
12
+ --capitalize-preps Capitalize articles/prepositions (default: false)
13
+ --dry-run Print renames, change nothing (default: true)
14
+ --ignore-words=LIST Comma-separated words to leave exactly as written
15
+ --help Show help
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import os
21
+ import re
22
+ import sys
23
+ from pathlib import Path
24
+
25
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
26
+
27
+ from utils import (
28
+ add_bool_flag,
29
+ build_parser,
30
+ info,
31
+ iter_files,
32
+ log,
33
+ note,
34
+ run_cli,
35
+ scan_root,
36
+ warning,
37
+ )
38
+
39
+ __version__ = "3.0.0"
40
+
41
+ VIDEO_EXTENSIONS = ("mp4", "mkv")
42
+
43
+ # Every flavour of curly quote, backtick and acute accent people end up with,
44
+ # normalised to a plain straight apostrophe.
45
+ _APOSTROPHES = str.maketrans({"โ€™": "'", "โ€˜": "'", "ยด": "'", "`": "'"}) # noqa: RUF001
46
+
47
+ # Space out dashes, then pull ALLCAPS-digit codes (ABC-123) back together.
48
+ _DASH_SPACING = re.compile(r"\s*-\s*")
49
+ _CODE_REJOIN = re.compile(r"\b([A-Z]+)\s*-\s*([0-9]+)")
50
+
51
+ # Articles, prepositions and conjunctions that stay lowercase mid-title.
52
+ _PREPOSITIONS = re.compile(
53
+ r"\b(And|The|A|An|By|For|In|Of|On|To|With|At|But|Or|Nor|So|Yet|As)\b",
54
+ re.IGNORECASE,
55
+ )
56
+
57
+ _FIRST_LETTER = re.compile(r"^([^A-Za-z]*)([A-Za-z])")
58
+ _AFTER_DASH = re.compile(r"(-\s*)([^A-Za-z]*)([A-Za-z])")
59
+
60
+ # "I'm", "You've", "Friend's" - one or two letters after an apostrophe stay low.
61
+ _APOSTROPHE_SUFFIX = re.compile(r"'([A-Za-z]{1,2})\b")
62
+
63
+ # Scene-style video codes such as "abc-1234", uppercased whole.
64
+ _CODE = r"\b(?P<code>[a-zA-Z]{3,5}-[0-9]{3,5})\b"
65
+ _WORD = r"\b(?P<first>\w)(?P<rest>\w*)"
66
+
67
+ # Filenames shaped "<prefix> - <title>.<ext>", and the fallback "<title>.<ext>".
68
+ _DASHED_NAME = re.compile(r"(.*- )([^.]+)(\..*)", re.DOTALL)
69
+ _PLAIN_NAME = re.compile(r"([^.]+)(\..*)", re.DOTALL)
70
+
71
+
72
+ def build_title_pattern(ignore_words: list[str]) -> re.Pattern[str]:
73
+ """The single alternation that does the title-casing.
74
+
75
+ Order matters and mirrors the perl original: a video code wins over an
76
+ ignored word, which wins over an ordinary word.
77
+
78
+ Ignore-words are regex-escaped, where perl interpolated them raw into the
79
+ alternation. "Leave exactly as written" is what the flag promises, so a
80
+ word containing a dot should match that dot, not any character.
81
+ """
82
+ parts = [_CODE]
83
+ if ignore_words:
84
+ alternation = "|".join(re.escape(word) for word in ignore_words)
85
+ parts.append(rf"\b(?P<keep>{alternation})\b")
86
+ parts.append(_WORD)
87
+ return re.compile("|".join(parts))
88
+
89
+
90
+ def title_case(text: str, pattern: re.Pattern[str]) -> str:
91
+ """Capitalize the first letter of every word, lowercase the rest.
92
+
93
+ Video codes are uppercased whole and ignore-words pass through verbatim.
94
+
95
+ Note: Python's ``\\w`` and ``\\b`` are Unicode-aware, where the perl
96
+ original ran without ``use utf8`` and so treated a UTF-8 filename as raw
97
+ bytes. Accented titles now title-case correctly instead of being mangled.
98
+ """
99
+
100
+ def replace(match: re.Match[str]) -> str:
101
+ if match.group("code"):
102
+ return match.group("code").upper()
103
+ if "keep" in match.groupdict() and match.group("keep"):
104
+ return match.group("keep")
105
+ return match.group("first").upper() + match.group("rest").lower()
106
+
107
+ return pattern.sub(replace, text)
108
+
109
+
110
+ def apply_preposition_rules(text: str) -> str:
111
+ """Lowercase mid-title prepositions, then restore the leading capitals."""
112
+ text = _PREPOSITIONS.sub(lambda m: m.group(1).lower(), text)
113
+ # The very first word is capitalized even if it is a preposition, and so is
114
+ # the word after any dash ("Show - the movie" -> "Show - The movie").
115
+ text = _FIRST_LETTER.sub(lambda m: m.group(1) + m.group(2).upper(), text)
116
+ return _AFTER_DASH.sub(lambda m: m.group(1) + m.group(2) + m.group(3).upper(), text)
117
+
118
+
119
+ def normalize(text: str) -> str:
120
+ """Straighten quotes and normalise dash spacing."""
121
+ text = text.translate(_APOSTROPHES)
122
+ text = _DASH_SPACING.sub(" - ", text)
123
+ return _CODE_REJOIN.sub(r"\1-\2", text)
124
+
125
+
126
+ def clean_segment(text: str, pattern: re.Pattern[str], *, capitalize_preps: bool) -> str:
127
+ """Title-case one name segment end to end."""
128
+ result = title_case(text, pattern)
129
+ if not capitalize_preps:
130
+ result = apply_preposition_rules(result)
131
+ return result
132
+
133
+
134
+ def clean_folder_name(name: str, pattern: re.Pattern[str], *, capitalize_preps: bool) -> str:
135
+ """The corrected name for a folder.
136
+
137
+ Folders now get the same curly-quote normalisation as files. The shell
138
+ version appeared to do this too, but both of its folder-branch sed patterns
139
+ were the plain ASCII apostrophe - two no-ops - so a folder called
140
+ ``l'auberge`` with a typographic apostrophe was left alone.
141
+ """
142
+ result = clean_segment(normalize(name), pattern, capitalize_preps=capitalize_preps)
143
+ return _APOSTROPHE_SUFFIX.sub(lambda m: "'" + m.group(1).lower(), result)
144
+
145
+
146
+ def clean_file_name(name: str, pattern: re.Pattern[str], *, capitalize_preps: bool) -> str | None:
147
+ """The corrected name for a file, or None when it cannot be parsed.
148
+
149
+ Anchored with ``fullmatch`` where the shell version used an unanchored
150
+ ``[[ =~ ]]`` search. The two agree on every ordinary name; the difference
151
+ is dot-leading ones, where the shell version matched from after the dot and
152
+ so renamed ``.hidden movie.mkv`` to ``Hidden Movie.mkv`` - silently
153
+ un-hiding the file. Those are reported as unparseable instead.
154
+ """
155
+ normalized = normalize(name)
156
+
157
+ match = _DASHED_NAME.fullmatch(normalized)
158
+ if match:
159
+ prefix, title, ext = match.group(1), match.group(2), match.group(3)
160
+ else:
161
+ match = _PLAIN_NAME.fullmatch(normalized)
162
+ if not match:
163
+ return None
164
+ prefix, title, ext = "", match.group(1), match.group(2)
165
+
166
+ prefix = clean_segment(prefix, pattern, capitalize_preps=capitalize_preps)
167
+ title = clean_segment(title, pattern, capitalize_preps=capitalize_preps)
168
+
169
+ result = f"{prefix}{title}{ext}"
170
+ return _APOSTROPHE_SUFFIX.sub(lambda m: "'" + m.group(1).lower(), result)
171
+
172
+
173
+ def safe_rename(source: Path, target: Path, *, kind: str) -> bool:
174
+ """Rename ``source`` to ``target`` unless something is already there.
175
+
176
+ The shell version used bare ``mv``, which silently clobbers an existing
177
+ file and moves a folder *inside* an existing folder of the same name.
178
+ A case-only change (``abc`` -> ``Abc``) is still allowed, because on a
179
+ case-insensitive filesystem the target "exists" as the source itself.
180
+ """
181
+ try:
182
+ # Inside the try: the source can vanish between the walk and the
183
+ # rename, and samefile() would then raise straight past this function.
184
+ # The shell version's bare `mv` printed an error and carried on.
185
+ if target.exists() and not source.samefile(target):
186
+ warning(f"โš ๏ธ Skipping {kind} (target already exists): {target.name}")
187
+ return False
188
+ source.rename(target)
189
+ except OSError as exc:
190
+ warning(f"โš ๏ธ Could not rename {source.name}: {exc}")
191
+ return False
192
+ return True
193
+
194
+
195
+ def rename_folders(
196
+ root: Path, pattern: re.Pattern[str], *, recursive: bool, capitalize_preps: bool, dry_run: bool
197
+ ) -> None:
198
+ """Rename folders deepest-first so a parent rename cannot break child paths."""
199
+ directories: list[Path] = []
200
+ for dirpath, _, _ in os.walk(root, topdown=False, followlinks=False):
201
+ directory = Path(dirpath)
202
+ if directory == root:
203
+ continue
204
+ if not recursive and directory.parent != root:
205
+ continue
206
+ directories.append(directory)
207
+
208
+ # Sorting the collected list, not os.walk's dirnames: under topdown=False
209
+ # the recursion has already happened, so mutating dirnames does nothing.
210
+ # Deepest first, so renaming a parent cannot invalidate a child's path.
211
+ directories.sort(key=lambda p: (-len(p.parts), p))
212
+
213
+ for folder in directories:
214
+ if folder.name.startswith("._"):
215
+ continue
216
+
217
+ new_name = clean_folder_name(folder.name, pattern, capitalize_preps=capitalize_preps)
218
+ if new_name == folder.name:
219
+ warning(f"โš ๏ธ Skipping folder (already correct format): {folder.name}")
220
+ continue
221
+
222
+ target = folder.with_name(new_name)
223
+ if dry_run:
224
+ log(f"๐Ÿ“ [DRY RUN] Would rename folder: {folder.name} โžก๏ธ {new_name}")
225
+ continue
226
+
227
+ if safe_rename(folder, target, kind="folder"):
228
+ log(f"๐Ÿ“ Renaming folder: {folder.name} โžก๏ธ {new_name}")
229
+
230
+
231
+ def rename_files(
232
+ root: Path, pattern: re.Pattern[str], *, recursive: bool, capitalize_preps: bool, dry_run: bool
233
+ ) -> None:
234
+ """Rename the .mp4/.mkv files themselves."""
235
+ # case_sensitive: this script's find used -name, not -iname, so a file
236
+ # called "Movie.MKV" was never renamed.
237
+ for path in iter_files(
238
+ root, extensions=VIDEO_EXTENSIONS, recursive=recursive, case_sensitive=True
239
+ ):
240
+ new_name = clean_file_name(path.name, pattern, capitalize_preps=capitalize_preps)
241
+ if new_name is None:
242
+ warning(f"Could not parse filename: {path.name}")
243
+ continue
244
+
245
+ if new_name == path.name:
246
+ warning(f"โš ๏ธ Skipping (already correct format): {path.name}")
247
+ continue
248
+
249
+ target = path.with_name(new_name)
250
+ if dry_run:
251
+ log(f"๐Ÿ‘“ [DRY RUN] Would rename: {path.name} โžก๏ธ {new_name}")
252
+ continue
253
+
254
+ if safe_rename(path, target, kind="file"):
255
+ log(f"๐Ÿ“ Renaming: {path.name} โžก๏ธ {new_name}")
256
+
257
+
258
+ def main(argv: list[str] | None = None) -> int:
259
+ parser = build_parser(
260
+ prog="rename-video-file",
261
+ description="๐Ÿ“ Title-case video filenames (and optionally their folders).",
262
+ )
263
+ parser.add_argument("--path", default="", metavar="DIR", help="Directory to scan (required)")
264
+ add_bool_flag(parser, "--recursive", default=True, help="Recurse into subdirectories")
265
+ add_bool_flag(
266
+ parser, "--rename-folders", dest="rename_folders", default=True, help="Rename folders too"
267
+ )
268
+ add_bool_flag(
269
+ parser,
270
+ "--capitalize-preps",
271
+ dest="capitalize_preps",
272
+ help="Capitalize articles and prepositions",
273
+ )
274
+ # Deliberately NOT env_bool: unlike the delete tools, the shell version
275
+ # hard-coded DRY_RUN=true and never read the environment. Honouring it here
276
+ # would let an exported DRY_RUN=false anywhere in the user's shell turn a
277
+ # preview into a mass rename.
278
+ add_bool_flag(
279
+ parser,
280
+ "--dry-run",
281
+ default=True,
282
+ help="Print renames, change nothing",
283
+ )
284
+ parser.add_argument(
285
+ "--ignore-words",
286
+ default="",
287
+ dest="ignore_words",
288
+ metavar="LIST",
289
+ help="Comma-separated words to leave exactly as written",
290
+ )
291
+
292
+ info(f"Running command in {Path.cwd()}")
293
+ args = parser.parse_args(argv)
294
+
295
+ ignore_words = [word.strip() for word in args.ignore_words.split(",") if word.strip()]
296
+
297
+ note(f"Scanning: {args.path}")
298
+ note(f"Recursive: {str(args.recursive).lower()}")
299
+ note(f"Rename Folders: {str(args.rename_folders).lower()}")
300
+ note(f"Capitalize Prepositions: {str(args.capitalize_preps).lower()}")
301
+ note(f"Dry Run: {str(args.dry_run).lower()}")
302
+ if ignore_words:
303
+ note(f"Ignore Words: {','.join(ignore_words)}")
304
+ note("----------------------------------------------------")
305
+
306
+ root = scan_root(args.path)
307
+ pattern = build_title_pattern(ignore_words)
308
+
309
+ if args.rename_folders:
310
+ rename_folders(
311
+ root,
312
+ pattern,
313
+ recursive=args.recursive,
314
+ capitalize_preps=args.capitalize_preps,
315
+ dry_run=args.dry_run,
316
+ )
317
+
318
+ rename_files(
319
+ root,
320
+ pattern,
321
+ recursive=args.recursive,
322
+ capitalize_preps=args.capitalize_preps,
323
+ dry_run=args.dry_run,
324
+ )
325
+ return 0
326
+
327
+
328
+ if __name__ == "__main__":
329
+ run_cli(main)
@@ -0,0 +1,110 @@
1
+ #!/usr/bin/env python3
2
+ """๐Ÿ”Š Print the audio language tags of every video under a path.
3
+
4
+ Usage:
5
+ scan-videos-audio-language.py --path=/path/to/media
6
+
7
+ Options:
8
+ --path=DIR Directory to scan (required)
9
+ --help Show help
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
18
+
19
+ from utils import (
20
+ NC,
21
+ build_parser,
22
+ color_enabled,
23
+ ffprobe_entries,
24
+ info,
25
+ iter_files,
26
+ require_binary,
27
+ run_cli,
28
+ scan_root,
29
+ warning,
30
+ )
31
+
32
+ __version__ = "3.0.0"
33
+
34
+ VIDEO_EXTENSIONS = ("mp4", "mkv", "avi", "mov", "flv", "wmv", "webm")
35
+
36
+ LANG_COLOR = {
37
+ "eng": "\033[32m", # Green
38
+ "jpn": "\033[31m", # Red
39
+ "spa": "\033[33m", # Yellow
40
+ "fre": "\033[34m", # Blue
41
+ "ger": "\033[35m", # Magenta
42
+ "ita": "\033[36m", # Cyan
43
+ "kor": "\033[95m", # Light Magenta
44
+ "chi": "\033[91m", # Light Red
45
+ "por": "\033[92m", # Light Green
46
+ "rus": "\033[94m", # Light Blue
47
+ }
48
+
49
+ DEFAULT_COLOR = "\033[90m" # Gray for unknown languages
50
+
51
+
52
+ _UNREADABLE = "\x00unreadable"
53
+
54
+
55
+ def audio_languages(path: Path) -> list[str] | None:
56
+ """Sorted, de-duplicated language tags, or None when ffprobe cannot read it.
57
+
58
+ Telling "no language tags" apart from "could not be read" matters: the
59
+ shell version aborted the whole scan on an unreadable file (`set -e` on a
60
+ failing command substitution), so it never had to render the difference.
61
+ """
62
+ raw = ffprobe_entries(path, "stream_tags=language", stream="a", on_error=_UNREADABLE)
63
+ if raw == _UNREADABLE:
64
+ return None
65
+ return sorted({line.strip() for line in raw.splitlines() if line.strip()})
66
+
67
+
68
+ def main(argv: list[str] | None = None) -> int:
69
+ parser = build_parser(
70
+ prog="scan-videos-audio-language",
71
+ description="๐Ÿ”Š Print the audio language tags of every video under a path.",
72
+ )
73
+ parser.add_argument("--path", default="", metavar="DIR", help="Directory to scan (required)")
74
+ info(f"Running command in {Path.cwd()}")
75
+ args = parser.parse_args(argv)
76
+
77
+ if not args.path:
78
+ info(f"โŒ Error: '{args.path}' is not a valid directory")
79
+ return 1
80
+
81
+ root = scan_root(args.path)
82
+
83
+ require_binary("ffprobe", hint="ffprobe (from ffmpeg) is required but not installed.")
84
+
85
+ # The per-language escapes are baked into the string before info() sees
86
+ # it, so NO_COLOR has to be honoured here as well or it would leak.
87
+ colors = color_enabled()
88
+
89
+ def paint(text: str, color: str) -> str:
90
+ return f"{color}{text}{NC}" if colors else text
91
+
92
+ for path in iter_files(root, extensions=VIDEO_EXTENSIONS):
93
+ info(f"File: {path}")
94
+
95
+ languages = audio_languages(path)
96
+ if languages is None:
97
+ warning(f"Audio language(s): {paint('Unreadable - ffprobe failed', DEFAULT_COLOR)}")
98
+ continue
99
+ if not languages:
100
+ info(f"Audio language(s): {paint('Unknown or not tagged', DEFAULT_COLOR)}")
101
+ continue
102
+
103
+ colored = " ".join(paint(lang, LANG_COLOR.get(lang, DEFAULT_COLOR)) for lang in languages)
104
+ info(f"Audio language(s): {colored} ")
105
+
106
+ return 0
107
+
108
+
109
+ if __name__ == "__main__":
110
+ run_cli(main)
@@ -0,0 +1,111 @@
1
+ #!/usr/bin/env python3
2
+ """๐ŸŽฅ Report media files whose codecs or container fall outside the Direct Play set.
3
+
4
+ Usage:
5
+ show-codecs.py --path=/path/to/media [--verbose]
6
+
7
+ Options:
8
+ --path=DIR Directory to scan (required)
9
+ --verbose Also print files that pass
10
+ --help Show help
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import sys
16
+ from pathlib import Path
17
+
18
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
19
+
20
+ from utils import (
21
+ add_bool_flag,
22
+ build_parser,
23
+ ffprobe_entries,
24
+ info,
25
+ iter_files,
26
+ log,
27
+ note,
28
+ require_binary,
29
+ run_cli,
30
+ scan_root,
31
+ warning,
32
+ )
33
+
34
+ __version__ = "3.0.0"
35
+
36
+ # The common safe set for Direct Play. Tuples, not sets, so the banner prints
37
+ # them in the order the shell version's arrays declared them.
38
+ ALLOWED_VIDEO_CODECS = ("h264", "hevc")
39
+ ALLOWED_AUDIO_CODECS = ("aac", "ac3", "eac3")
40
+ ALLOWED_CONTAINERS = ("mov", "mp4", "mkv")
41
+
42
+ SCAN_EXTENSIONS = ("mp4", "mkv", "mov", "avi")
43
+
44
+
45
+ def main(argv: list[str] | None = None) -> int:
46
+ parser = build_parser(
47
+ prog="show-codecs",
48
+ description="๐ŸŽฅ Report media files outside the Direct Play codec/container set.",
49
+ )
50
+ parser.add_argument("--path", default="", metavar="DIR", help="Directory to scan (required)")
51
+ add_bool_flag(parser, "--verbose", help="Also print files that pass")
52
+ info(f"Running command in {Path.cwd()}")
53
+ args = parser.parse_args(argv)
54
+
55
+ note(f"Scanning: {args.path}")
56
+ note(f"Verbose: {str(args.verbose).lower()}")
57
+ note("----------------------------------------------------")
58
+
59
+ if not args.path:
60
+ warning("โŒ --path is required")
61
+ return 1
62
+
63
+ require_binary("ffprobe", hint="Install FFmpeg: brew install ffmpeg")
64
+
65
+ root = scan_root(args.path)
66
+
67
+ print(f"Scanning for problematic codecs in: {args.path}")
68
+ print(f"Allowed Video: {' '.join(ALLOWED_VIDEO_CODECS)}")
69
+ print(f"Allowed Audio: {' '.join(ALLOWED_AUDIO_CODECS)}")
70
+ print(f"Allowed Containers: {' '.join(ALLOWED_CONTAINERS)}")
71
+ print("----------------------------------------------------")
72
+
73
+ problems = 0
74
+
75
+ for path in iter_files(root, extensions=SCAN_EXTENSIONS):
76
+ container = path.name.lower().rsplit(".", 1)[-1]
77
+
78
+ # "unknown" only when ffprobe itself FAILS (a corrupt file), matching
79
+ # the shell version's `|| echo "unknown"`. A file that probes fine but
80
+ # has no audio track keeps the empty string the shell version stored,
81
+ # so the two cases stay distinguishable in the report.
82
+ video_codec = ffprobe_entries(
83
+ path, "stream=codec_name", stream="v:0", on_error="unknown"
84
+ ).lower()
85
+ audio_codec = ffprobe_entries(
86
+ path, "stream=codec_name", stream="a:0", on_error="unknown"
87
+ ).lower()
88
+
89
+ ok = (
90
+ video_codec in ALLOWED_VIDEO_CODECS
91
+ and audio_codec in ALLOWED_AUDIO_CODECS
92
+ and container in ALLOWED_CONTAINERS
93
+ )
94
+
95
+ if not ok:
96
+ problems += 1
97
+ warning(f"โŒ {path}")
98
+ warning(f"Container: {container} | Video: {video_codec} | Audio: {audio_codec}")
99
+ elif args.verbose:
100
+ log(f"โœ… {path}")
101
+ log(f"Container: {container} | Video: {video_codec} | Audio: {audio_codec}")
102
+
103
+ # DELIBERATE DIVERGENCE: the shell version always exited 0. Exiting 1 when
104
+ # problems are found lets this gate a pipeline. Note a real library nearly
105
+ # always has some non-Direct-Play file, so `show-codecs && next` will
106
+ # usually stop - use `; ` rather than `&&` if that is not what you want.
107
+ return 0 if problems == 0 else 1
108
+
109
+
110
+ if __name__ == "__main__":
111
+ run_cli(main)