@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
package/bin/machine.py
ADDED
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
"""Facts about the machine che is installed on.
|
|
2
|
+
|
|
3
|
+
Where che keeps its config, which shells exist and which rc files each one
|
|
4
|
+
reads, which python can run the scripts, which external tools are missing, and
|
|
5
|
+
whether this copy of che is a git clone. The installer, the doctor and the
|
|
6
|
+
dispatcher all ask these questions; the install record is the only thing
|
|
7
|
+
written from here.
|
|
8
|
+
|
|
9
|
+
Every path is resolved from the environment at call time, never at import
|
|
10
|
+
time, so the tests can point HOME at a temporary directory.
|
|
11
|
+
|
|
12
|
+
Library module: no shebang, not executable.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import shutil
|
|
20
|
+
import subprocess
|
|
21
|
+
import sys
|
|
22
|
+
from collections.abc import Callable
|
|
23
|
+
from dataclasses import asdict, dataclass, field, fields, replace
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
from commands import PACKAGE_NAME, REPO, required_binaries
|
|
27
|
+
from utils import atomic_write_text, run
|
|
28
|
+
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
# External tools
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
# Binary -> the Homebrew formula that provides it, which is what
|
|
34
|
+
# `che install --install-deps` installs.
|
|
35
|
+
BREW_FORMULA: dict[str, str] = {
|
|
36
|
+
"ffmpeg": "ffmpeg",
|
|
37
|
+
"ffprobe": "ffmpeg",
|
|
38
|
+
"mpv": "mpv",
|
|
39
|
+
"exiftool": "exiftool",
|
|
40
|
+
"gh": "gh",
|
|
41
|
+
"btop": "btop",
|
|
42
|
+
"git": "git",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
# Binary -> how to get it. Anything absent from here is either part of macOS or
|
|
46
|
+
# has no obvious one-liner, and doctor says so rather than inventing a command.
|
|
47
|
+
INSTALL_HINTS: dict[str, str] = {
|
|
48
|
+
**{binary: f"brew install {formula}" for binary, formula in BREW_FORMULA.items()},
|
|
49
|
+
"brew": 'bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"',
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
# Binaries that ship with macOS: never suggest installing them, just report.
|
|
53
|
+
SYSTEM_BINARIES = frozenset({"diskutil", "osascript"})
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def missing_binaries() -> list[str]:
|
|
57
|
+
"""Tools some command needs that are not on PATH, in name order, macOS's own aside."""
|
|
58
|
+
return [
|
|
59
|
+
binary
|
|
60
|
+
for binary in required_binaries()
|
|
61
|
+
if binary not in SYSTEM_BINARIES and not shutil.which(binary)
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ---------------------------------------------------------------------------
|
|
66
|
+
# Locations
|
|
67
|
+
# ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def home() -> Path:
|
|
71
|
+
return Path(os.environ.get("HOME", str(Path.home())))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def xdg_config_home() -> Path:
|
|
75
|
+
return Path(os.environ.get("XDG_CONFIG_HOME") or home() / ".config")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def config_dir() -> Path:
|
|
79
|
+
return xdg_config_home() / "che"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def config_file() -> Path:
|
|
83
|
+
return config_dir() / "config.json"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def backup_dir() -> Path:
|
|
87
|
+
return config_dir() / "backups"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def state_dir() -> Path:
|
|
91
|
+
return Path(os.environ.get("XDG_STATE_HOME") or home() / ".local" / "state") / "che"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def shim_path() -> Path:
|
|
95
|
+
return home() / ".local" / "bin" / "che"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def shim_on_path() -> bool:
|
|
99
|
+
entries = os.environ.get("PATH", "").split(os.pathsep)
|
|
100
|
+
return str(shim_path().parent) in entries
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def tildify(path: Path) -> str:
|
|
104
|
+
"""``path`` for display, with the home directory spelled ``~``."""
|
|
105
|
+
try:
|
|
106
|
+
return f"~/{path.relative_to(home())}"
|
|
107
|
+
except ValueError:
|
|
108
|
+
return str(path)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# ---------------------------------------------------------------------------
|
|
112
|
+
# Shells
|
|
113
|
+
# ---------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _bash_rc_files() -> list[Path]:
|
|
117
|
+
"""Which bash startup files to write.
|
|
118
|
+
|
|
119
|
+
macOS Terminal and iTerm start bash as a *login* shell, which reads
|
|
120
|
+
``.bash_profile`` and never ``.bashrc``; most Linux terminals do the
|
|
121
|
+
opposite. Writing to whichever already exist (and to ``.bashrc`` when
|
|
122
|
+
neither does) covers both without the usual "why isn't my alias there"
|
|
123
|
+
hunt. The block is idempotent, so a ``.bash_profile`` that sources
|
|
124
|
+
``.bashrc`` just defines the same functions twice, harmlessly.
|
|
125
|
+
"""
|
|
126
|
+
candidates = [home() / ".bashrc", home() / ".bash_profile"]
|
|
127
|
+
existing = [path for path in candidates if path.exists()]
|
|
128
|
+
return existing or [home() / ".bashrc"]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _zsh_rc_files() -> list[Path]:
|
|
132
|
+
zdotdir = os.environ.get("ZDOTDIR")
|
|
133
|
+
base = Path(zdotdir) if zdotdir else home()
|
|
134
|
+
return [base / ".zshrc"]
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _fish_rc_files() -> list[Path]:
|
|
138
|
+
return [xdg_config_home() / "fish" / "config.fish"]
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _sh_rc_files() -> list[Path]:
|
|
142
|
+
return [home() / ".profile"]
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def login_shell() -> str:
|
|
146
|
+
return Path(os.environ.get("SHELL", "")).name
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@dataclass(frozen=True)
|
|
150
|
+
class ShellSpec:
|
|
151
|
+
"""A shell che can install into, how, and how it stands on this machine.
|
|
152
|
+
|
|
153
|
+
The properties are read from the environment each time they are asked, so
|
|
154
|
+
a test that moves HOME or ZDOTDIR sees the move.
|
|
155
|
+
"""
|
|
156
|
+
|
|
157
|
+
name: str
|
|
158
|
+
find_rc_files: Callable[[], list[Path]]
|
|
159
|
+
# The generated file the block sources, `shell/che.<flavor>`, and so the
|
|
160
|
+
# syntax the block itself is written in.
|
|
161
|
+
flavor: str
|
|
162
|
+
# Never installed into unless asked for, and a missing block only warns.
|
|
163
|
+
opt_in: bool = False
|
|
164
|
+
|
|
165
|
+
@property
|
|
166
|
+
def rc_files(self) -> list[Path]:
|
|
167
|
+
"""The rc files an install into this shell writes."""
|
|
168
|
+
return self.find_rc_files()
|
|
169
|
+
|
|
170
|
+
@property
|
|
171
|
+
def installed(self) -> bool:
|
|
172
|
+
"""Whether the shell itself exists on this machine."""
|
|
173
|
+
return shutil.which(self.name) is not None
|
|
174
|
+
|
|
175
|
+
@property
|
|
176
|
+
def has_rc(self) -> bool:
|
|
177
|
+
return any(path.exists() for path in self.rc_files)
|
|
178
|
+
|
|
179
|
+
@property
|
|
180
|
+
def is_login_shell(self) -> bool:
|
|
181
|
+
return self.name == login_shell()
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
SHELLS: dict[str, ShellSpec] = {
|
|
185
|
+
spec.name: spec
|
|
186
|
+
for spec in (
|
|
187
|
+
ShellSpec("zsh", _zsh_rc_files, "zsh"),
|
|
188
|
+
ShellSpec("bash", _bash_rc_files, "bash"),
|
|
189
|
+
ShellSpec("fish", _fish_rc_files, "fish"),
|
|
190
|
+
# `sh` reuses the bash wrapper file: it is plain POSIX shell apart from
|
|
191
|
+
# the completion tail, which is guarded by a $BASH_VERSION test. Opt-in,
|
|
192
|
+
# because `.profile` is read by every POSIX shell on the machine, which
|
|
193
|
+
# is rarely what someone means.
|
|
194
|
+
ShellSpec("sh", _sh_rc_files, "bash", opt_in=True),
|
|
195
|
+
)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def default_shell_selection() -> list[str]:
|
|
200
|
+
"""What to install into when the user does not say.
|
|
201
|
+
|
|
202
|
+
The login shell always, plus any other shell that is both installed and
|
|
203
|
+
already has an rc file - those are shells the user demonstrably uses.
|
|
204
|
+
Opt-in shells are never chosen here.
|
|
205
|
+
"""
|
|
206
|
+
specs = [spec for spec in SHELLS.values() if not spec.opt_in]
|
|
207
|
+
chosen = [spec.name for spec in specs if spec.is_login_shell]
|
|
208
|
+
chosen += [
|
|
209
|
+
spec.name for spec in specs if spec.name not in chosen and spec.installed and spec.has_rc
|
|
210
|
+
]
|
|
211
|
+
return chosen or ["zsh"]
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
# ---------------------------------------------------------------------------
|
|
215
|
+
# The install record
|
|
216
|
+
# ---------------------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
@dataclass(frozen=True)
|
|
220
|
+
class InstallRecord:
|
|
221
|
+
"""What ``config.json`` keeps about an install.
|
|
222
|
+
|
|
223
|
+
``che update`` repeats the install from it, ``che doctor`` checks against
|
|
224
|
+
it, and its absence is how the dispatcher knows to run the first-run
|
|
225
|
+
wizard. Every field has a default so a record an older che wrote, missing
|
|
226
|
+
a key, still loads.
|
|
227
|
+
"""
|
|
228
|
+
|
|
229
|
+
version: str = ""
|
|
230
|
+
home: str = ""
|
|
231
|
+
python: str = ""
|
|
232
|
+
shells: list[str] = field(default_factory=list)
|
|
233
|
+
rc_files: list[str] = field(default_factory=list)
|
|
234
|
+
sounds: bool = True
|
|
235
|
+
path_shim: bool = True
|
|
236
|
+
installed_at: str = ""
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def load_record() -> InstallRecord | None:
|
|
240
|
+
"""The recorded install, or None when there is none.
|
|
241
|
+
|
|
242
|
+
A missing, unreadable or empty file, or one that does not hold a JSON
|
|
243
|
+
object, all read as "not installed". Keys this che does not know (older
|
|
244
|
+
records carried ``completions``) are ignored, and an empty ``shells``
|
|
245
|
+
means the default selection - so ``che update``, which reinstalls into
|
|
246
|
+
them, and ``che doctor``, which checks them, agree on what it means.
|
|
247
|
+
"""
|
|
248
|
+
try:
|
|
249
|
+
data = json.loads(config_file().read_text(encoding="utf-8"))
|
|
250
|
+
except (OSError, ValueError):
|
|
251
|
+
return None
|
|
252
|
+
if not isinstance(data, dict) or not data:
|
|
253
|
+
return None
|
|
254
|
+
|
|
255
|
+
known = {entry.name for entry in fields(InstallRecord)}
|
|
256
|
+
record = InstallRecord(**{key: value for key, value in data.items() if key in known})
|
|
257
|
+
return record if record.shells else replace(record, shells=default_shell_selection())
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def save_record(record: InstallRecord, *, dry_run: bool = False) -> None:
|
|
261
|
+
if dry_run:
|
|
262
|
+
return
|
|
263
|
+
atomic_write_text(config_file(), json.dumps(asdict(record), indent=2) + "\n")
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
# ---------------------------------------------------------------------------
|
|
267
|
+
# Python interpreter
|
|
268
|
+
# ---------------------------------------------------------------------------
|
|
269
|
+
|
|
270
|
+
MIN_PYTHON = (3, 12)
|
|
271
|
+
PYTHON_HINT = "brew install python@3.13"
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def version_text(version: tuple[int, ...]) -> str:
|
|
275
|
+
return ".".join(str(part) for part in version)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
MIN_PYTHON_TEXT = version_text(MIN_PYTHON)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def python_ok(version: tuple[int, ...]) -> bool:
|
|
282
|
+
return tuple(version[:2]) >= MIN_PYTHON
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def python_version(executable: str) -> tuple[int, ...] | None:
|
|
286
|
+
"""Version of another interpreter, or None if it cannot be asked."""
|
|
287
|
+
if executable == sys.executable:
|
|
288
|
+
return sys.version_info[:3]
|
|
289
|
+
try:
|
|
290
|
+
output = run(
|
|
291
|
+
[executable, "-c", "import sys; print('.'.join(str(p) for p in sys.version_info[:3]))"],
|
|
292
|
+
capture=True,
|
|
293
|
+
check=True,
|
|
294
|
+
timeout=10,
|
|
295
|
+
).stdout
|
|
296
|
+
except (OSError, subprocess.SubprocessError):
|
|
297
|
+
return None
|
|
298
|
+
try:
|
|
299
|
+
return tuple(int(part) for part in output.strip().split("."))
|
|
300
|
+
except ValueError:
|
|
301
|
+
return None
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def find_python() -> tuple[str, tuple[int, ...]]:
|
|
305
|
+
"""Pick the interpreter the wrappers should use.
|
|
306
|
+
|
|
307
|
+
The interpreter running this script wins when it is new enough - it is the
|
|
308
|
+
one the user just proved works. Otherwise fall back to the newest suitable
|
|
309
|
+
``python3.X`` on PATH. macOS matters here: ``/usr/bin/python3`` is whatever
|
|
310
|
+
the Command Line Tools shipped, often years behind the Homebrew python that
|
|
311
|
+
is also installed. With nothing suitable anywhere, the running interpreter
|
|
312
|
+
comes back anyway, and the caller reports it as too old.
|
|
313
|
+
"""
|
|
314
|
+
if python_ok(sys.version_info):
|
|
315
|
+
return sys.executable, sys.version_info[:3]
|
|
316
|
+
|
|
317
|
+
for candidate in ("python3.14", "python3.13", "python3.12", "python3"):
|
|
318
|
+
resolved = shutil.which(candidate)
|
|
319
|
+
if not resolved:
|
|
320
|
+
continue
|
|
321
|
+
version = python_version(resolved)
|
|
322
|
+
if version and python_ok(version):
|
|
323
|
+
return resolved, version
|
|
324
|
+
|
|
325
|
+
return sys.executable, sys.version_info[:3]
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
# ---------------------------------------------------------------------------
|
|
329
|
+
# Where this copy of che came from
|
|
330
|
+
# ---------------------------------------------------------------------------
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def is_git_checkout() -> bool:
|
|
334
|
+
"""Whether this copy of che is a clone, which ``che update`` can pull.
|
|
335
|
+
|
|
336
|
+
The npm package is the same tree without ``.git``.
|
|
337
|
+
"""
|
|
338
|
+
return (REPO / ".git").exists()
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def update_command() -> str:
|
|
342
|
+
"""The command that fetches a newer che for this kind of install."""
|
|
343
|
+
if is_git_checkout():
|
|
344
|
+
return "git pull"
|
|
345
|
+
return f"npm install -g {PACKAGE_NAME}@latest"
|
package/bin/media.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""ffprobe helpers and shared extension lists for the scripts under ``bin/video/``.
|
|
2
|
+
|
|
3
|
+
Split out of ``utils.py``, which keeps the logging, argument parsing and
|
|
4
|
+
subprocess plumbing every script shares. Scripts pick it up the same way::
|
|
5
|
+
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
10
|
+
from media import probe_media
|
|
11
|
+
|
|
12
|
+
Standard library only, like everything else at the ``bin/`` root. ffprobe
|
|
13
|
+
itself is an external binary; callers check for it with ``require_binary``.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from utils import run
|
|
23
|
+
|
|
24
|
+
# The two containers the library is kept in: what delete-duplicate-videos,
|
|
25
|
+
# rename-video-file, validate-video-files and video-list all scan.
|
|
26
|
+
LIBRARY_EXTENSIONS = ("mp4", "mkv")
|
|
27
|
+
|
|
28
|
+
# What show-codecs audits and fix-codecs re-encodes. One list, so fix-codecs
|
|
29
|
+
# can never pass over a file show-codecs reported.
|
|
30
|
+
CODEC_SCAN_EXTENSIONS = ("mp4", "mkv", "mov", "avi")
|
|
31
|
+
|
|
32
|
+
# Everything any video script reads, so each file is opened once.
|
|
33
|
+
_PROBE_ENTRIES = "stream=codec_type,codec_name,width:stream_tags=language:format=bit_rate"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class MediaProbe:
|
|
38
|
+
"""ffprobe's view of one file's streams and container.
|
|
39
|
+
|
|
40
|
+
The codec and language accessors return what the one-field-per-call
|
|
41
|
+
ffprobe invocations the scripts used to make printed, so callers read the
|
|
42
|
+
same values as before. That takes restoring by hand: the JSON writer OMITS
|
|
43
|
+
a field ffprobe cannot fill, where the csv and default writers printed
|
|
44
|
+
``unknown`` or ``N/A``. ``width`` and ``bit_rate`` are numbers instead,
|
|
45
|
+
``None`` where ffprobe had nothing to report.
|
|
46
|
+
|
|
47
|
+
One difference is deliberate. On ffprobe 9 the csv writer appended a comma
|
|
48
|
+
per side-data block on the stream, so AC3 or E-AC3 audio in an MP4 came
|
|
49
|
+
back as ``ac3,`` and its language as ``und,``. JSON has no such artefact.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
streams: tuple[dict, ...]
|
|
53
|
+
format: dict
|
|
54
|
+
|
|
55
|
+
@classmethod
|
|
56
|
+
def from_json(cls, data: dict) -> MediaProbe:
|
|
57
|
+
"""Build from ffprobe's parsed ``-of json`` output."""
|
|
58
|
+
return cls(streams=tuple(data.get("streams", ())), format=data.get("format", {}))
|
|
59
|
+
|
|
60
|
+
def first(self, kind: str) -> dict | None:
|
|
61
|
+
"""The first ``"video"``/``"audio"``/``"subtitle"`` stream: ``-select_streams v:0``."""
|
|
62
|
+
return next((s for s in self.streams if s.get("codec_type") == kind), None)
|
|
63
|
+
|
|
64
|
+
def codec(self, kind: str) -> str:
|
|
65
|
+
"""The first ``kind`` stream's codec.
|
|
66
|
+
|
|
67
|
+
``""`` when there is no such stream, ``"unknown"`` when there is one
|
|
68
|
+
but ffprobe cannot name its codec - the two values the per-field calls
|
|
69
|
+
printed for those cases.
|
|
70
|
+
"""
|
|
71
|
+
stream = self.first(kind)
|
|
72
|
+
if stream is None:
|
|
73
|
+
return ""
|
|
74
|
+
return stream.get("codec_name", "unknown")
|
|
75
|
+
|
|
76
|
+
def width(self) -> int | None:
|
|
77
|
+
"""The first video stream's width in pixels, or None."""
|
|
78
|
+
return _count((self.first("video") or {}).get("width", ""))
|
|
79
|
+
|
|
80
|
+
def bit_rate(self) -> int | None:
|
|
81
|
+
"""The container's overall bit rate, or None where ffprobe said ``N/A``."""
|
|
82
|
+
return _count(self.format.get("bit_rate", ""))
|
|
83
|
+
|
|
84
|
+
def languages(self, kind: str) -> list[str]:
|
|
85
|
+
"""Every language tagged on a ``kind`` stream, sorted and de-duplicated.
|
|
86
|
+
|
|
87
|
+
The key is matched case-insensitively because ``-show_entries
|
|
88
|
+
stream_tags=language`` matches that way, so a container's ``LANGUAGE``
|
|
89
|
+
tag arrives under its own spelling.
|
|
90
|
+
"""
|
|
91
|
+
return sorted(
|
|
92
|
+
{
|
|
93
|
+
value.strip()
|
|
94
|
+
for stream in self.streams
|
|
95
|
+
if stream.get("codec_type") == kind
|
|
96
|
+
for key, value in stream.get("tags", {}).items()
|
|
97
|
+
if key.lower() == "language" and value.strip()
|
|
98
|
+
}
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _count(value: object) -> int | None:
|
|
103
|
+
"""``value`` as a non-negative integer, or None when it is absent or not numeric."""
|
|
104
|
+
text = str(value)
|
|
105
|
+
return int(text) if text.isdecimal() else None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def probe_media(path: Path) -> MediaProbe | None:
|
|
109
|
+
"""Probe ``path`` once for everything the video scripts read.
|
|
110
|
+
|
|
111
|
+
Returns None when ffprobe fails - a corrupt or unreadable file - which
|
|
112
|
+
callers have to tell apart from a readable file that lacks a stream or a
|
|
113
|
+
tag. One process per file, where the scripts used to spawn one per field:
|
|
114
|
+
on an SMB share each of those re-opened the file and re-read its header.
|
|
115
|
+
"""
|
|
116
|
+
completed = run(
|
|
117
|
+
["ffprobe", "-v", "error", "-show_entries", _PROBE_ENTRIES, "-of", "json", str(path)],
|
|
118
|
+
check=False,
|
|
119
|
+
capture=True,
|
|
120
|
+
)
|
|
121
|
+
if completed.returncode != 0:
|
|
122
|
+
return None
|
|
123
|
+
try:
|
|
124
|
+
data = json.loads(completed.stdout or "")
|
|
125
|
+
except json.JSONDecodeError:
|
|
126
|
+
return None
|
|
127
|
+
return MediaProbe.from_json(data)
|