Skip to content

Commit f523950

Browse files
committed
Add cbz clean up script
1 parent 5e24646 commit f523950

5 files changed

Lines changed: 280 additions & 14 deletions

File tree

‎README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ Scripts are fully tested, linted, and compatible with both direct execution and
1111
- **scripts/trim_filenames.py**: Recursively removes a specified number of characters from the beginning of filenames in a directory and its subdirectories.
1212
- **scripts/mp3_metadata_stripper.py**: Recursively removes metadata from mp3 and m4a files in a directory and its subdirectories.
1313
- **scripts/cbz_processor.py**: Compresses and optimizes CBZ files in a directory (see script for details).
14+
- **scripts/cbz_clean_up.py**: Recursively deletes CBZ backup files ending in `_original.cbz`.
1415

1516
## Requirements
1617

@@ -31,6 +32,7 @@ You can run any script directly or as a module:
3132
python -m scripts.trim_filenames
3233
python -m scripts.mp3_metadata_stripper
3334
python -m scripts.cbz_processor
35+
python -m scripts.cbz_clean_up
3436
```
3537

3638
## Linting & Code Quality

‎scripts/cbz_clean_up.py‎

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
"""
2+
CBZ Cleanup Utility
3+
4+
Recursively scans a directory and removes backup CBZ files that end with
5+
"_original.cbz" (extension matching is case-insensitive).
6+
7+
Features:
8+
- Recursive processing of directories and subdirectories
9+
- Deletes only files ending in "_original.cbz"
10+
- Reads target directory from .env via CBZ_PROCESSOR_DIR
11+
- Continues processing when a delete operation fails
12+
- Prints a summary of deleted, skipped, and failed files
13+
14+
Usage:
15+
python -m scripts.cbz_clean_up
16+
17+
Environment Variables:
18+
CBZ_PROCESSOR_DIR (optional): Directory path to process files in
19+
"""
20+
21+
import os
22+
23+
from dotenv import load_dotenv
24+
25+
from scripts.utils import get_directory_from_env_or_prompt, make_summary_dict, print_result
26+
27+
TITLES = {
28+
"success": "Successfully deleted",
29+
"warning": "Skipped file",
30+
"failed": "Failed to delete",
31+
}
32+
33+
34+
def _is_original_cbz(filename):
35+
"""Return True when filename ends with the exact '_original.cbz' pattern."""
36+
base, extension = os.path.splitext(filename)
37+
return extension.lower() == ".cbz" and base.endswith("_original")
38+
39+
40+
def clean_up_original_cbz_files(directory):
41+
"""
42+
Recursively delete files matching the '_original.cbz' naming pattern.
43+
44+
Args:
45+
directory (str): Root directory to scan.
46+
47+
Returns:
48+
dict: Summary counts for successful, skipped, and failed operations.
49+
"""
50+
success_count = 0
51+
skipped_count = 0
52+
failed_count = 0
53+
54+
for root, _, files in os.walk(directory):
55+
for filename in files:
56+
filepath = os.path.join(root, filename)
57+
if not _is_original_cbz(filename):
58+
skipped_count += 1
59+
continue
60+
61+
try:
62+
os.remove(filepath)
63+
print(f"Deleted: {filepath}")
64+
success_count += 1
65+
except OSError as error:
66+
print(f"Failed to delete {filepath}: {error}")
67+
failed_count += 1
68+
69+
return make_summary_dict(success_count, skipped_count, failed_count)
70+
71+
72+
def main():
73+
"""Entry point for recursive cleanup of '_original.cbz' backup files."""
74+
load_dotenv()
75+
directory = get_directory_from_env_or_prompt("CBZ_PROCESSOR_DIR")
76+
print(f"Processing directory: {directory}")
77+
result = clean_up_original_cbz_files(directory)
78+
print_result(result, TITLES)
79+
80+
81+
if __name__ == "__main__":
82+
main()

‎scripts/cbz_processor.py‎

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ def _compress_and_write_image(img_path, temp_dir, zipf, quality, max_height):
6767
arcname = os.path.relpath(img_compressed_path, temp_dir)
6868
zipf.write(img_compressed_path, arcname)
6969

70-
def compress_cbz(file_path, output_path=None, quality=80, max_height=1024):
70+
def compress_cbz(file_path, output_path=None, quality=100, max_height=1024):
7171
"""
7272
Compress images in a CBZ file and optionally resize them.
7373
Returns the size saved in MB.
@@ -118,26 +118,27 @@ def process_cbz_files(directory, quality, max_height):
118118
"failed_count": 0
119119
}
120120

121-
def backup_and_compress_cbz(filepath, filename):
121+
def backup_and_compress_cbz(filepath, filename, source_directory):
122122
base, ext = os.path.splitext(filename)
123-
original_copy_path = os.path.join(directory, f"{base}_original{ext}")
123+
original_copy_path = os.path.join(source_directory, f"{base}_original{ext}")
124124
print(f"Creating backup: {original_copy_path}")
125125
shutil.copy2(filepath, original_copy_path)
126126
size_saved = compress_cbz(filepath, quality=quality, max_height=max_height)
127127
print(f"✅ Optimized {os.path.basename(filepath)} | Size saved: {size_saved:.2f} MB")
128128
return size_saved
129129

130-
for filename in os.listdir(directory):
131-
filepath = os.path.join(directory, filename)
132-
if filename.endswith('.cbz'):
133-
try:
134-
backup_and_compress_cbz(filepath, filename)
135-
counts["success_count"] += 1
136-
except Exception as e:
137-
print(f"🛑 Failed to process {filename}: {e}")
138-
counts["failed_count"] += 1
139-
else:
140-
counts["skipped_count"] += 1
130+
for source_directory, _, filenames in os.walk(directory):
131+
for filename in filenames:
132+
filepath = os.path.join(source_directory, filename)
133+
if filename.lower().endswith('.cbz'):
134+
try:
135+
backup_and_compress_cbz(filepath, filename, source_directory)
136+
counts["success_count"] += 1
137+
except Exception as e:
138+
print(f"🛑 Failed to process {filename}: {e}")
139+
counts["failed_count"] += 1
140+
else:
141+
counts["skipped_count"] += 1
141142
return counts
142143

143144
def main():

‎tests/test_cbz_clean_up.py‎

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
"""Tests for scripts/cbz_clean_up.py."""
2+
3+
import os
4+
5+
import scripts.cbz_clean_up as cbz_clean_up
6+
from tests.test_helpers import run_cli_with_env
7+
8+
9+
def test_cleanup_deletes_matching_files(tmp_path):
10+
"""Delete only files ending in '_original.cbz'."""
11+
keep_file = tmp_path / "book.cbz"
12+
delete_file = tmp_path / "book_original.cbz"
13+
non_cbz_file = tmp_path / "book_original.txt"
14+
15+
keep_file.write_text("keep")
16+
delete_file.write_text("delete")
17+
non_cbz_file.write_text("keep")
18+
19+
result = cbz_clean_up.clean_up_original_cbz_files(str(tmp_path))
20+
21+
assert result["success_count"] == 1
22+
assert result["skipped_count"] == 2
23+
assert result["failed_count"] == 0
24+
assert keep_file.exists()
25+
assert not delete_file.exists()
26+
assert non_cbz_file.exists()
27+
28+
29+
def test_cleanup_is_recursive(tmp_path):
30+
"""Delete matching files from nested subdirectories."""
31+
nested = tmp_path / "nested" / "deeper"
32+
nested.mkdir(parents=True)
33+
delete_file = nested / "comic_original.cbz"
34+
keep_file = nested / "comic.cbz"
35+
36+
delete_file.write_text("delete")
37+
keep_file.write_text("keep")
38+
39+
result = cbz_clean_up.clean_up_original_cbz_files(str(tmp_path))
40+
41+
assert result["success_count"] == 1
42+
assert result["skipped_count"] == 1
43+
assert result["failed_count"] == 0
44+
assert not delete_file.exists()
45+
assert keep_file.exists()
46+
47+
48+
def test_cleanup_does_not_delete_non_matching_original_names(tmp_path):
49+
"""Do not delete files that only contain '_original' as a substring."""
50+
files = [
51+
tmp_path / "book_original_backup.cbz",
52+
tmp_path / "book.cbz",
53+
tmp_path / "book_originals.cbz",
54+
tmp_path / "other.txt",
55+
]
56+
for file_path in files:
57+
file_path.write_text("keep")
58+
59+
result = cbz_clean_up.clean_up_original_cbz_files(str(tmp_path))
60+
61+
assert result["success_count"] == 0
62+
assert result["skipped_count"] == 4
63+
assert result["failed_count"] == 0
64+
for file_path in files:
65+
assert file_path.exists()
66+
67+
68+
def test_cleanup_matches_case_insensitive_extension(tmp_path):
69+
"""Delete '_original.CBZ' with uppercase extension."""
70+
delete_file = tmp_path / "book_original.CBZ"
71+
delete_file.write_text("delete")
72+
73+
result = cbz_clean_up.clean_up_original_cbz_files(str(tmp_path))
74+
75+
assert result["success_count"] == 1
76+
assert result["skipped_count"] == 0
77+
assert result["failed_count"] == 0
78+
assert not delete_file.exists()
79+
80+
81+
def test_cleanup_continue_on_delete_error(monkeypatch, tmp_path):
82+
"""Continue scanning and report failed_count when os.remove raises OSError."""
83+
failing_file = tmp_path / "bad_original.cbz"
84+
deleted_file = tmp_path / "good_original.cbz"
85+
skipped_file = tmp_path / "keep.cbz"
86+
87+
failing_file.write_text("delete")
88+
deleted_file.write_text("delete")
89+
skipped_file.write_text("keep")
90+
91+
original_remove = os.remove
92+
93+
def fake_remove(path):
94+
if path == str(failing_file):
95+
raise OSError("permission denied")
96+
return original_remove(path)
97+
98+
monkeypatch.setattr(cbz_clean_up.os, "remove", fake_remove)
99+
100+
result = cbz_clean_up.clean_up_original_cbz_files(str(tmp_path))
101+
102+
assert result["success_count"] == 1
103+
assert result["skipped_count"] == 1
104+
assert result["failed_count"] == 1
105+
assert failing_file.exists()
106+
assert not deleted_file.exists()
107+
assert skipped_file.exists()
108+
109+
110+
def test_main_uses_cbz_processor_env_var(monkeypatch, tmp_path):
111+
"""main() should use CBZ_PROCESSOR_DIR without prompting."""
112+
delete_file = tmp_path / "series_original.cbz"
113+
delete_file.write_text("delete")
114+
115+
monkeypatch.setenv("CBZ_PROCESSOR_DIR", str(tmp_path))
116+
117+
captured = {}
118+
119+
def fake_print_result(stats, _titles):
120+
captured["stats"] = stats
121+
122+
monkeypatch.setattr(cbz_clean_up, "print_result", fake_print_result)
123+
124+
cbz_clean_up.main()
125+
126+
assert captured["stats"]["success_count"] == 1
127+
assert not delete_file.exists()
128+
129+
130+
def test_cli_entry(tmp_path):
131+
"""CLI module execution should run with CBZ_PROCESSOR_DIR set."""
132+
env_vars = {"CBZ_PROCESSOR_DIR": str(tmp_path)}
133+
result = run_cli_with_env("scripts.cbz_clean_up", env_vars)
134+
assert result is not None
135+
assert result.returncode == 0

‎tests/test_cbz_processor.py‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -490,3 +490,49 @@ def __exit__(self, *args):
490490

491491
assert isinstance(size_saved, (int, float))
492492
assert output_path.exists()
493+
494+
495+
def test_process_cbz_files_recursive_discovers_nested_cbz(monkeypatch, tmp_path):
496+
"""Test process_cbz_files processes CBZ files in subdirectories."""
497+
nested_dir = tmp_path / "nested"
498+
nested_dir.mkdir()
499+
cbz_path = nested_dir / "book.cbz"
500+
cbz_path.write_bytes(b"dummy")
501+
502+
monkeypatch.setattr("scripts.cbz_processor.compress_cbz", lambda *_a, **_kw: 1.0)
503+
504+
result = process_cbz_files(str(tmp_path), 80, 1024)
505+
506+
assert result["success_count"] == 1
507+
assert (nested_dir / "book_original.cbz").exists()
508+
509+
510+
def test_process_cbz_files_case_insensitive_extension(monkeypatch, tmp_path):
511+
"""Test process_cbz_files accepts uppercase/mixed-case .CBZ extension."""
512+
cbz_path = tmp_path / "Book.CBZ"
513+
cbz_path.write_bytes(b"dummy")
514+
515+
monkeypatch.setattr("scripts.cbz_processor.compress_cbz", lambda *_a, **_kw: 1.0)
516+
517+
result = process_cbz_files(str(tmp_path), 80, 1024)
518+
519+
assert result["success_count"] == 1
520+
assert (tmp_path / "Book_original.CBZ").exists()
521+
522+
523+
def test_process_cbz_files_same_name_different_subdirectories(monkeypatch, tmp_path):
524+
"""Test backups for same basename in different folders do not collide."""
525+
folder_a = tmp_path / "a"
526+
folder_b = tmp_path / "b"
527+
folder_a.mkdir()
528+
folder_b.mkdir()
529+
(folder_a / "comic.cbz").write_bytes(b"dummy")
530+
(folder_b / "comic.cbz").write_bytes(b"dummy")
531+
532+
monkeypatch.setattr("scripts.cbz_processor.compress_cbz", lambda *_a, **_kw: 1.0)
533+
534+
result = process_cbz_files(str(tmp_path), 80, 1024)
535+
536+
assert result["success_count"] == 2
537+
assert (folder_a / "comic_original.cbz").exists()
538+
assert (folder_b / "comic_original.cbz").exists()

0 commit comments

Comments
 (0)