@codebend3r/che 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +424 -0
- package/bin/che.py +869 -0
- package/bin/commands.py +872 -0
- package/bin/drives/eject-all-drives.py +203 -0
- package/bin/drives/mount-all-drives.py +140 -0
- package/bin/drives/ping-nas.py +242 -0
- package/bin/files/compress-folders.py +188 -0
- package/bin/files/delete-by-ext.py +108 -0
- package/bin/files/delete-empty-folders.py +136 -0
- package/bin/files/delete-smb-files.py +105 -0
- package/bin/files/files-under-size.py +132 -0
- package/bin/files/find-largest-files.py +102 -0
- package/bin/files/make-alpha-dir.py +63 -0
- package/bin/git/all-actions.py +545 -0
- package/bin/git/checkout-my-branches.py +147 -0
- package/bin/git/clean-stale-branches.py +161 -0
- package/bin/git/prune-worktrees.py +143 -0
- package/bin/git/sync-all-branches.py +368 -0
- package/bin/git/update-local-branches.py +195 -0
- package/bin/install.py +1515 -0
- package/bin/shellgen.py +782 -0
- package/bin/system/btop-launch.py +114 -0
- package/bin/system/update-brew.py +146 -0
- package/bin/tui.py +648 -0
- package/bin/utils.py +836 -0
- package/bin/video/delete-duplicate-videos.py +313 -0
- package/bin/video/detect-green-magenta-videos.py +268 -0
- package/bin/video/find-movie-by-year.py +84 -0
- package/bin/video/find-video-mkv-issues.py +242 -0
- package/bin/video/fix-codecs.py +209 -0
- package/bin/video/largest-tv-shows.py +175 -0
- package/bin/video/remove-metadata.py +126 -0
- package/bin/video/rename-video-file.py +329 -0
- package/bin/video/scan-videos-audio-language.py +110 -0
- package/bin/video/show-codecs.py +111 -0
- package/bin/video/validate-video-files.py +114 -0
- package/bin/video/video-list.py +120 -0
- package/package.json +54 -0
- package/shell/che.bash +382 -0
- package/shell/che.fish +282 -0
- package/shell/che.zsh +389 -0
- package/shell/completions/_che +146 -0
- package/shell/completions/che.bash +107 -0
- package/shell/completions/che.fish +371 -0
|
@@ -0,0 +1,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)
|