@codebend3r/che 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +424 -0
  3. package/bin/che.py +869 -0
  4. package/bin/commands.py +872 -0
  5. package/bin/drives/eject-all-drives.py +203 -0
  6. package/bin/drives/mount-all-drives.py +140 -0
  7. package/bin/drives/ping-nas.py +242 -0
  8. package/bin/files/compress-folders.py +188 -0
  9. package/bin/files/delete-by-ext.py +108 -0
  10. package/bin/files/delete-empty-folders.py +136 -0
  11. package/bin/files/delete-smb-files.py +105 -0
  12. package/bin/files/files-under-size.py +132 -0
  13. package/bin/files/find-largest-files.py +102 -0
  14. package/bin/files/make-alpha-dir.py +63 -0
  15. package/bin/git/all-actions.py +545 -0
  16. package/bin/git/checkout-my-branches.py +147 -0
  17. package/bin/git/clean-stale-branches.py +161 -0
  18. package/bin/git/prune-worktrees.py +143 -0
  19. package/bin/git/sync-all-branches.py +368 -0
  20. package/bin/git/update-local-branches.py +195 -0
  21. package/bin/install.py +1515 -0
  22. package/bin/shellgen.py +782 -0
  23. package/bin/system/btop-launch.py +114 -0
  24. package/bin/system/update-brew.py +146 -0
  25. package/bin/tui.py +648 -0
  26. package/bin/utils.py +836 -0
  27. package/bin/video/delete-duplicate-videos.py +313 -0
  28. package/bin/video/detect-green-magenta-videos.py +268 -0
  29. package/bin/video/find-movie-by-year.py +84 -0
  30. package/bin/video/find-video-mkv-issues.py +242 -0
  31. package/bin/video/fix-codecs.py +209 -0
  32. package/bin/video/largest-tv-shows.py +175 -0
  33. package/bin/video/remove-metadata.py +126 -0
  34. package/bin/video/rename-video-file.py +329 -0
  35. package/bin/video/scan-videos-audio-language.py +110 -0
  36. package/bin/video/show-codecs.py +111 -0
  37. package/bin/video/validate-video-files.py +114 -0
  38. package/bin/video/video-list.py +120 -0
  39. package/package.json +54 -0
  40. package/shell/che.bash +382 -0
  41. package/shell/che.fish +282 -0
  42. package/shell/che.zsh +389 -0
  43. package/shell/completions/_che +146 -0
  44. package/shell/completions/che.bash +107 -0
  45. package/shell/completions/che.fish +371 -0
