@codebend3r/che 1.1.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 +38 -24
- package/bin/art.py +22 -20
- package/bin/che.py +208 -587
- 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 -1182
- 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 +503 -431
- 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
|
@@ -20,17 +20,19 @@ from __future__ import annotations
|
|
|
20
20
|
import os
|
|
21
21
|
import re
|
|
22
22
|
import sys
|
|
23
|
+
from dataclasses import dataclass
|
|
23
24
|
from pathlib import Path
|
|
24
25
|
|
|
25
26
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
26
27
|
|
|
28
|
+
from media import LIBRARY_EXTENSIONS
|
|
27
29
|
from utils import (
|
|
28
30
|
add_bool_flag,
|
|
29
31
|
build_parser,
|
|
30
32
|
info,
|
|
31
33
|
iter_files,
|
|
32
34
|
log,
|
|
33
|
-
|
|
35
|
+
note_settings,
|
|
34
36
|
run_cli,
|
|
35
37
|
scan_root,
|
|
36
38
|
warning,
|
|
@@ -38,8 +40,6 @@ from utils import (
|
|
|
38
40
|
|
|
39
41
|
__version__ = "3.0.0"
|
|
40
42
|
|
|
41
|
-
VIDEO_EXTENSIONS = ("mp4", "mkv")
|
|
42
|
-
|
|
43
43
|
# Every flavour of curly quote, backtick and acute accent people end up with,
|
|
44
44
|
# normalised to a plain straight apostrophe.
|
|
45
45
|
_APOSTROPHES = str.maketrans({"’": "'", "‘": "'", "´": "'", "`": "'"}) # noqa: RUF001
|
|
@@ -100,8 +100,8 @@ def title_case(text: str, pattern: re.Pattern[str]) -> str:
|
|
|
100
100
|
def replace(match: re.Match[str]) -> str:
|
|
101
101
|
if match.group("code"):
|
|
102
102
|
return match.group("code").upper()
|
|
103
|
-
if
|
|
104
|
-
return
|
|
103
|
+
if keep := match.groupdict().get("keep"):
|
|
104
|
+
return keep
|
|
105
105
|
return match.group("first").upper() + match.group("rest").lower()
|
|
106
106
|
|
|
107
107
|
return pattern.sub(replace, text)
|
|
@@ -123,51 +123,59 @@ def normalize(text: str) -> str:
|
|
|
123
123
|
return _CODE_REJOIN.sub(r"\1-\2", text)
|
|
124
124
|
|
|
125
125
|
|
|
126
|
-
def
|
|
127
|
-
"""
|
|
128
|
-
|
|
129
|
-
if not capitalize_preps:
|
|
130
|
-
result = apply_preposition_rules(result)
|
|
131
|
-
return result
|
|
126
|
+
def _lower_contractions(text: str) -> str:
|
|
127
|
+
"""Lowercase the one or two letters after an apostrophe: "It'S" -> "It's"."""
|
|
128
|
+
return _APOSTROPHE_SUFFIX.sub(lambda m: "'" + m.group(1).lower(), text)
|
|
132
129
|
|
|
133
130
|
|
|
134
|
-
|
|
135
|
-
|
|
131
|
+
@dataclass(frozen=True)
|
|
132
|
+
class TitleCaser:
|
|
133
|
+
"""The casing policy for one run: the words to keep, and the prepositions rule."""
|
|
136
134
|
|
|
137
|
-
|
|
138
|
-
|
|
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)
|
|
135
|
+
pattern: re.Pattern[str]
|
|
136
|
+
capitalize_preps: bool
|
|
144
137
|
|
|
138
|
+
@classmethod
|
|
139
|
+
def build(cls, ignore_words: list[str], *, capitalize_preps: bool) -> TitleCaser:
|
|
140
|
+
return cls(build_title_pattern(ignore_words), capitalize_preps)
|
|
145
141
|
|
|
146
|
-
def
|
|
147
|
-
|
|
142
|
+
def segment(self, text: str) -> str:
|
|
143
|
+
"""Title-case one name segment end to end."""
|
|
144
|
+
result = title_case(text, self.pattern)
|
|
145
|
+
return result if self.capitalize_preps else apply_preposition_rules(result)
|
|
148
146
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
147
|
+
def folder_name(self, name: str) -> str:
|
|
148
|
+
"""The corrected name for a folder.
|
|
149
|
+
|
|
150
|
+
Folders now get the same curly-quote normalisation as files. The shell
|
|
151
|
+
version appeared to do this too, but both of its folder-branch sed
|
|
152
|
+
patterns were the plain ASCII apostrophe - two no-ops - so a folder
|
|
153
|
+
called ``l'auberge`` with a typographic apostrophe was left alone.
|
|
154
|
+
"""
|
|
155
|
+
return _lower_contractions(self.segment(normalize(name)))
|
|
156
|
+
|
|
157
|
+
def file_name(self, name: str) -> str | None:
|
|
158
|
+
"""The corrected name for a file, or None when it cannot be parsed.
|
|
156
159
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
160
|
+
Anchored with ``fullmatch`` where the shell version used an unanchored
|
|
161
|
+
``[[ =~ ]]`` search. The two agree on every ordinary name; the
|
|
162
|
+
difference is dot-leading ones, where the shell version matched from
|
|
163
|
+
after the dot and so renamed ``.hidden movie.mkv`` to ``Hidden
|
|
164
|
+
Movie.mkv`` - silently un-hiding the file. Those are reported as
|
|
165
|
+
unparseable instead.
|
|
166
|
+
"""
|
|
167
|
+
normalized = normalize(name)
|
|
165
168
|
|
|
166
|
-
|
|
167
|
-
|
|
169
|
+
match = _DASHED_NAME.fullmatch(normalized)
|
|
170
|
+
if match:
|
|
171
|
+
prefix, title, ext = match.group(1), match.group(2), match.group(3)
|
|
172
|
+
else:
|
|
173
|
+
match = _PLAIN_NAME.fullmatch(normalized)
|
|
174
|
+
if not match:
|
|
175
|
+
return None
|
|
176
|
+
prefix, title, ext = "", match.group(1), match.group(2)
|
|
168
177
|
|
|
169
|
-
|
|
170
|
-
return _APOSTROPHE_SUFFIX.sub(lambda m: "'" + m.group(1).lower(), result)
|
|
178
|
+
return _lower_contractions(f"{self.segment(prefix)}{self.segment(title)}{ext}")
|
|
171
179
|
|
|
172
180
|
|
|
173
181
|
def safe_rename(source: Path, target: Path, *, kind: str) -> bool:
|
|
@@ -192,29 +200,28 @@ def safe_rename(source: Path, target: Path, *, kind: str) -> bool:
|
|
|
192
200
|
return True
|
|
193
201
|
|
|
194
202
|
|
|
195
|
-
def rename_folders(
|
|
196
|
-
root: Path, pattern: re.Pattern[str], *, recursive: bool, capitalize_preps: bool, dry_run: bool
|
|
197
|
-
) -> None:
|
|
203
|
+
def rename_folders(root: Path, caser: TitleCaser, *, recursive: bool, dry_run: bool) -> None:
|
|
198
204
|
"""Rename folders deepest-first so a parent rename cannot break child paths."""
|
|
199
205
|
directories: list[Path] = []
|
|
200
|
-
for dirpath,
|
|
206
|
+
for dirpath, dirnames, _ in os.walk(root, followlinks=False):
|
|
201
207
|
directory = Path(dirpath)
|
|
202
208
|
if directory == root:
|
|
203
209
|
continue
|
|
204
|
-
if not recursive and directory.parent != root:
|
|
205
|
-
continue
|
|
206
210
|
directories.append(directory)
|
|
211
|
+
# Not recursive: the root's own children are the only folders renamed,
|
|
212
|
+
# so the walk need not open anything below them.
|
|
213
|
+
if not recursive:
|
|
214
|
+
dirnames.clear()
|
|
207
215
|
|
|
208
|
-
#
|
|
209
|
-
#
|
|
210
|
-
# Deepest first, so renaming a parent cannot invalidate a child's path.
|
|
216
|
+
# The walk is top-down, so the list is re-sorted: deepest first, so
|
|
217
|
+
# renaming a parent cannot invalidate a child's path.
|
|
211
218
|
directories.sort(key=lambda p: (-len(p.parts), p))
|
|
212
219
|
|
|
213
220
|
for folder in directories:
|
|
214
221
|
if folder.name.startswith("._"):
|
|
215
222
|
continue
|
|
216
223
|
|
|
217
|
-
new_name =
|
|
224
|
+
new_name = caser.folder_name(folder.name)
|
|
218
225
|
if new_name == folder.name:
|
|
219
226
|
warning(f"⚠️ Skipping folder (already correct format): {folder.name}")
|
|
220
227
|
continue
|
|
@@ -228,16 +235,14 @@ def rename_folders(
|
|
|
228
235
|
log(f"📁 Renaming folder: {folder.name} ➡️ {new_name}")
|
|
229
236
|
|
|
230
237
|
|
|
231
|
-
def rename_files(
|
|
232
|
-
root: Path, pattern: re.Pattern[str], *, recursive: bool, capitalize_preps: bool, dry_run: bool
|
|
233
|
-
) -> None:
|
|
238
|
+
def rename_files(root: Path, caser: TitleCaser, *, recursive: bool, dry_run: bool) -> None:
|
|
234
239
|
"""Rename the .mp4/.mkv files themselves."""
|
|
235
240
|
# case_sensitive: this script's find used -name, not -iname, so a file
|
|
236
241
|
# called "Movie.MKV" was never renamed.
|
|
237
242
|
for path in iter_files(
|
|
238
|
-
root, extensions=
|
|
243
|
+
root, extensions=LIBRARY_EXTENSIONS, recursive=recursive, case_sensitive=True
|
|
239
244
|
):
|
|
240
|
-
new_name =
|
|
245
|
+
new_name = caser.file_name(path.name)
|
|
241
246
|
if new_name is None:
|
|
242
247
|
warning(f"Could not parse filename: {path.name}")
|
|
243
248
|
continue
|
|
@@ -262,13 +267,10 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
262
267
|
)
|
|
263
268
|
parser.add_argument("--path", default="", metavar="DIR", help="Directory to scan (required)")
|
|
264
269
|
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
|
-
)
|
|
270
|
+
add_bool_flag(parser, "--rename-folders", default=True, help="Rename folders too")
|
|
268
271
|
add_bool_flag(
|
|
269
272
|
parser,
|
|
270
273
|
"--capitalize-preps",
|
|
271
|
-
dest="capitalize_preps",
|
|
272
274
|
help="Capitalize articles and prepositions",
|
|
273
275
|
)
|
|
274
276
|
# Deliberately NOT env_bool: unlike the delete tools, the shell version
|
|
@@ -284,7 +286,6 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
284
286
|
parser.add_argument(
|
|
285
287
|
"--ignore-words",
|
|
286
288
|
default="",
|
|
287
|
-
dest="ignore_words",
|
|
288
289
|
metavar="LIST",
|
|
289
290
|
help="Comma-separated words to leave exactly as written",
|
|
290
291
|
)
|
|
@@ -294,34 +295,23 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
294
295
|
|
|
295
296
|
ignore_words = [word.strip() for word in args.ignore_words.split(",") if word.strip()]
|
|
296
297
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
298
|
+
settings: list[tuple[str, object]] = [
|
|
299
|
+
("Scanning", args.path),
|
|
300
|
+
("Recursive", args.recursive),
|
|
301
|
+
("Rename Folders", args.rename_folders),
|
|
302
|
+
("Capitalize Prepositions", args.capitalize_preps),
|
|
303
|
+
("Dry Run", args.dry_run),
|
|
304
|
+
]
|
|
302
305
|
if ignore_words:
|
|
303
|
-
|
|
304
|
-
|
|
306
|
+
settings.append(("Ignore Words", ",".join(ignore_words)))
|
|
307
|
+
note_settings(*settings)
|
|
305
308
|
|
|
306
309
|
root = scan_root(args.path)
|
|
307
|
-
|
|
310
|
+
caser = TitleCaser.build(ignore_words, capitalize_preps=args.capitalize_preps)
|
|
308
311
|
|
|
309
312
|
if args.rename_folders:
|
|
310
|
-
rename_folders(
|
|
311
|
-
|
|
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
|
-
)
|
|
313
|
+
rename_folders(root, caser, recursive=args.recursive, dry_run=args.dry_run)
|
|
314
|
+
rename_files(root, caser, recursive=args.recursive, dry_run=args.dry_run)
|
|
325
315
|
return 0
|
|
326
316
|
|
|
327
317
|
|
|
@@ -16,20 +16,19 @@ from pathlib import Path
|
|
|
16
16
|
|
|
17
17
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
18
18
|
|
|
19
|
+
from media import probe_media
|
|
19
20
|
from utils import (
|
|
20
|
-
NC,
|
|
21
21
|
build_parser,
|
|
22
|
-
color_enabled,
|
|
23
|
-
ffprobe_entries,
|
|
24
22
|
info,
|
|
25
23
|
iter_files,
|
|
24
|
+
paint,
|
|
26
25
|
require_binary,
|
|
27
26
|
run_cli,
|
|
28
27
|
scan_root,
|
|
29
28
|
warning,
|
|
30
29
|
)
|
|
31
30
|
|
|
32
|
-
__version__ = "3.
|
|
31
|
+
__version__ = "3.1.0"
|
|
33
32
|
|
|
34
33
|
VIDEO_EXTENSIONS = ("mp4", "mkv", "avi", "mov", "flv", "wmv", "webm")
|
|
35
34
|
|
|
@@ -49,22 +48,6 @@ LANG_COLOR = {
|
|
|
49
48
|
DEFAULT_COLOR = "\033[90m" # Gray for unknown languages
|
|
50
49
|
|
|
51
50
|
|
|
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
51
|
def main(argv: list[str] | None = None) -> int:
|
|
69
52
|
parser = build_parser(
|
|
70
53
|
prog="scan-videos-audio-language",
|
|
@@ -74,28 +57,28 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
74
57
|
info(f"Running command in {Path.cwd()}")
|
|
75
58
|
args = parser.parse_args(argv)
|
|
76
59
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
return 1
|
|
80
|
-
|
|
60
|
+
# An empty --path reads "--path is required", where the shell version
|
|
61
|
+
# reported `'' is not a valid directory`.
|
|
81
62
|
root = scan_root(args.path)
|
|
82
63
|
|
|
83
64
|
require_binary("ffprobe", hint="ffprobe (from ffmpeg) is required but not installed.")
|
|
84
65
|
|
|
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
66
|
for path in iter_files(root, extensions=VIDEO_EXTENSIONS):
|
|
93
67
|
info(f"File: {path}")
|
|
94
68
|
|
|
95
|
-
|
|
96
|
-
|
|
69
|
+
# Telling "no language tags" apart from "could not be read" matters:
|
|
70
|
+
# the shell version aborted the whole scan on an unreadable file
|
|
71
|
+
# (`set -e` on a failing command substitution), so it never had to
|
|
72
|
+
# render the difference.
|
|
73
|
+
probe = probe_media(path)
|
|
74
|
+
if probe is None:
|
|
97
75
|
warning(f"Audio language(s): {paint('Unreadable - ffprobe failed', DEFAULT_COLOR)}")
|
|
98
76
|
continue
|
|
77
|
+
|
|
78
|
+
# DELIBERATE DIVERGENCE: the shell version's csv output read `und,`
|
|
79
|
+
# for AC3 or E-AC3 audio in an MP4 on ffprobe 9 (see MediaProbe), and
|
|
80
|
+
# a bare `,` when such a stream had no tag. Both now read cleanly.
|
|
81
|
+
languages = probe.languages("audio")
|
|
99
82
|
if not languages:
|
|
100
83
|
info(f"Audio language(s): {paint('Unknown or not tagged', DEFAULT_COLOR)}")
|
|
101
84
|
continue
|
package/bin/video/show-codecs.py
CHANGED
|
@@ -17,21 +17,21 @@ from pathlib import Path
|
|
|
17
17
|
|
|
18
18
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
19
19
|
|
|
20
|
+
from media import CODEC_SCAN_EXTENSIONS, probe_media
|
|
20
21
|
from utils import (
|
|
21
22
|
add_bool_flag,
|
|
22
23
|
build_parser,
|
|
23
|
-
ffprobe_entries,
|
|
24
24
|
info,
|
|
25
25
|
iter_files,
|
|
26
26
|
log,
|
|
27
|
-
|
|
27
|
+
note_settings,
|
|
28
28
|
require_binary,
|
|
29
29
|
run_cli,
|
|
30
30
|
scan_root,
|
|
31
31
|
warning,
|
|
32
32
|
)
|
|
33
33
|
|
|
34
|
-
__version__ = "3.
|
|
34
|
+
__version__ = "3.1.0"
|
|
35
35
|
|
|
36
36
|
# The common safe set for Direct Play. Tuples, not sets, so the banner prints
|
|
37
37
|
# them in the order the shell version's arrays declared them.
|
|
@@ -39,8 +39,6 @@ ALLOWED_VIDEO_CODECS = ("h264", "hevc")
|
|
|
39
39
|
ALLOWED_AUDIO_CODECS = ("aac", "ac3", "eac3")
|
|
40
40
|
ALLOWED_CONTAINERS = ("mov", "mp4", "mkv")
|
|
41
41
|
|
|
42
|
-
SCAN_EXTENSIONS = ("mp4", "mkv", "mov", "avi")
|
|
43
|
-
|
|
44
42
|
|
|
45
43
|
def main(argv: list[str] | None = None) -> int:
|
|
46
44
|
parser = build_parser(
|
|
@@ -52,18 +50,12 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
52
50
|
info(f"Running command in {Path.cwd()}")
|
|
53
51
|
args = parser.parse_args(argv)
|
|
54
52
|
|
|
55
|
-
|
|
56
|
-
note(f"Verbose: {str(args.verbose).lower()}")
|
|
57
|
-
note("----------------------------------------------------")
|
|
53
|
+
note_settings(("Scanning", args.path), ("Verbose", args.verbose))
|
|
58
54
|
|
|
59
|
-
|
|
60
|
-
warning("❌ --path is required")
|
|
61
|
-
return 1
|
|
55
|
+
root = scan_root(args.path)
|
|
62
56
|
|
|
63
57
|
require_binary("ffprobe", hint="Install FFmpeg: brew install ffmpeg")
|
|
64
58
|
|
|
65
|
-
root = scan_root(args.path)
|
|
66
|
-
|
|
67
59
|
print(f"Scanning for problematic codecs in: {args.path}")
|
|
68
60
|
print(f"Allowed Video: {' '.join(ALLOWED_VIDEO_CODECS)}")
|
|
69
61
|
print(f"Allowed Audio: {' '.join(ALLOWED_AUDIO_CODECS)}")
|
|
@@ -72,19 +64,23 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
72
64
|
|
|
73
65
|
problems = 0
|
|
74
66
|
|
|
75
|
-
for path in iter_files(root, extensions=
|
|
67
|
+
for path in iter_files(root, extensions=CODEC_SCAN_EXTENSIONS):
|
|
76
68
|
container = path.name.lower().rsplit(".", 1)[-1]
|
|
77
69
|
|
|
78
|
-
# "unknown"
|
|
79
|
-
#
|
|
80
|
-
# has no audio track keeps the empty
|
|
81
|
-
# so the
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
70
|
+
# "unknown" when ffprobe itself FAILS (a corrupt file), matching the
|
|
71
|
+
# shell version's `|| echo "unknown"`, or when it cannot name a codec.
|
|
72
|
+
# A file that probes fine but has no audio track keeps the empty
|
|
73
|
+
# string the shell version stored, so the cases stay distinguishable.
|
|
74
|
+
#
|
|
75
|
+
# DELIBERATE DIVERGENCE: the shell version's csv output read `ac3,`
|
|
76
|
+
# for AC3 or E-AC3 audio in an MP4 on ffprobe 9 (see MediaProbe), so
|
|
77
|
+
# every such file failed the allowed-codec check. They now pass.
|
|
78
|
+
probe = probe_media(path)
|
|
79
|
+
if probe is None:
|
|
80
|
+
video_codec = audio_codec = "unknown"
|
|
81
|
+
else:
|
|
82
|
+
video_codec = probe.codec("video").lower()
|
|
83
|
+
audio_codec = probe.codec("audio").lower()
|
|
88
84
|
|
|
89
85
|
ok = (
|
|
90
86
|
video_codec in ALLOWED_VIDEO_CODECS
|
|
@@ -18,6 +18,7 @@ from pathlib import Path
|
|
|
18
18
|
|
|
19
19
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
20
20
|
|
|
21
|
+
from media import LIBRARY_EXTENSIONS
|
|
21
22
|
from utils import (
|
|
22
23
|
add_bool_flag,
|
|
23
24
|
build_parser,
|
|
@@ -31,9 +32,7 @@ from utils import (
|
|
|
31
32
|
warning,
|
|
32
33
|
)
|
|
33
34
|
|
|
34
|
-
__version__ = "3.
|
|
35
|
-
|
|
36
|
-
VIDEO_EXTENSIONS = ("mp4", "mkv")
|
|
35
|
+
__version__ = "3.1.0"
|
|
37
36
|
|
|
38
37
|
|
|
39
38
|
def main(argv: list[str] | None = None) -> int:
|
|
@@ -46,10 +45,8 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
46
45
|
info(f"Running command in {Path.cwd()}")
|
|
47
46
|
args = parser.parse_args(argv)
|
|
48
47
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
return 1
|
|
52
|
-
|
|
48
|
+
# An empty --path reads "--path is required", where the shell version
|
|
49
|
+
# reported `'' is not a valid directory`.
|
|
53
50
|
root = scan_root(args.path)
|
|
54
51
|
|
|
55
52
|
require_binary("mpv", hint="Install it using Homebrew:\n brew install mpv")
|
|
@@ -57,7 +54,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
57
54
|
checked = 0
|
|
58
55
|
bad: list[Path] = []
|
|
59
56
|
|
|
60
|
-
for path in iter_files(root, extensions=
|
|
57
|
+
for path in iter_files(root, extensions=LIBRARY_EXTENSIONS):
|
|
61
58
|
# The shell version gated this on `[[ -z "$VERBOSE" ]]`, but VERBOSE is
|
|
62
59
|
# the literal string "false" when unset, which is never empty - so the
|
|
63
60
|
# line never printed and --verbose did nothing. Wired up properly here.
|
package/bin/video/video-list.py
CHANGED
|
@@ -21,10 +21,10 @@ from pathlib import Path
|
|
|
21
21
|
|
|
22
22
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
23
23
|
|
|
24
|
+
from media import LIBRARY_EXTENSIONS
|
|
24
25
|
from utils import (
|
|
25
26
|
CYAN,
|
|
26
27
|
GREEN,
|
|
27
|
-
NC,
|
|
28
28
|
YELLOW,
|
|
29
29
|
add_bool_flag,
|
|
30
30
|
build_parser,
|
|
@@ -32,15 +32,14 @@ from utils import (
|
|
|
32
32
|
info,
|
|
33
33
|
iter_files,
|
|
34
34
|
log,
|
|
35
|
-
|
|
35
|
+
note_settings,
|
|
36
|
+
paint,
|
|
36
37
|
run_cli,
|
|
37
38
|
scan_root,
|
|
38
|
-
warning,
|
|
39
39
|
)
|
|
40
40
|
|
|
41
|
-
__version__ = "3.
|
|
41
|
+
__version__ = "3.1.0"
|
|
42
42
|
|
|
43
|
-
VIDEO_EXTENSIONS = ("mp4", "mkv")
|
|
44
43
|
SORT_METHODS = ("alpha", "fileSizeAsc", "fileSizeDesc")
|
|
45
44
|
|
|
46
45
|
|
|
@@ -51,7 +50,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
51
50
|
)
|
|
52
51
|
parser.add_argument("--path", default="", metavar="DIR", help="Directory to scan (required)")
|
|
53
52
|
add_bool_flag(parser, "--recursive", help="Recurse into subdirectories")
|
|
54
|
-
add_bool_flag(parser, "--with-folder",
|
|
53
|
+
add_bool_flag(parser, "--with-folder", help="Prefix with folder name")
|
|
55
54
|
parser.add_argument(
|
|
56
55
|
"--sort",
|
|
57
56
|
default="alpha",
|
|
@@ -62,15 +61,12 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
62
61
|
info(f"Running command in {Path.cwd()}")
|
|
63
62
|
args = parser.parse_args(argv)
|
|
64
63
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
if not args.path:
|
|
72
|
-
warning("❌ --path is required")
|
|
73
|
-
return 1
|
|
64
|
+
note_settings(
|
|
65
|
+
("Scanning", args.path),
|
|
66
|
+
("Recursive", args.recursive),
|
|
67
|
+
("Sort method", args.sort),
|
|
68
|
+
("With folder", args.with_folder),
|
|
69
|
+
)
|
|
74
70
|
|
|
75
71
|
# Replaces the shell version's `find` error on a missing path with a
|
|
76
72
|
# styled message; the exit code is unchanged.
|
|
@@ -79,7 +75,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
79
75
|
entries: list[tuple[int, Path]] = []
|
|
80
76
|
for path in iter_files(
|
|
81
77
|
root,
|
|
82
|
-
extensions=
|
|
78
|
+
extensions=LIBRARY_EXTENSIONS,
|
|
83
79
|
recursive=args.recursive,
|
|
84
80
|
skip_hidden=True,
|
|
85
81
|
):
|
|
@@ -101,16 +97,17 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
101
97
|
locale.setlocale(locale.LC_COLLATE, "")
|
|
102
98
|
entries.sort(key=lambda item: locale.strxfrm(str(item[1])))
|
|
103
99
|
|
|
100
|
+
# DELIBERATE DIVERGENCE: the shell version baked these escapes into every
|
|
101
|
+
# line unconditionally, so NO_COLOR or a pipe still got them raw. paint()
|
|
102
|
+
# honours both, like every other coloured line in the repo.
|
|
104
103
|
for size, path in entries:
|
|
105
|
-
|
|
104
|
+
display = f"{paint(path.name, GREEN)} {paint(f'[{format_bytes(size)}]', YELLOW)}"
|
|
106
105
|
if args.with_folder:
|
|
107
106
|
# `basename $(dirname ./x.mkv)` is ".", but pathlib collapses the
|
|
108
107
|
# "." away and leaves parent.name empty - which would render as a
|
|
109
108
|
# bare leading slash and read like an absolute path.
|
|
110
109
|
folder = path.parent.name or path.parent.as_posix()
|
|
111
|
-
display = f"{CYAN}
|
|
112
|
-
else:
|
|
113
|
-
display = f"{GREEN}{path.name}{NC} {YELLOW}[{size_human}]{NC}"
|
|
110
|
+
display = f"{paint(folder, CYAN)}/{display}"
|
|
114
111
|
log(display)
|
|
115
112
|
|
|
116
113
|
return 0
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@codebend3r/che",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Helper scripts shared across machines, called by name from your shell and browsable through one menu",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cli",
|
|
@@ -27,7 +27,6 @@
|
|
|
27
27
|
"files": [
|
|
28
28
|
"bin/",
|
|
29
29
|
"shell/",
|
|
30
|
-
"!bin/version-bump.py",
|
|
31
30
|
"!**/__pycache__/",
|
|
32
31
|
"!**/*.pyc"
|
|
33
32
|
],
|
|
@@ -36,19 +35,14 @@
|
|
|
36
35
|
},
|
|
37
36
|
"scripts": {
|
|
38
37
|
"test": "uv run --no-project --with-requirements requirements-dev.txt -- pytest",
|
|
39
|
-
"ci": "run
|
|
38
|
+
"ci": "bun run --sequential lint format executable shebang test",
|
|
40
39
|
"lint": "uv run --no-project --with-requirements requirements-dev.txt -- ruff check bin test",
|
|
41
40
|
"format": "uv run --no-project --with-requirements requirements-dev.txt -- ruff format --check bin test",
|
|
42
|
-
"syntax": "find bin test -type f -name '*.py' -print0 | xargs -0 -n1 python3 -m py_compile",
|
|
43
41
|
"executable": "find bin -type f -name '*.py' ! -perm -u+x -print0 | xargs -0 awk 'FNR==1 && /^#!/{print \"Has a shebang but is not executable: \" FILENAME; ec=1} END{exit ec+0}'",
|
|
44
42
|
"shebang": "find bin -type f -name '*.py' -perm -u+x -print0 | xargs -0 awk 'FNR==1 && !/^#!/{print \"Executable but has no shebang: \" FILENAME; ec=1} END{exit ec+0}'",
|
|
45
|
-
"generate": "python3 bin/
|
|
46
|
-
"generated": "python3 bin/install.py --check",
|
|
43
|
+
"generate": "python3 bin/shellgen.py",
|
|
47
44
|
"version": "bun run generate && git add shell .zshrc README.md",
|
|
48
45
|
"prepublishOnly": "bun run ci"
|
|
49
46
|
},
|
|
50
|
-
"packageManager": "bun@1.
|
|
51
|
-
"devDependencies": {
|
|
52
|
-
"npm-run-all2": "9.0.1"
|
|
53
|
-
}
|
|
47
|
+
"packageManager": "bun@1.4.2"
|
|
54
48
|
}
|