@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,188 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""📦 Create a max-compression .zip of every immediate subfolder of a path.
|
|
3
|
+
|
|
4
|
+
Each ``<folder>`` becomes ``<folder>.zip`` alongside it.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
compress-folders.py --path=/dir [--dry-run] [--verbose]
|
|
8
|
+
|
|
9
|
+
Options:
|
|
10
|
+
--path=DIR Parent directory whose subfolders should be zipped (required)
|
|
11
|
+
--dry-run Print what would be zipped, change nothing
|
|
12
|
+
--verbose Show per-file progress
|
|
13
|
+
--help Show help
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import os
|
|
19
|
+
import sys
|
|
20
|
+
import zipfile
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
24
|
+
|
|
25
|
+
from utils import (
|
|
26
|
+
add_bool_flag,
|
|
27
|
+
build_parser,
|
|
28
|
+
info,
|
|
29
|
+
log,
|
|
30
|
+
note,
|
|
31
|
+
run_cli,
|
|
32
|
+
scan_root,
|
|
33
|
+
success,
|
|
34
|
+
warning,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
__version__ = "3.0.0"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _walk_entries(folder: Path) -> list[tuple[Path, str]]:
|
|
41
|
+
"""Every entry to store, as ``(source_path, archive_name)`` pairs.
|
|
42
|
+
|
|
43
|
+
Symlinks are FOLLOWED, matching ``zip -r`` - which, contrary to a common
|
|
44
|
+
assumption, dereferences by default and only stores links as links with
|
|
45
|
+
``-y``. Skipping them would silently drop file content from the archive.
|
|
46
|
+
|
|
47
|
+
Directory inodes already visited are not re-entered, so a symlink loop
|
|
48
|
+
cannot hang the walk. ``zip`` has its own loop protection; this is ours.
|
|
49
|
+
"""
|
|
50
|
+
parent = folder.parent
|
|
51
|
+
entries: list[tuple[Path, str]] = [(folder, str(folder.relative_to(parent)))]
|
|
52
|
+
seen: set[tuple[int, int]] = set()
|
|
53
|
+
|
|
54
|
+
for dirpath, dirnames, filenames in os.walk(folder, followlinks=True):
|
|
55
|
+
directory = Path(dirpath)
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
stat = directory.stat()
|
|
59
|
+
except OSError:
|
|
60
|
+
continue
|
|
61
|
+
key = (stat.st_dev, stat.st_ino)
|
|
62
|
+
if key in seen:
|
|
63
|
+
dirnames[:] = []
|
|
64
|
+
continue
|
|
65
|
+
seen.add(key)
|
|
66
|
+
|
|
67
|
+
dirnames.sort()
|
|
68
|
+
filenames.sort()
|
|
69
|
+
|
|
70
|
+
for name in dirnames:
|
|
71
|
+
child = directory / name
|
|
72
|
+
entries.append((child, str(child.relative_to(parent))))
|
|
73
|
+
for name in filenames:
|
|
74
|
+
child = directory / name
|
|
75
|
+
entries.append((child, str(child.relative_to(parent))))
|
|
76
|
+
|
|
77
|
+
return entries
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def zip_folder(folder: Path, archive: Path, *, verbose: bool) -> None:
|
|
81
|
+
"""Write ``folder`` into ``archive`` with deflate level 9.
|
|
82
|
+
|
|
83
|
+
Paths are stored relative to the folder's *parent*, so the archive expands
|
|
84
|
+
to ``<folder>/...``, and the top-level directory entry is written first -
|
|
85
|
+
without it an empty folder would archive to nothing at all, and its mode
|
|
86
|
+
bits would be lost on restore.
|
|
87
|
+
|
|
88
|
+
The archive is written to a temporary name and moved into place, so an
|
|
89
|
+
interrupted run cannot leave a half-written .zip shadowing a good one.
|
|
90
|
+
Note this always rewrites from scratch: unlike ``zip -FS`` sync mode it
|
|
91
|
+
recompresses unchanged files rather than copying them across.
|
|
92
|
+
|
|
93
|
+
Filenames are stored with the UTF-8 flag bit set, which ``zip`` does not
|
|
94
|
+
do - non-ASCII names come back correctly instead of as mojibake.
|
|
95
|
+
"""
|
|
96
|
+
temp_archive = archive.with_name(archive.name + ".partial")
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
with zipfile.ZipFile(
|
|
100
|
+
temp_archive, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9
|
|
101
|
+
) as zf:
|
|
102
|
+
for source, arcname in _walk_entries(folder):
|
|
103
|
+
if verbose and source.is_file():
|
|
104
|
+
info(f" + {arcname}")
|
|
105
|
+
zf.write(source, arcname)
|
|
106
|
+
temp_archive.replace(archive)
|
|
107
|
+
except BaseException:
|
|
108
|
+
# Also fires on KeyboardInterrupt and SystemExit, deliberately: the
|
|
109
|
+
# point is to never leave a .partial behind. Always re-raises.
|
|
110
|
+
temp_archive.unlink(missing_ok=True)
|
|
111
|
+
raise
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def main(argv: list[str] | None = None) -> int:
|
|
115
|
+
parser = build_parser(
|
|
116
|
+
prog="compress-folders",
|
|
117
|
+
description="📦 Create a max-compression .zip of every immediate subfolder of --path.",
|
|
118
|
+
)
|
|
119
|
+
parser.add_argument(
|
|
120
|
+
"--path", default="", metavar="DIR", help="Parent directory to zip subfolders of (required)"
|
|
121
|
+
)
|
|
122
|
+
add_bool_flag(parser, "--dry-run", help="Print what would be zipped, change nothing")
|
|
123
|
+
add_bool_flag(parser, "--verbose", help="Show per-file progress")
|
|
124
|
+
args = parser.parse_args(argv)
|
|
125
|
+
|
|
126
|
+
if not args.path:
|
|
127
|
+
warning("❌ --path is required")
|
|
128
|
+
parser.print_help()
|
|
129
|
+
return 1
|
|
130
|
+
|
|
131
|
+
root = scan_root(args.path)
|
|
132
|
+
|
|
133
|
+
header_suffix = " 🌵 (dry run)" if args.dry_run else ""
|
|
134
|
+
note("═══════════════════════════════════════════")
|
|
135
|
+
note(f"📦 compress-folders{header_suffix}")
|
|
136
|
+
note("═══════════════════════════════════════════")
|
|
137
|
+
note(f"Parent: {root}")
|
|
138
|
+
|
|
139
|
+
compressed = 0
|
|
140
|
+
skipped = 0
|
|
141
|
+
|
|
142
|
+
# Matches the shell version's `for dir in "$ROOT_DIR"/*/` glob: dotted
|
|
143
|
+
# directories are skipped (no `dotglob`), and a symlink to a directory IS
|
|
144
|
+
# included, because `*/` matches it.
|
|
145
|
+
folders = sorted(
|
|
146
|
+
path for path in root.iterdir() if path.is_dir() and not path.name.startswith(".")
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
for folder in folders:
|
|
150
|
+
archive = folder.with_name(folder.name + ".zip")
|
|
151
|
+
|
|
152
|
+
if args.dry_run:
|
|
153
|
+
info(f" 🪄 Would zip: {folder.name} → {folder.name}.zip")
|
|
154
|
+
compressed += 1
|
|
155
|
+
continue
|
|
156
|
+
|
|
157
|
+
info(f"📦 Compressing '{folder.name}'...")
|
|
158
|
+
try:
|
|
159
|
+
zip_folder(folder, archive, verbose=args.verbose)
|
|
160
|
+
except (OSError, ValueError) as exc:
|
|
161
|
+
# ValueError covers a file whose size changes mid-write and the
|
|
162
|
+
# UnicodeEncodeError raised for surrogate bytes in names off an
|
|
163
|
+
# SMB mount. The shell version isolated each folder in a subshell,
|
|
164
|
+
# so one bad folder never stopped the batch.
|
|
165
|
+
warning(f" ❌ Failed: {archive} ({exc})")
|
|
166
|
+
skipped += 1
|
|
167
|
+
continue
|
|
168
|
+
success(f" ✅ {folder.name}.zip")
|
|
169
|
+
compressed += 1
|
|
170
|
+
|
|
171
|
+
print()
|
|
172
|
+
note("─────────────────────────────────────────────")
|
|
173
|
+
if args.dry_run:
|
|
174
|
+
success(f"🌵 Would compress: {compressed} folder(s)")
|
|
175
|
+
log("💡 Dry run complete. Nothing was actually changed.")
|
|
176
|
+
return 0
|
|
177
|
+
|
|
178
|
+
success(f"✅ Compressed: {compressed}")
|
|
179
|
+
if skipped:
|
|
180
|
+
warning(f"❌ Failed: {skipped}")
|
|
181
|
+
return 1
|
|
182
|
+
|
|
183
|
+
log("🎉 Compression complete.")
|
|
184
|
+
return 0
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
if __name__ == "__main__":
|
|
188
|
+
run_cli(main)
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""🗑️ Delete every file under a path matching a set of extensions.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
delete-by-ext.py --path=/dir [--ext=jpg,png,mp4] [--dry-run] [--verbose]
|
|
6
|
+
|
|
7
|
+
Options:
|
|
8
|
+
--path=DIR Directory to scan (required)
|
|
9
|
+
--ext=LIST Comma-separated extensions, no dots. Glob metacharacters work,
|
|
10
|
+
so --ext=mp* matches mp4/mp3/mpg
|
|
11
|
+
(default: m3u,nfo,sfv,jpg,png,txt,log,cue,srr)
|
|
12
|
+
--dry-run Print what would be deleted, change nothing
|
|
13
|
+
--verbose Extra detail
|
|
14
|
+
--help Show help
|
|
15
|
+
|
|
16
|
+
Env:
|
|
17
|
+
DRY_RUN=false Opt out of the preview-only default (used by the .zshrc wrapper)
|
|
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
|
+
env_bool,
|
|
31
|
+
info,
|
|
32
|
+
iter_files,
|
|
33
|
+
log,
|
|
34
|
+
normalize_extensions,
|
|
35
|
+
note,
|
|
36
|
+
run_cli,
|
|
37
|
+
scan_root,
|
|
38
|
+
warning,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
__version__ = "2.2.0"
|
|
42
|
+
|
|
43
|
+
DEFAULT_EXTENSIONS = ("m3u", "nfo", "sfv", "jpg", "png", "txt", "log", "cue", "srr")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def main(argv: list[str] | None = None) -> int:
|
|
47
|
+
parser = build_parser(
|
|
48
|
+
prog="delete-by-ext",
|
|
49
|
+
description="🗑️ Delete files under a path matching a set of extensions.",
|
|
50
|
+
)
|
|
51
|
+
parser.add_argument("--path", default="", metavar="DIR", help="Directory to scan (required)")
|
|
52
|
+
parser.add_argument(
|
|
53
|
+
"--ext",
|
|
54
|
+
default=",".join(DEFAULT_EXTENSIONS),
|
|
55
|
+
metavar="LIST",
|
|
56
|
+
help=f"Comma-separated extensions (default: {','.join(DEFAULT_EXTENSIONS)})",
|
|
57
|
+
)
|
|
58
|
+
add_bool_flag(
|
|
59
|
+
parser,
|
|
60
|
+
"--dry-run",
|
|
61
|
+
default=env_bool("DRY_RUN", True),
|
|
62
|
+
help="Print what would be deleted, change nothing",
|
|
63
|
+
)
|
|
64
|
+
add_bool_flag(parser, "--verbose", help="Extra detail")
|
|
65
|
+
|
|
66
|
+
info(f"Running command in {Path.cwd()}")
|
|
67
|
+
args = parser.parse_args(argv)
|
|
68
|
+
|
|
69
|
+
extensions = normalize_extensions(args.ext)
|
|
70
|
+
|
|
71
|
+
note(f"Scanning: {args.path}")
|
|
72
|
+
note(f"Extensions: {' '.join(extensions)}")
|
|
73
|
+
note(f"Dry run: {str(args.dry_run).lower()}")
|
|
74
|
+
note(f"Verbose: {str(args.verbose).lower()}")
|
|
75
|
+
note("----------------------------------------------------")
|
|
76
|
+
|
|
77
|
+
if not extensions:
|
|
78
|
+
warning("--ext is required")
|
|
79
|
+
return 1
|
|
80
|
+
|
|
81
|
+
root = scan_root(args.path)
|
|
82
|
+
|
|
83
|
+
failures = 0
|
|
84
|
+
|
|
85
|
+
# macOS ._ sidecars are NOT skipped: the shell version deleted them when
|
|
86
|
+
# they matched, and leaving them orphaned after their parent file is gone
|
|
87
|
+
# is worse than deleting them.
|
|
88
|
+
for path in iter_files(root, extensions=extensions, skip_macos_metadata=False):
|
|
89
|
+
if args.dry_run:
|
|
90
|
+
info(f"[DRY RUN] Would delete: {path}")
|
|
91
|
+
continue
|
|
92
|
+
|
|
93
|
+
log(f"Deleting: {path}")
|
|
94
|
+
try:
|
|
95
|
+
path.unlink()
|
|
96
|
+
except OSError as exc:
|
|
97
|
+
# The shell version aborted the whole run here under `set -e`.
|
|
98
|
+
# Continuing and reporting at the end is more useful on a NAS
|
|
99
|
+
# where one busy file should not strand the rest of the sweep.
|
|
100
|
+
warning(f" ✗ Failed to delete {path}: {exc}")
|
|
101
|
+
failures += 1
|
|
102
|
+
|
|
103
|
+
log("Completed.")
|
|
104
|
+
return 1 if failures else 0
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
if __name__ == "__main__":
|
|
108
|
+
run_cli(main)
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""🧹 Delete truly-empty directories under a path, cascading upwards.
|
|
3
|
+
|
|
4
|
+
Only *strictly* empty directories are removed. A folder holding nothing but
|
|
5
|
+
``.DS_Store``, ``._*`` or ``.git`` is intentionally left alone - a recursive
|
|
6
|
+
delete would silently wipe them.
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
delete-empty-folders.py --path=/dir [--dry-run] [--verbose]
|
|
10
|
+
|
|
11
|
+
Options:
|
|
12
|
+
--path=DIR Directory to scan (required)
|
|
13
|
+
--dry-run Print what would be deleted, change nothing
|
|
14
|
+
--verbose Report skipped non-empty directories too
|
|
15
|
+
--help Show help
|
|
16
|
+
|
|
17
|
+
Env:
|
|
18
|
+
DRY_RUN=false Opt out of the preview-only default (used by the .zshrc wrapper)
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import os
|
|
24
|
+
import sys
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
28
|
+
|
|
29
|
+
from utils import (
|
|
30
|
+
add_bool_flag,
|
|
31
|
+
build_parser,
|
|
32
|
+
env_bool,
|
|
33
|
+
info,
|
|
34
|
+
log,
|
|
35
|
+
note,
|
|
36
|
+
run_cli,
|
|
37
|
+
scan_root,
|
|
38
|
+
warning,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
__version__ = "3.0.0"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def directories_deepest_first(root: Path) -> list[Path]:
|
|
45
|
+
"""Every directory under ``root``, children before parents.
|
|
46
|
+
|
|
47
|
+
This is ``find -depth -type d``. The ordering is what makes cascading work:
|
|
48
|
+
emptying a leaf lets its parent become empty, and the parent is visited
|
|
49
|
+
afterwards. The whole tree is enumerated up front - exactly like ``find``
|
|
50
|
+
piping into a loop - and emptiness is re-checked at visit time against the
|
|
51
|
+
live filesystem, so a directory emptied earlier in the run is seen as
|
|
52
|
+
empty when its turn comes.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def _on_error(exc: OSError) -> None:
|
|
56
|
+
warning(f"find: {exc.filename}: {exc.strerror}")
|
|
57
|
+
|
|
58
|
+
found: list[Path] = []
|
|
59
|
+
for dirpath, _, _ in os.walk(root, topdown=False, followlinks=False, onerror=_on_error):
|
|
60
|
+
found.append(Path(dirpath))
|
|
61
|
+
return found
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def is_strictly_empty(path: Path) -> bool:
|
|
65
|
+
"""True only when the directory has no entries at all, hidden included."""
|
|
66
|
+
try:
|
|
67
|
+
next(path.iterdir())
|
|
68
|
+
except StopIteration:
|
|
69
|
+
return True
|
|
70
|
+
except OSError:
|
|
71
|
+
return False
|
|
72
|
+
return False
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def main(argv: list[str] | None = None) -> int:
|
|
76
|
+
parser = build_parser(
|
|
77
|
+
prog="delete-empty-folders",
|
|
78
|
+
description="🧹 Delete truly-empty directories under a path, cascading upwards.",
|
|
79
|
+
)
|
|
80
|
+
parser.add_argument("--path", default="", metavar="DIR", help="Directory to scan (required)")
|
|
81
|
+
add_bool_flag(
|
|
82
|
+
parser,
|
|
83
|
+
"--dry-run",
|
|
84
|
+
default=env_bool("DRY_RUN", True),
|
|
85
|
+
help="Print what would be deleted, change nothing",
|
|
86
|
+
)
|
|
87
|
+
add_bool_flag(parser, "--verbose", help="Report skipped non-empty directories too")
|
|
88
|
+
|
|
89
|
+
info(f"Running command in {Path.cwd()}")
|
|
90
|
+
args = parser.parse_args(argv)
|
|
91
|
+
|
|
92
|
+
if not args.path:
|
|
93
|
+
warning("Error: --path is required.")
|
|
94
|
+
return 1
|
|
95
|
+
|
|
96
|
+
root = scan_root(args.path)
|
|
97
|
+
|
|
98
|
+
note(f"Searching in: {args.path}")
|
|
99
|
+
note(f"Dry run: {str(args.dry_run).lower()}")
|
|
100
|
+
note(f"Verbose: {str(args.verbose).lower()}")
|
|
101
|
+
|
|
102
|
+
# Compare resolved paths so `--path=foo` and `--path=foo/` protect the same
|
|
103
|
+
# directory regardless of how it was spelled.
|
|
104
|
+
root_resolved = root.resolve()
|
|
105
|
+
deleted_count = 0
|
|
106
|
+
failures = 0
|
|
107
|
+
|
|
108
|
+
for directory in directories_deepest_first(root):
|
|
109
|
+
if directory.resolve() == root_resolved:
|
|
110
|
+
continue # never touch the root the user pointed at
|
|
111
|
+
|
|
112
|
+
if not is_strictly_empty(directory):
|
|
113
|
+
if args.verbose:
|
|
114
|
+
note(f"Skipped (not empty): {directory}")
|
|
115
|
+
continue
|
|
116
|
+
|
|
117
|
+
if args.dry_run:
|
|
118
|
+
log(f"[DRY-RUN] Would delete: {directory}")
|
|
119
|
+
else:
|
|
120
|
+
try:
|
|
121
|
+
directory.rmdir()
|
|
122
|
+
except OSError as exc:
|
|
123
|
+
# The shell version aborted here under `set -e`; reporting and
|
|
124
|
+
# carrying on lets one unwritable directory not strand the rest.
|
|
125
|
+
warning(f" ✗ Failed to delete {directory}: {exc}")
|
|
126
|
+
failures += 1
|
|
127
|
+
continue
|
|
128
|
+
log(f"Deleted: {directory}")
|
|
129
|
+
deleted_count += 1
|
|
130
|
+
|
|
131
|
+
log(f"📊 Total deleted directories: {deleted_count}")
|
|
132
|
+
return 1 if failures else 0
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
if __name__ == "__main__":
|
|
136
|
+
run_cli(main)
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""🧽 Delete the ``.smbdelete*`` turds an SMB share leaves behind.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
delete-smb-files.py --path=/dir [--dry-run]
|
|
6
|
+
|
|
7
|
+
Options:
|
|
8
|
+
--path=DIR Directory to scan (required)
|
|
9
|
+
--dry-run List what would be deleted, change nothing
|
|
10
|
+
--help Show help
|
|
11
|
+
|
|
12
|
+
Env:
|
|
13
|
+
DRY_RUN=false Opt out of the preview-only default (used by the .zshrc wrapper)
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import sys
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
22
|
+
|
|
23
|
+
from utils import (
|
|
24
|
+
add_bool_flag,
|
|
25
|
+
build_parser,
|
|
26
|
+
env_bool,
|
|
27
|
+
info,
|
|
28
|
+
iter_files,
|
|
29
|
+
log,
|
|
30
|
+
note,
|
|
31
|
+
run_cli,
|
|
32
|
+
scan_root,
|
|
33
|
+
warning,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
__version__ = "2.1.0"
|
|
37
|
+
|
|
38
|
+
PREFIX = ".smbdelete"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def main(argv: list[str] | None = None) -> int:
|
|
42
|
+
parser = build_parser(
|
|
43
|
+
prog="delete-smb-files",
|
|
44
|
+
description="🧽 Delete .smbdelete* files left behind by an SMB share.",
|
|
45
|
+
)
|
|
46
|
+
parser.add_argument("--path", default="", metavar="DIR", help="Directory to scan (required)")
|
|
47
|
+
add_bool_flag(
|
|
48
|
+
parser,
|
|
49
|
+
"--dry-run",
|
|
50
|
+
default=env_bool("DRY_RUN", True),
|
|
51
|
+
help="List what would be deleted, change nothing",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
info(f"Running command in {Path.cwd()}")
|
|
55
|
+
args = parser.parse_args(argv)
|
|
56
|
+
|
|
57
|
+
note(f"Searching in: {args.path}")
|
|
58
|
+
note(f"Dry run: {str(args.dry_run).lower()}")
|
|
59
|
+
|
|
60
|
+
if not args.path:
|
|
61
|
+
warning("Error: --path is required.")
|
|
62
|
+
warning("Usage: delete-smb-files.py --path=/directory/to/scan")
|
|
63
|
+
return 1
|
|
64
|
+
|
|
65
|
+
root = scan_root(args.path)
|
|
66
|
+
|
|
67
|
+
info(f"Scanning for .smbdelete* files under: {args.path}")
|
|
68
|
+
|
|
69
|
+
if args.dry_run:
|
|
70
|
+
info("Dry run mode: no files will be deleted.")
|
|
71
|
+
|
|
72
|
+
deleted = 0
|
|
73
|
+
failures = 0
|
|
74
|
+
|
|
75
|
+
# These sidecars are hidden files, so hidden entries must NOT be skipped.
|
|
76
|
+
# `._*` filtering is likewise off - a `._.smbdeleteX` is still junk.
|
|
77
|
+
for path in iter_files(root, skip_macos_metadata=False):
|
|
78
|
+
if not path.name.startswith(PREFIX):
|
|
79
|
+
continue
|
|
80
|
+
|
|
81
|
+
print(path, flush=True)
|
|
82
|
+
if args.dry_run:
|
|
83
|
+
continue
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
path.unlink()
|
|
87
|
+
except OSError as exc:
|
|
88
|
+
# A file held open by the SMB client fails with "Resource busy".
|
|
89
|
+
# The shell version swallowed these with `2>/dev/null || true`;
|
|
90
|
+
# naming them is strictly more useful and still non-fatal.
|
|
91
|
+
warning(f" ✗ Could not delete {path}: {exc}")
|
|
92
|
+
failures += 1
|
|
93
|
+
continue
|
|
94
|
+
deleted += 1
|
|
95
|
+
|
|
96
|
+
if not args.dry_run:
|
|
97
|
+
# Summary line the shell version did not print; a sweep that silently
|
|
98
|
+
# deleted nothing is indistinguishable from one that could not run.
|
|
99
|
+
log(f"📊 Deleted {deleted} file(s).")
|
|
100
|
+
|
|
101
|
+
return 1 if failures else 0
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
if __name__ == "__main__":
|
|
105
|
+
run_cli(main)
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""📉 Find (and optionally delete) video files at or under a size threshold.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
files-under-size.py --path=/dir --size=1MB [--dry-run]
|
|
6
|
+
|
|
7
|
+
Options:
|
|
8
|
+
--path=DIR Directory to scan (required)
|
|
9
|
+
--size=SIZE Threshold, e.g. 500k / 1MB / 2GiB (required, binary units,
|
|
10
|
+
bytes through gigabytes only)
|
|
11
|
+
--dry-run List what would be deleted, change nothing
|
|
12
|
+
--help Show help
|
|
13
|
+
|
|
14
|
+
Env:
|
|
15
|
+
DRY_RUN=false Opt out of the preview-only default (used by the .zshrc wrapper)
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import sys
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
24
|
+
|
|
25
|
+
from utils import (
|
|
26
|
+
add_bool_flag,
|
|
27
|
+
build_parser,
|
|
28
|
+
env_bool,
|
|
29
|
+
format_bytes,
|
|
30
|
+
info,
|
|
31
|
+
iter_files,
|
|
32
|
+
log,
|
|
33
|
+
note,
|
|
34
|
+
parse_size,
|
|
35
|
+
run_cli,
|
|
36
|
+
scan_root,
|
|
37
|
+
warning,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
__version__ = "2.2.0"
|
|
41
|
+
|
|
42
|
+
VIDEO_EXTENSIONS = (
|
|
43
|
+
"mp4",
|
|
44
|
+
"mkv",
|
|
45
|
+
"avi",
|
|
46
|
+
"mov",
|
|
47
|
+
"flv",
|
|
48
|
+
"wmv",
|
|
49
|
+
"webm",
|
|
50
|
+
"mpeg",
|
|
51
|
+
"mpg",
|
|
52
|
+
"m4v",
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def main(argv: list[str] | None = None) -> int:
|
|
57
|
+
parser = build_parser(
|
|
58
|
+
prog="files-under-size",
|
|
59
|
+
description="📉 Find video files at or under a size threshold.",
|
|
60
|
+
)
|
|
61
|
+
parser.add_argument("--path", default="", metavar="DIR", help="Directory to scan (required)")
|
|
62
|
+
parser.add_argument("--size", default="", metavar="SIZE", help="Threshold, e.g. 1MB (required)")
|
|
63
|
+
add_bool_flag(
|
|
64
|
+
parser,
|
|
65
|
+
"--dry-run",
|
|
66
|
+
default=env_bool("DRY_RUN", True),
|
|
67
|
+
help="List what would be deleted, change nothing",
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
info(f"Running command in {Path.cwd()}")
|
|
71
|
+
args = parser.parse_args(argv)
|
|
72
|
+
|
|
73
|
+
note(f"Scanning: {args.path}")
|
|
74
|
+
note(f"Size threshold: {args.size}")
|
|
75
|
+
note(f"Dry run: {str(args.dry_run).lower()}")
|
|
76
|
+
note("----------------------------------------------------")
|
|
77
|
+
|
|
78
|
+
if not args.size or not args.path:
|
|
79
|
+
warning("❌ --size and --path are required")
|
|
80
|
+
warning("📋 Usage: files-under-size.py --path=/dir --size=1MB [--dry-run]")
|
|
81
|
+
return 1
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
limit_bytes = parse_size(args.size)
|
|
85
|
+
except ValueError as exc:
|
|
86
|
+
warning(f"❌ {exc}")
|
|
87
|
+
return 1
|
|
88
|
+
|
|
89
|
+
root = scan_root(args.path)
|
|
90
|
+
|
|
91
|
+
# The shell version used `find -size -Nc` with N = limit + 1, which selects
|
|
92
|
+
# files of AT MOST `limit` bytes. Keep the inclusive comparison.
|
|
93
|
+
matches: list[tuple[Path, int]] = []
|
|
94
|
+
for path in iter_files(root, extensions=VIDEO_EXTENSIONS, skip_macos_metadata=False):
|
|
95
|
+
try:
|
|
96
|
+
size = path.stat().st_size
|
|
97
|
+
except OSError:
|
|
98
|
+
continue
|
|
99
|
+
if size <= limit_bytes:
|
|
100
|
+
matches.append((path, size))
|
|
101
|
+
|
|
102
|
+
total = len(matches)
|
|
103
|
+
if total == 0:
|
|
104
|
+
log(f"No matching video files ≤ {args.size} ({limit_bytes} bytes).")
|
|
105
|
+
return 0
|
|
106
|
+
|
|
107
|
+
info(f"Found {total} file(s) ≤ {args.size} ({limit_bytes} bytes)")
|
|
108
|
+
note(f"Threshold: {limit_bytes:,} bytes ({args.size})")
|
|
109
|
+
|
|
110
|
+
failures = 0
|
|
111
|
+
|
|
112
|
+
for index, (path, size) in enumerate(matches, start=1):
|
|
113
|
+
log(f"File size: {format_bytes(size)}")
|
|
114
|
+
if args.dry_run:
|
|
115
|
+
warning(f"[{index}/{total}] Would delete: {format_bytes(size)} - {path}")
|
|
116
|
+
continue
|
|
117
|
+
|
|
118
|
+
warning(f"[{index}/{total}] Deleting: {format_bytes(size)} - {path}")
|
|
119
|
+
try:
|
|
120
|
+
path.unlink()
|
|
121
|
+
except OSError as exc:
|
|
122
|
+
# The shell version aborted the run here; reporting and continuing
|
|
123
|
+
# means one busy file cannot strand the rest of the sweep.
|
|
124
|
+
warning(f" ✗ Failed to delete {path}: {exc}")
|
|
125
|
+
failures += 1
|
|
126
|
+
|
|
127
|
+
log("Done.")
|
|
128
|
+
return 1 if failures else 0
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
if __name__ == "__main__":
|
|
132
|
+
run_cli(main)
|