@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,27 +20,30 @@ needed now.
|
|
|
20
20
|
from __future__ import annotations
|
|
21
21
|
|
|
22
22
|
import sys
|
|
23
|
+
from collections import Counter
|
|
24
|
+
from collections.abc import Callable
|
|
23
25
|
from pathlib import Path
|
|
26
|
+
from typing import NamedTuple
|
|
24
27
|
|
|
25
28
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
26
29
|
|
|
30
|
+
from media import probe_media
|
|
27
31
|
from utils import (
|
|
28
32
|
add_bool_flag,
|
|
29
33
|
build_parser,
|
|
30
|
-
ffprobe_entries,
|
|
31
34
|
format_bytes,
|
|
32
35
|
info,
|
|
33
36
|
iter_files,
|
|
34
37
|
note,
|
|
38
|
+
note_settings,
|
|
35
39
|
require_binary,
|
|
36
|
-
run,
|
|
37
40
|
run_cli,
|
|
38
41
|
scan_root,
|
|
39
42
|
success,
|
|
40
43
|
warning,
|
|
41
44
|
)
|
|
42
45
|
|
|
43
|
-
__version__ = "2.1.
|
|
46
|
+
__version__ = "2.1.1"
|
|
44
47
|
|
|
45
48
|
# (score penalty, note). An empty note means "penalise silently", matching the
|
|
46
49
|
# shell version, which docked a point for ac3/eac3 without explaining why.
|
|
@@ -74,6 +77,22 @@ SUBTITLE_PENALTIES: dict[str, tuple[int, str]] = {
|
|
|
74
77
|
}
|
|
75
78
|
|
|
76
79
|
|
|
80
|
+
class Tier(NamedTuple):
|
|
81
|
+
floor: int # the lowest score in the tier
|
|
82
|
+
label: str
|
|
83
|
+
logger: Callable[[str], None]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# Best first: a score lands in the first tier it reaches. The labels double as
|
|
87
|
+
# the summary rows, in this order.
|
|
88
|
+
TIERS: tuple[Tier, ...] = (
|
|
89
|
+
Tier(9, "Excellent", success),
|
|
90
|
+
Tier(7, "Good", info),
|
|
91
|
+
Tier(4, "Poor", note),
|
|
92
|
+
Tier(0, "Bad", warning),
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
77
96
|
def rate_video(
|
|
78
97
|
video_codec: str,
|
|
79
98
|
audio_codec: str,
|
|
@@ -113,9 +132,9 @@ def rate_video(
|
|
|
113
132
|
return score, ";".join(reasons) if reasons else "Excellent Plex compatibility"
|
|
114
133
|
|
|
115
134
|
|
|
116
|
-
def
|
|
117
|
-
"""
|
|
118
|
-
return
|
|
135
|
+
def tier(score: int) -> Tier:
|
|
136
|
+
"""The ``TIERS`` row for a 0-10 score."""
|
|
137
|
+
return next(row for row in TIERS if score >= row.floor)
|
|
119
138
|
|
|
120
139
|
|
|
121
140
|
def main(argv: list[str] | None = None) -> int:
|
|
@@ -136,18 +155,14 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
136
155
|
|
|
137
156
|
require_binary("ffprobe", hint="Install FFmpeg: brew install ffmpeg")
|
|
138
157
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
parser.print_help()
|
|
142
|
-
return 1
|
|
143
|
-
|
|
158
|
+
# An empty --path reads "❌ --path is required" without the usage block
|
|
159
|
+
# the shell version printed after it.
|
|
144
160
|
root = scan_root(args.path)
|
|
145
161
|
|
|
146
|
-
|
|
147
|
-
note(f"Recursive: {str(args.recursive).lower()}")
|
|
148
|
-
note("----------------------------------------------------")
|
|
162
|
+
note_settings(("Scanning", args.path), ("Recursive", args.recursive))
|
|
149
163
|
|
|
150
|
-
total =
|
|
164
|
+
total = 0
|
|
165
|
+
counts: Counter[str] = Counter()
|
|
151
166
|
|
|
152
167
|
# macOS ._ sidecars are skipped: they are never real media, and ffprobing
|
|
153
168
|
# each one would waste a subprocess per file on a NAS scan. The shell
|
|
@@ -159,70 +174,40 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
159
174
|
note("Checking:")
|
|
160
175
|
print(f" {path}", flush=True)
|
|
161
176
|
|
|
162
|
-
|
|
163
|
-
if
|
|
177
|
+
probe = probe_media(path)
|
|
178
|
+
if probe is None:
|
|
164
179
|
warning(" Corrupt or unreadable file")
|
|
165
180
|
print(flush=True)
|
|
166
|
-
|
|
181
|
+
counts["Bad"] += 1
|
|
167
182
|
continue
|
|
168
183
|
|
|
169
|
-
video_codec = (
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
)
|
|
175
|
-
audio_codec = (
|
|
176
|
-
ffprobe_entries(
|
|
177
|
-
path, "stream=codec_name", stream="a:0", fmt="default=noprint_wrappers=1:nokey=1"
|
|
178
|
-
)
|
|
179
|
-
or "unknown"
|
|
180
|
-
)
|
|
181
|
-
subtitle_codec = (
|
|
182
|
-
ffprobe_entries(
|
|
183
|
-
path, "stream=codec_name", stream="s:0", fmt="default=noprint_wrappers=1:nokey=1"
|
|
184
|
-
)
|
|
185
|
-
or "none"
|
|
186
|
-
)
|
|
187
|
-
width_raw = ffprobe_entries(
|
|
188
|
-
path, "stream=width", stream="v:0", fmt="default=noprint_wrappers=1:nokey=1"
|
|
189
|
-
)
|
|
190
|
-
bitrate_raw = ffprobe_entries(
|
|
191
|
-
path, "format=bit_rate", fmt="default=noprint_wrappers=1:nokey=1"
|
|
192
|
-
)
|
|
193
|
-
width = as_int(width_raw)
|
|
194
|
-
bitrate = as_int(bitrate_raw)
|
|
184
|
+
video_codec = probe.codec("video") or "unknown"
|
|
185
|
+
audio_codec = probe.codec("audio") or "unknown"
|
|
186
|
+
subtitle_codec = probe.codec("subtitle") or "none"
|
|
187
|
+
width = probe.width()
|
|
188
|
+
bitrate = probe.bit_rate()
|
|
195
189
|
|
|
196
190
|
try:
|
|
197
191
|
filesize = path.stat().st_size
|
|
198
192
|
except OSError:
|
|
199
193
|
filesize = 0
|
|
200
194
|
|
|
201
|
-
score, notes = rate_video(
|
|
195
|
+
score, notes = rate_video(
|
|
196
|
+
video_codec, audio_codec, subtitle_codec, width or 0, bitrate or 0
|
|
197
|
+
)
|
|
202
198
|
|
|
203
199
|
print(f" Size : {format_bytes(filesize)}", flush=True)
|
|
204
200
|
print(f" Video : {video_codec}", flush=True)
|
|
205
201
|
print(f" Audio : {audio_codec}", flush=True)
|
|
206
202
|
print(f" Subtitles : {subtitle_codec}", flush=True)
|
|
207
|
-
|
|
208
|
-
# value being numeric, so a literal 0 still printed its line.
|
|
209
|
-
if width_raw.isdigit():
|
|
203
|
+
if width is not None:
|
|
210
204
|
print(f" Resolution: {width}px", flush=True)
|
|
211
|
-
if
|
|
205
|
+
if bitrate is not None:
|
|
212
206
|
print(f" Bitrate : {bitrate // 1_000_000} Mbps", flush=True)
|
|
213
207
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
elif score >= 7:
|
|
218
|
-
info(f" Plex Score: {score}/10")
|
|
219
|
-
good += 1
|
|
220
|
-
elif score >= 4:
|
|
221
|
-
note(f" Plex Score: {score}/10")
|
|
222
|
-
poor += 1
|
|
223
|
-
else:
|
|
224
|
-
warning(f" Plex Score: {score}/10")
|
|
225
|
-
bad += 1
|
|
208
|
+
rated = tier(score)
|
|
209
|
+
rated.logger(f" Plex Score: {score}/10")
|
|
210
|
+
counts[rated.label] += 1
|
|
226
211
|
|
|
227
212
|
print(f" Notes : {notes}", flush=True)
|
|
228
213
|
print(flush=True)
|
|
@@ -231,10 +216,8 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
231
216
|
success("Scan complete")
|
|
232
217
|
print(flush=True)
|
|
233
218
|
print(f"Total Files : {total}", flush=True)
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
print(f"Poor : {poor}", flush=True)
|
|
237
|
-
print(f"Bad : {bad}", flush=True)
|
|
219
|
+
for row in TIERS:
|
|
220
|
+
print(f"{row.label:<12}: {counts[row.label]}", flush=True)
|
|
238
221
|
return 0
|
|
239
222
|
|
|
240
223
|
|
package/bin/video/fix-codecs.py
CHANGED
|
@@ -25,14 +25,17 @@ from pathlib import Path
|
|
|
25
25
|
|
|
26
26
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
27
27
|
|
|
28
|
+
from media import CODEC_SCAN_EXTENSIONS
|
|
28
29
|
from utils import (
|
|
29
30
|
add_bool_flag,
|
|
30
31
|
build_parser,
|
|
32
|
+
dry_run_complete,
|
|
31
33
|
env_bool,
|
|
32
34
|
info,
|
|
33
35
|
iter_files,
|
|
34
36
|
log,
|
|
35
|
-
|
|
37
|
+
note_settings,
|
|
38
|
+
remove_or_warn,
|
|
36
39
|
require_binary,
|
|
37
40
|
run,
|
|
38
41
|
run_cli,
|
|
@@ -41,9 +44,7 @@ from utils import (
|
|
|
41
44
|
warning,
|
|
42
45
|
)
|
|
43
46
|
|
|
44
|
-
__version__ = "3.
|
|
45
|
-
|
|
46
|
-
SCAN_EXTENSIONS = ("mp4", "mkv", "mov", "avi")
|
|
47
|
+
__version__ = "3.1.0"
|
|
47
48
|
|
|
48
49
|
FFMPEG_ARGS = (
|
|
49
50
|
"-map",
|
|
@@ -120,7 +121,6 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
120
121
|
add_bool_flag(
|
|
121
122
|
parser,
|
|
122
123
|
"--delete-original",
|
|
123
|
-
dest="delete_original",
|
|
124
124
|
help="Delete each source file after a successful convert",
|
|
125
125
|
)
|
|
126
126
|
add_bool_flag(
|
|
@@ -133,25 +133,24 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
133
133
|
info(f"Running command in {Path.cwd()}")
|
|
134
134
|
args = parser.parse_args(argv)
|
|
135
135
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
# Binary check before the path check, matching the shell version's order,
|
|
141
|
-
# so a missing ffmpeg is reported even when the path is also wrong.
|
|
136
|
+
# Binary check before the directory check, matching the shell version's
|
|
137
|
+
# order, so a missing ffmpeg is reported even when the path is also wrong.
|
|
138
|
+
# An empty --path reads "--path is required" after it, where the shell
|
|
139
|
+
# version said "Error: You must provide a path with --path=" before it.
|
|
142
140
|
require_binary("ffmpeg", hint="Install FFmpeg first.")
|
|
143
141
|
|
|
144
142
|
root = scan_root(args.path)
|
|
145
143
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
144
|
+
note_settings(
|
|
145
|
+
("Scanning", args.path),
|
|
146
|
+
("Delete original after conversion", args.delete_original),
|
|
147
|
+
("Dry run", args.dry_run),
|
|
148
|
+
)
|
|
150
149
|
|
|
151
150
|
converted = 0
|
|
152
151
|
failed = 0
|
|
153
152
|
|
|
154
|
-
for path in iter_files(root, extensions=
|
|
153
|
+
for path in iter_files(root, extensions=CODEC_SCAN_EXTENSIONS):
|
|
155
154
|
# Don't re-encode our own output on a second run. Announced rather than
|
|
156
155
|
# silent, because scene "PROPER/FIXED" repacks legitimately end in
|
|
157
156
|
# _fixed and would otherwise vanish from the listing with no trace.
|
|
@@ -161,19 +160,20 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
161
160
|
|
|
162
161
|
output = path.with_name(f"{path.stem}_fixed.mp4")
|
|
163
162
|
|
|
164
|
-
if args.dry_run:
|
|
165
|
-
info(f"[DRY RUN] Would re-encode: {path} -> {output}")
|
|
166
|
-
if args.delete_original:
|
|
167
|
-
info(f"[DRY RUN] Would delete original: {path}")
|
|
168
|
-
continue
|
|
169
|
-
|
|
170
163
|
# Never silently replace an existing conversion; the user may have
|
|
171
164
|
# produced it deliberately, and there is no way to tell it apart from
|
|
172
|
-
# one this run just made for a different same-stem source.
|
|
165
|
+
# one this run just made for a different same-stem source. Checked
|
|
166
|
+
# before the dry-run branch so a preview reports the same skips.
|
|
173
167
|
if output.exists():
|
|
174
168
|
warning(f"⏭️ Skipping (output already exists): {output}")
|
|
175
169
|
continue
|
|
176
170
|
|
|
171
|
+
if args.dry_run:
|
|
172
|
+
info(f"[DRY RUN] Would re-encode: {path} -> {output}")
|
|
173
|
+
if args.delete_original:
|
|
174
|
+
info(f"[DRY RUN] Would delete original: {path}")
|
|
175
|
+
continue
|
|
176
|
+
|
|
177
177
|
log(f"Processing: {path}")
|
|
178
178
|
|
|
179
179
|
if not encode(path, output):
|
|
@@ -184,16 +184,11 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
184
184
|
log(f"✅ Converted to: {output}")
|
|
185
185
|
converted += 1
|
|
186
186
|
|
|
187
|
-
if args.delete_original:
|
|
188
|
-
try:
|
|
189
|
-
path.unlink()
|
|
190
|
-
except OSError as exc:
|
|
191
|
-
warning(f" ✗ Could not delete {path}: {exc}")
|
|
192
|
-
continue
|
|
187
|
+
if args.delete_original and remove_or_warn(path):
|
|
193
188
|
warning(f"🗑 Deleted original: {path}")
|
|
194
189
|
|
|
195
190
|
if args.dry_run:
|
|
196
|
-
|
|
191
|
+
dry_run_complete()
|
|
197
192
|
return 0
|
|
198
193
|
|
|
199
194
|
# Summary and the non-zero exit on failure are both new; the shell version
|
|
@@ -20,6 +20,7 @@ from __future__ import annotations
|
|
|
20
20
|
import os
|
|
21
21
|
import re
|
|
22
22
|
import sys
|
|
23
|
+
from collections.abc import Callable
|
|
23
24
|
from pathlib import Path
|
|
24
25
|
|
|
25
26
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
@@ -30,12 +31,14 @@ from utils import (
|
|
|
30
31
|
format_bytes,
|
|
31
32
|
info,
|
|
32
33
|
note,
|
|
34
|
+
note_settings,
|
|
35
|
+
parse_count,
|
|
33
36
|
run_cli,
|
|
34
37
|
scan_root,
|
|
35
38
|
warning,
|
|
36
39
|
)
|
|
37
40
|
|
|
38
|
-
__version__ = "3.
|
|
41
|
+
__version__ = "3.1.0"
|
|
39
42
|
|
|
40
43
|
# `^Season[[:space:]]+[0-9]+$` in the shell version. \Z rather than $ because $
|
|
41
44
|
# also matches before a trailing newline, and an explicit [ \t] rather than \s
|
|
@@ -43,34 +46,36 @@ __version__ = "3.0.0"
|
|
|
43
46
|
# otherwise match where the shell version's POSIX classes did not.
|
|
44
47
|
SEASON_RE = re.compile(r"Season[ \t]+[0-9]+\Z")
|
|
45
48
|
|
|
46
|
-
_NON_NEGATIVE_INT = re.compile(r"[0-9]+")
|
|
47
49
|
|
|
50
|
+
def _silent(_message: str) -> None:
|
|
51
|
+
"""The ``trace`` used without ``--debug``."""
|
|
48
52
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
53
|
+
|
|
54
|
+
def is_tv_show_folder(
|
|
55
|
+
path: Path, child_dirs: list[str], *, trace: Callable[[str], None] = _silent
|
|
56
|
+
) -> bool:
|
|
57
|
+
"""True when one of ``child_dirs``, the folders directly in ``path``, is ``Season <N>``.
|
|
58
|
+
|
|
59
|
+
``child_dirs`` is the listing ``os.walk`` already made, so a folder is
|
|
60
|
+
read once. Taken in its sorted order, which only shows in ``trace``.
|
|
61
|
+
"""
|
|
62
|
+
trace(f"[DEBUG] Checking folder: {path}")
|
|
63
|
+
for name in child_dirs:
|
|
64
|
+
# os.walk lists a symlinked folder without descending into it. find
|
|
65
|
+
# -type d does not match a symlink; following one here would report a
|
|
66
|
+
# show whose season folder lives elsewhere, and then size it at zero
|
|
67
|
+
# because the walk below refuses to descend.
|
|
68
|
+
if (path / name).is_symlink():
|
|
69
|
+
continue
|
|
70
|
+
trace(f"[DEBUG] Found child folder: {name}")
|
|
71
|
+
if SEASON_RE.fullmatch(name):
|
|
72
|
+
trace(f"[DEBUG] Matched season folder in: {path}")
|
|
73
|
+
return True
|
|
74
|
+
trace(f"[DEBUG] No season folders found in: {path}")
|
|
70
75
|
return False
|
|
71
76
|
|
|
72
77
|
|
|
73
|
-
def folder_size(path: Path
|
|
78
|
+
def folder_size(path: Path) -> int:
|
|
74
79
|
"""Disk usage under ``path``, matching ``du -sk`` closely enough to rank by.
|
|
75
80
|
|
|
76
81
|
``st_blocks * 512`` is ALLOCATED size, which is what ``du`` reports - not
|
|
@@ -78,12 +83,13 @@ def folder_size(path: Path, seen: set[tuple[int, int]]) -> int:
|
|
|
78
83
|
here: a sparse preallocated download reads as its full final size, and a
|
|
79
84
|
library of many small files reads far under its real footprint.
|
|
80
85
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
+
A hard-linked file is counted once, again matching ``du``. Sonarr and
|
|
87
|
+
Radarr hardlink into the library while seeding, so without this a show
|
|
88
|
+
holding both copies would double. The de-duplication is per-show, because
|
|
89
|
+
the shell version ran one ``du -sk`` per show and each invocation
|
|
90
|
+
de-duplicates only within its own tree.
|
|
86
91
|
"""
|
|
92
|
+
seen: set[tuple[int, int]] = set()
|
|
87
93
|
total = 0
|
|
88
94
|
for dirpath, _, filenames in os.walk(path, followlinks=False):
|
|
89
95
|
for name in filenames:
|
|
@@ -106,36 +112,35 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
106
112
|
description="📺 Rank TV show folders in a library by total size on disk.",
|
|
107
113
|
)
|
|
108
114
|
parser.add_argument("--path", default="", metavar="DIR", help="TV library root (required)")
|
|
109
|
-
# Kept as a string so "+5" is rejected the way
|
|
110
|
-
# ^[0-9]+$ test rejected it,
|
|
115
|
+
# Kept as a string and checked by parse_count, so "+5" is rejected the way
|
|
116
|
+
# the shell version's ^[0-9]+$ test rejected it, not accepted by int().
|
|
111
117
|
parser.add_argument("--limit", default="20", metavar="N", help="How many to list")
|
|
112
|
-
add_bool_flag(parser, "--full-path",
|
|
118
|
+
add_bool_flag(parser, "--full-path", help="Print the whole path")
|
|
113
119
|
add_bool_flag(parser, "--debug", help="Print traversal detail")
|
|
114
120
|
|
|
115
121
|
info(f"Running command in {Path.cwd()}")
|
|
116
122
|
args = parser.parse_args(argv)
|
|
117
123
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
"📋 Usage: largest-tv-shows.py --path=/path/to/tv [--limit=20] [--full-path] [--debug]"
|
|
126
|
-
)
|
|
127
|
-
return 1
|
|
124
|
+
note_settings(
|
|
125
|
+
("Searching in", args.path),
|
|
126
|
+
("Limit", args.limit),
|
|
127
|
+
("Show full path", args.full_path),
|
|
128
|
+
("Debug", args.debug),
|
|
129
|
+
rule=False,
|
|
130
|
+
)
|
|
128
131
|
|
|
132
|
+
# An empty --path reads "--path is required", where the shell version
|
|
133
|
+
# printed a one-line usage.
|
|
129
134
|
root = scan_root(args.path)
|
|
130
135
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
limit = int(args.limit)
|
|
136
|
+
# Same test as the shell version's ^[0-9]+$ and -lt 1. The message is the
|
|
137
|
+
# repo-wide parse_count one, which also echoes the rejected value.
|
|
138
|
+
limit = parse_count(args.limit, "--limit", minimum=1)
|
|
135
139
|
|
|
136
140
|
note("Scanning for TV show folders...")
|
|
137
141
|
|
|
138
|
-
|
|
142
|
+
trace = note if args.debug else _silent
|
|
143
|
+
shows: list[tuple[int, Path]] = []
|
|
139
144
|
|
|
140
145
|
# Nested show folders are listed too (a "Doctor Who Confidential" inside
|
|
141
146
|
# "Doctor Who" is its own row), matching the shell version. Its bytes still
|
|
@@ -143,14 +148,12 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
143
148
|
for dirpath, dirnames, _ in os.walk(root, followlinks=False):
|
|
144
149
|
dirnames.sort()
|
|
145
150
|
directory = Path(dirpath)
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
if not is_tv_show_folder(directory, debug=args.debug):
|
|
151
|
+
trace(f"[DEBUG] Scanning candidate: {directory}")
|
|
152
|
+
if not is_tv_show_folder(directory, dirnames, trace=trace):
|
|
149
153
|
continue
|
|
150
|
-
size = folder_size(directory
|
|
151
|
-
shows.append((size, directory
|
|
152
|
-
|
|
153
|
-
note(f"[DEBUG] Detected TV show folder: {directory.name} | Size: {size}")
|
|
154
|
+
size = folder_size(directory)
|
|
155
|
+
shows.append((size, directory))
|
|
156
|
+
trace(f"[DEBUG] Detected TV show folder: {directory.name} | Size: {size}")
|
|
154
157
|
|
|
155
158
|
info(f"Detected {len(shows)} TV show folder(s)")
|
|
156
159
|
|
|
@@ -161,11 +164,11 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
161
164
|
# `sort -rn` reverses the whole comparison, including its last-resort
|
|
162
165
|
# tiebreak on the full line - so equal-sized shows come out in DESCENDING
|
|
163
166
|
# name order. Reversing both key fields reproduces that.
|
|
164
|
-
shows.sort(key=lambda item: (item[0], item[1]), reverse=True)
|
|
167
|
+
shows.sort(key=lambda item: (item[0], item[1].name), reverse=True)
|
|
165
168
|
|
|
166
169
|
note(f"Top {limit} largest TV show folders:")
|
|
167
|
-
for size,
|
|
168
|
-
label = str(path) if args.full_path else name
|
|
170
|
+
for size, path in shows[:limit]:
|
|
171
|
+
label = str(path) if args.full_path else path.name
|
|
169
172
|
print(f"{format_bytes(size):<12} {label}", flush=True)
|
|
170
173
|
|
|
171
174
|
return 0
|
|
@@ -32,7 +32,7 @@ from utils import (
|
|
|
32
32
|
warning,
|
|
33
33
|
)
|
|
34
34
|
|
|
35
|
-
__version__ = "2.1.
|
|
35
|
+
__version__ = "2.1.1"
|
|
36
36
|
|
|
37
37
|
DEFAULT_EXTS = (
|
|
38
38
|
"mp4",
|
|
@@ -68,11 +68,8 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
68
68
|
info(f"Running command in {Path.cwd()}")
|
|
69
69
|
args = parser.parse_args(argv)
|
|
70
70
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
parser.print_help()
|
|
74
|
-
return 1
|
|
75
|
-
|
|
71
|
+
# An empty --path reads "--path is required", where the shell version
|
|
72
|
+
# printed "--path must be specified" and the full usage.
|
|
76
73
|
root = scan_root(args.path)
|
|
77
74
|
|
|
78
75
|
require_binary("exiftool", hint="Install exiftool and retry.")
|