@@ -0,0 +1,242 @@
1
+ #!/usr/bin/env python3
2
+ """🎬 Scan MKV files and estimate Plex direct-play compatibility.
3
+
4
+ Usage:
5
+ find-video-mkv-issues.py --path=/media/path [--recursive]
6
+
7
+ Options:
8
+ --path=PATH Required path to scan
9
+ --recursive Recursively scan subfolders (default: false)
10
+ --help Show help
11
+
12
+ Examples:
13
+ find-video-mkv-issues.py --path=./Movies
14
+ find-video-mkv-issues.py --path=/mnt/media --recursive
15
+
16
+ Requires ffprobe. The shell version also required `find` and `bc`; neither is
17
+ needed now.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
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
+ ffprobe_entries,
31
+ format_bytes,
32
+ info,
33
+ iter_files,
34
+ note,
35
+ require_binary,
36
+ run,
37
+ run_cli,
38
+ scan_root,
39
+ success,
40
+ warning,
41
+ )
42
+
43
+ __version__ = "2.1.0"
44
+
45
+ # (score penalty, note). An empty note means "penalise silently", matching the
46
+ # shell version, which docked a point for ac3/eac3 without explaining why.
47
+ VIDEO_PENALTIES: dict[str, tuple[int, str]] = {
48
+ "h264": (0, ""),
49
+ "hevc": (1, "HEVC may transcode on older clients"),
50
+ "h265": (1, "HEVC may transcode on older clients"),
51
+ "vp9": (3, "VP9 support inconsistent"),
52
+ "av1": (5, "AV1 unsupported on many Plex devices"),
53
+ "vc1": (7, "Legacy video codec"),
54
+ "wmv3": (7, "Legacy video codec"),
55
+ "mpeg2video": (5, "MPEG2 commonly transcoded"),
56
+ }
57
+ VIDEO_DEFAULT = (8, "Unknown or niche video codec")
58
+
59
+ AUDIO_PENALTIES: dict[str, tuple[int, str]] = {
60
+ "aac": (0, ""),
61
+ "ac3": (1, ""),
62
+ "eac3": (1, ""),
63
+ "dts": (3, "DTS unsupported on many TVs/mobile devices"),
64
+ "truehd": (5, "TrueHD frequently transcoded"),
65
+ "flac": (3, "FLAC audio may transcode"),
66
+ "mp2": (5, "MP2 audio uncommon"),
67
+ }
68
+ AUDIO_DEFAULT = (4, "Unknown or niche audio codec")
69
+
70
+ SUBTITLE_PENALTIES: dict[str, tuple[int, str]] = {
71
+ "pgs": (2, "PGS subtitles may force transcoding"),
72
+ "hdmv_pgs_subtitle": (2, "PGS subtitles may force transcoding"),
73
+ "dvd_subtitle": (2, "Image subtitles may transcode"),
74
+ }
75
+
76
+
77
+ def rate_video(
78
+ video_codec: str,
79
+ audio_codec: str,
80
+ subtitle_codec: str,
81
+ width: int,
82
+ bitrate: int,
83
+ ) -> tuple[int, str]:
84
+ """Score a file 0-10 for Plex direct play, with the reasons for the deductions."""
85
+ score = 10
86
+ reasons: list[str] = []
87
+
88
+ for table, default, codec in (
89
+ (VIDEO_PENALTIES, VIDEO_DEFAULT, video_codec),
90
+ (AUDIO_PENALTIES, AUDIO_DEFAULT, audio_codec),
91
+ (SUBTITLE_PENALTIES, (0, ""), subtitle_codec),
92
+ ):
93
+ penalty, reason = table.get(codec, default)
94
+ score -= penalty
95
+ if reason:
96
+ reasons.append(reason)
97
+
98
+ if width >= 3840:
99
+ score -= 1
100
+ reasons.append("4K playback may struggle on weaker devices")
101
+
102
+ mbps = bitrate // 1_000_000
103
+ if mbps > 60:
104
+ score -= 2
105
+ reasons.append("Very high bitrate")
106
+ elif mbps > 30:
107
+ score -= 1
108
+ reasons.append("High bitrate")
109
+
110
+ score = max(0, min(10, score))
111
+ # ";" with no space: the shell version's `IFS='; '; echo "${reasons[*]}"`
112
+ # joins on the FIRST character of IFS only.
113
+ return score, ";".join(reasons) if reasons else "Excellent Plex compatibility"
114
+
115
+
116
+ def as_int(value: str) -> int:
117
+ """ffprobe prints 'N/A' for absent fields; treat anything non-numeric as 0."""
118
+ return int(value) if value.isdigit() else 0
119
+
120
+
121
+ def main(argv: list[str] | None = None) -> int:
122
+ parser = build_parser(
123
+ prog="find-video-mkv-issues",
124
+ description="🎬 Scan MKV files and estimate Plex direct-play compatibility.",
125
+ epilog=(
126
+ "Examples:\n"
127
+ " find-video-mkv-issues.py --path=./Movies\n"
128
+ " find-video-mkv-issues.py --path=/mnt/media --recursive"
129
+ ),
130
+ )
131
+ parser.add_argument("--path", default="", metavar="PATH", help="Required path to scan")
132
+ add_bool_flag(parser, "--recursive", help="Recursively scan subfolders (default: false)")
133
+
134
+ info(f"Running command in {Path.cwd()}")
135
+ args = parser.parse_args(argv)
136
+
137
+ require_binary("ffprobe", hint="Install FFmpeg: brew install ffmpeg")
138
+
139
+ if not args.path:
140
+ warning("--path is required")
141
+ parser.print_help()
142
+ return 1
143
+
144
+ root = scan_root(args.path)
145
+
146
+ note(f"Scanning: {args.path}")
147
+ note(f"Recursive: {str(args.recursive).lower()}")
148
+ note("----------------------------------------------------")
149
+
150
+ total = excellent = good = poor = bad = 0
151
+
152
+ # macOS ._ sidecars are skipped: they are never real media, and ffprobing
153
+ # each one would waste a subprocess per file on a NAS scan. The shell
154
+ # version probed them, failed, and counted each one as Bad - so Total and
155
+ # Bad both read lower here on a library that has them.
156
+ for path in iter_files(root, extensions=["mkv"], recursive=args.recursive):
157
+ total += 1
158
+
159
+ note("Checking:")
160
+ print(f" {path}", flush=True)
161
+
162
+ readable = run(["ffprobe", "-v", "error", str(path)], check=False, capture=True)
163
+ if readable.returncode != 0:
164
+ warning(" Corrupt or unreadable file")
165
+ print(flush=True)
166
+ bad += 1
167
+ continue
168
+
169
+ video_codec = (
170
+ ffprobe_entries(
171
+ path, "stream=codec_name", stream="v:0", fmt="default=noprint_wrappers=1:nokey=1"
172
+ )
173
+ or "unknown"
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)
195
+
196
+ try:
197
+ filesize = path.stat().st_size
198
+ except OSError:
199
+ filesize = 0
200
+
201
+ score, notes = rate_video(video_codec, audio_codec, subtitle_codec, width, bitrate)
202
+
203
+ print(f" Size : {format_bytes(filesize)}", flush=True)
204
+ print(f" Video : {video_codec}", flush=True)
205
+ print(f" Audio : {audio_codec}", flush=True)
206
+ print(f" Subtitles : {subtitle_codec}", flush=True)
207
+ # `is not None` rather than truthiness: the shell version gated on the
208
+ # value being numeric, so a literal 0 still printed its line.
209
+ if width_raw.isdigit():
210
+ print(f" Resolution: {width}px", flush=True)
211
+ if bitrate_raw.isdigit():
212
+ print(f" Bitrate : {bitrate // 1_000_000} Mbps", flush=True)
213
+
214
+ if score >= 9:
215
+ success(f" Plex Score: {score}/10")
216
+ excellent += 1
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
226
+
227
+ print(f" Notes : {notes}", flush=True)
228
+ print(flush=True)
229
+
230
+ note("----------------------------------------------------")
231
+ success("Scan complete")
232
+ print(flush=True)
233
+ print(f"Total Files : {total}", flush=True)
234
+ print(f"Excellent : {excellent}", flush=True)
235
+ print(f"Good : {good}", flush=True)
236
+ print(f"Poor : {poor}", flush=True)
237
+ print(f"Bad : {bad}", flush=True)
238
+ return 0
239
+
240
+
241
+ if __name__ == "__main__":
242
+ run_cli(main)
@@ -0,0 +1,209 @@
1
+ #!/usr/bin/env python3
2
+ """🔧 Re-encode media to h265/aac mp4 so it Direct Plays.
3
+
4
+ Usage:
5
+ fix-codecs.py --path=/path/to/media [--delete-original] [--dry-run]
6
+
7
+ Options:
8
+ --path=DIR Directory to scan (required)
9
+ --delete-original Delete each source file after a successful convert
10
+ --dry-run Print what would be converted, change nothing
11
+ --help Show help
12
+
13
+ Defaults: --delete-original=false, --dry-run=true
14
+
15
+ Env:
16
+ DRY_RUN=false Opt out of the preview-only default
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import os
22
+ import subprocess
23
+ import sys
24
+ from pathlib import Path
25
+
26
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
27
+
28
+ from utils import (
29
+ add_bool_flag,
30
+ build_parser,
31
+ env_bool,
32
+ info,
33
+ iter_files,
34
+ log,
35
+ note,
36
+ require_binary,
37
+ run,
38
+ run_cli,
39
+ scan_root,
40
+ success,
41
+ warning,
42
+ )
43
+
44
+ __version__ = "3.0.0"
45
+
46
+ SCAN_EXTENSIONS = ("mp4", "mkv", "mov", "avi")
47
+
48
+ FFMPEG_ARGS = (
49
+ "-map",
50
+ "0:v:0",
51
+ "-map",
52
+ "0:a:0",
53
+ "-map",
54
+ "0:s:0?",
55
+ "-c:v",
56
+ "libx265",
57
+ "-preset",
58
+ "slow",
59
+ "-crf",
60
+ "22",
61
+ "-c:a",
62
+ "aac",
63
+ "-b:a",
64
+ "384k",
65
+ "-c:s",
66
+ "mov_text",
67
+ "-movflags",
68
+ "+faststart",
69
+ "-max_muxing_queue_size",
70
+ "9999",
71
+ )
72
+
73
+
74
+ def encode(source: Path, output: Path) -> bool:
75
+ """Encode ``source`` to ``output``. True on success.
76
+
77
+ Written to a unique temp sibling and moved into place only on success, so
78
+ a failed run can never delete anything but its own partial output. The
79
+ shell version wrote straight to the final name; a naive port that cleaned
80
+ up ``output`` on failure would delete a *good* file produced earlier in the
81
+ same run whenever two sources share a stem (``Show.avi`` + ``Show.mkv``
82
+ both target ``Show_fixed.mp4``, and the second one failing is routine -
83
+ ``-map 0:a:0`` fails on any file with no audio track).
84
+ """
85
+ temp = output.with_name(f".{output.name}.{os.getpid()}.part")
86
+
87
+ completed = run(
88
+ [
89
+ "ffmpeg",
90
+ # -nostdin so ffmpeg cannot put an interactive terminal into raw
91
+ # mode. DEVNULL as well, so the guarantee does not rely on the flag.
92
+ "-nostdin",
93
+ # -y: the target is our own temp file, so overwriting a leftover
94
+ # from a killed run is correct. Without it ffmpeg silently refuses
95
+ # and, on some versions, still exits 0 - a false success.
96
+ "-y",
97
+ "-i",
98
+ str(source),
99
+ *FFMPEG_ARGS,
100
+ str(temp),
101
+ ],
102
+ check=False,
103
+ stdin=subprocess.DEVNULL,
104
+ )
105
+
106
+ if completed.returncode != 0 or not temp.exists() or temp.stat().st_size == 0:
107
+ temp.unlink(missing_ok=True)
108
+ return False
109
+
110
+ temp.replace(output)
111
+ return True
112
+
113
+
114
+ def main(argv: list[str] | None = None) -> int:
115
+ parser = build_parser(
116
+ prog="fix-codecs",
117
+ description="🔧 Re-encode media to h265/aac mp4 so it Direct Plays.",
118
+ )
119
+ parser.add_argument("--path", default="", metavar="DIR", help="Directory to scan (required)")
120
+ add_bool_flag(
121
+ parser,
122
+ "--delete-original",
123
+ dest="delete_original",
124
+ help="Delete each source file after a successful convert",
125
+ )
126
+ add_bool_flag(
127
+ parser,
128
+ "--dry-run",
129
+ default=env_bool("DRY_RUN", True),
130
+ help="Print what would be converted, change nothing",
131
+ )
132
+
133
+ info(f"Running command in {Path.cwd()}")
134
+ args = parser.parse_args(argv)
135
+
136
+ if not args.path:
137
+ warning("Error: You must provide a path with --path=")
138
+ return 1
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.
142
+ require_binary("ffmpeg", hint="Install FFmpeg first.")
143
+
144
+ root = scan_root(args.path)
145
+
146
+ note(f"Scanning: {args.path}")
147
+ note(f"Delete original after conversion: {str(args.delete_original).lower()}")
148
+ note(f"Dry run: {str(args.dry_run).lower()}")
149
+ note("----------------------------------------------------")
150
+
151
+ converted = 0
152
+ failed = 0
153
+
154
+ for path in iter_files(root, extensions=SCAN_EXTENSIONS):
155
+ # Don't re-encode our own output on a second run. Announced rather than
156
+ # silent, because scene "PROPER/FIXED" repacks legitimately end in
157
+ # _fixed and would otherwise vanish from the listing with no trace.
158
+ if path.stem.endswith("_fixed"):
159
+ info(f"⏭️ Skipping (already _fixed): {path}")
160
+ continue
161
+
162
+ output = path.with_name(f"{path.stem}_fixed.mp4")
163
+
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
+ # Never silently replace an existing conversion; the user may have
171
+ # 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.
173
+ if output.exists():
174
+ warning(f"⏭️ Skipping (output already exists): {output}")
175
+ continue
176
+
177
+ log(f"Processing: {path}")
178
+
179
+ if not encode(path, output):
180
+ warning(f"❌ ffmpeg failed for: {path}")
181
+ failed += 1
182
+ continue
183
+
184
+ log(f"✅ Converted to: {output}")
185
+ converted += 1
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
193
+ warning(f"🗑 Deleted original: {path}")
194
+
195
+ if args.dry_run:
196
+ log("💡 Dry run complete. Nothing was actually changed.")
197
+ return 0
198
+
199
+ # Summary and the non-zero exit on failure are both new; the shell version
200
+ # printed nothing and always exited 0 from the loop.
201
+ success(f"✅ Converted: {converted}")
202
+ if failed:
203
+ warning(f"❌ Failed: {failed}")
204
+ return 1
205
+ return 0
206
+
207
+
208
+ if __name__ == "__main__":
209
+ run_cli(main)
@@ -0,0 +1,175 @@
1
+ #!/usr/bin/env python3
2
+ """📺 Rank TV show folders in a library by total size on disk.
3
+
4
+ A TV show folder is any folder with at least one direct child folder named
5
+ ``Season <N>``.
6
+
7
+ Usage:
8
+ largest-tv-shows.py --path=/path/to/tv [--limit=20] [--full-path] [--debug]
9
+
10
+ Options:
11
+ --path=DIR TV library root (required)
12
+ --limit=N How many shows to list (default: 20)
13
+ --full-path Print the whole path instead of just the show name
14
+ --debug Print traversal detail
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
+ format_bytes,
31
+ info,
32
+ note,
33
+ run_cli,
34
+ scan_root,
35
+ warning,
36
+ )
37
+
38
+ __version__ = "3.0.0"
39
+
40
+ # `^Season[[:space:]]+[0-9]+$` in the shell version. \Z rather than $ because $
41
+ # also matches before a trailing newline, and an explicit [ \t] rather than \s
42
+ # because Python's \s and \d are Unicode-aware - a non-ASCII digit would
43
+ # otherwise match where the shell version's POSIX classes did not.
44
+ SEASON_RE = re.compile(r"Season[ \t]+[0-9]+\Z")
45
+
46
+ _NON_NEGATIVE_INT = re.compile(r"[0-9]+")
47
+
48
+
49
+ def is_tv_show_folder(path: Path, *, debug: bool) -> bool:
50
+ """True when ``path`` has a direct child folder named ``Season <N>``."""
51
+ if debug:
52
+ note(f"[DEBUG] Checking folder: {path}")
53
+ try:
54
+ for child in path.iterdir():
55
+ # find -type d does not match a symlink; following one here would
56
+ # report a show whose season folder lives elsewhere, and then size
57
+ # it at zero because the walk below refuses to descend.
58
+ if child.is_symlink() or not child.is_dir():
59
+ continue
60
+ if debug:
61
+ note(f"[DEBUG] Found child folder: {child.name}")
62
+ if SEASON_RE.fullmatch(child.name):
63
+ if debug:
64
+ note(f"[DEBUG] Matched season folder in: {path}")
65
+ return True
66
+ except OSError:
67
+ return False
68
+ if debug:
69
+ note(f"[DEBUG] No season folders found in: {path}")
70
+ return False
71
+
72
+
73
+ def folder_size(path: Path, seen: set[tuple[int, int]]) -> int:
74
+ """Disk usage under ``path``, matching ``du -sk`` closely enough to rank by.
75
+
76
+ ``st_blocks * 512`` is ALLOCATED size, which is what ``du`` reports - not
77
+ ``st_size``, which is the apparent size. The difference is not cosmetic
78
+ here: a sparse preallocated download reads as its full final size, and a
79
+ library of many small files reads far under its real footprint.
80
+
81
+ ``seen`` collects inode identity so a hard-linked file is counted once,
82
+ again matching ``du``. Sonarr and Radarr hardlink into the library while
83
+ seeding, so without this a show holding both copies would double. The set
84
+ is per-show, because the shell version ran one ``du -sk`` per show and each
85
+ invocation de-duplicates only within its own tree.
86
+ """
87
+ total = 0
88
+ for dirpath, _, filenames in os.walk(path, followlinks=False):
89
+ for name in filenames:
90
+ try:
91
+ stat = (Path(dirpath) / name).lstat()
92
+ except OSError:
93
+ continue
94
+ if stat.st_nlink > 1:
95
+ key = (stat.st_dev, stat.st_ino)
96
+ if key in seen:
97
+ continue
98
+ seen.add(key)
99
+ total += stat.st_blocks * 512
100
+ return total
101
+
102
+
103
+ def main(argv: list[str] | None = None) -> int:
104
+ parser = build_parser(
105
+ prog="largest-tv-shows",
106
+ description="📺 Rank TV show folders in a library by total size on disk.",
107
+ )
108
+ parser.add_argument("--path", default="", metavar="DIR", help="TV library root (required)")
109
+ # Kept as a string so "+5" is rejected the way the shell version's
110
+ # ^[0-9]+$ test rejected it, rather than silently accepted by int().
111
+ parser.add_argument("--limit", default="20", metavar="N", help="How many to list")
112
+ add_bool_flag(parser, "--full-path", dest="full_path", help="Print the whole path")
113
+ add_bool_flag(parser, "--debug", help="Print traversal detail")
114
+
115
+ info(f"Running command in {Path.cwd()}")
116
+ args = parser.parse_args(argv)
117
+
118
+ note(f"Searching in: {args.path}")
119
+ note(f"Limit: {args.limit}")
120
+ note(f"Show full path: {str(args.full_path).lower()}")
121
+ note(f"Debug: {str(args.debug).lower()}")
122
+
123
+ if not args.path:
124
+ info(
125
+ "📋 Usage: largest-tv-shows.py --path=/path/to/tv [--limit=20] [--full-path] [--debug]"
126
+ )
127
+ return 1
128
+
129
+ root = scan_root(args.path)
130
+
131
+ if not _NON_NEGATIVE_INT.fullmatch(args.limit) or int(args.limit) < 1:
132
+ warning("Error: --limit must be a positive integer")
133
+ return 1
134
+ limit = int(args.limit)
135
+
136
+ note("Scanning for TV show folders...")
137
+
138
+ shows: list[tuple[int, str, Path]] = []
139
+
140
+ # Nested show folders are listed too (a "Doctor Who Confidential" inside
141
+ # "Doctor Who" is its own row), matching the shell version. Its bytes still
142
+ # count toward the parent as well - that is how `du -s` on the parent works.
143
+ for dirpath, dirnames, _ in os.walk(root, followlinks=False):
144
+ dirnames.sort()
145
+ directory = Path(dirpath)
146
+ if args.debug:
147
+ note(f"[DEBUG] Scanning candidate: {directory}")
148
+ if not is_tv_show_folder(directory, debug=args.debug):
149
+ continue
150
+ size = folder_size(directory, set())
151
+ shows.append((size, directory.name, directory))
152
+ if args.debug:
153
+ note(f"[DEBUG] Detected TV show folder: {directory.name} | Size: {size}")
154
+
155
+ info(f"Detected {len(shows)} TV show folder(s)")
156
+
157
+ if not shows:
158
+ warning("No TV show folders found.")
159
+ return 0
160
+
161
+ # `sort -rn` reverses the whole comparison, including its last-resort
162
+ # tiebreak on the full line - so equal-sized shows come out in DESCENDING
163
+ # name order. Reversing both key fields reproduces that.
164
+ shows.sort(key=lambda item: (item[0], item[1]), reverse=True)
165
+
166
+ note(f"Top {limit} largest TV show folders:")
167
+ for size, name, path in shows[:limit]:
168
+ label = str(path) if args.full_path else name
169
+ print(f"{format_bytes(size):<12} {label}", flush=True)
170
+
171
+ return 0
172
+
173
+
174
+ if __name__ == "__main__":
175
+ run_cli(main)