@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,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)
|