Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
333 changes: 204 additions & 129 deletions Cachyos/Scripts/WIP/emu/cia_3ds_decryptor.py

Large diffs are not rendered by default.

67 changes: 67 additions & 0 deletions Cachyos/Scripts/WIP/emu/test_decryptor_contentid.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
#!/usr/bin/env python3
import importlib.util
import unittest
import sys
from pathlib import Path
import tempfile

# Dynamically import cia_3ds_decryptor.py
file_path = Path(__file__).parent / "cia_3ds_decryptor.py"
spec = importlib.util.spec_from_file_location("cia_3ds_decryptor", str(file_path))
if spec is None:
raise ImportError(f"Could not load {file_path}")
decryptor = importlib.util.module_from_spec(spec)
sys.modules["cia_3ds_decryptor"] = decryptor
spec.loader.exec_module(decryptor)

Check warning on line 15 in Cachyos/Scripts/WIP/emu/test_decryptor_contentid.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

Cachyos/Scripts/WIP/emu/test_decryptor_contentid.py#L15

Item "None" of "Loader | None" has no attribute "exec_module". (union-attr)

class TestDecryptorContentId(unittest.TestCase):
def setUp(self):
self.temp_dir = tempfile.TemporaryDirectory()
self.bin_dir = Path(self.temp_dir.name)
self.content_txt = self.bin_dir / "content.txt"

def tearDown(self):
self.temp_dir.cleanup()

def test_extract_content_ids_no_file(self):

Check notice on line 26 in Cachyos/Scripts/WIP/emu/test_decryptor_contentid.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

Cachyos/Scripts/WIP/emu/test_decryptor_contentid.py#L26

Method name "test_extract_content_ids_no_file" doesn't conform to '[a-z_][a-z0-9_]{2,30}$' pattern
self.assertEqual(decryptor._extract_content_ids(self.content_txt), [])

def test_extract_content_ids_valid_file(self):

Check notice on line 29 in Cachyos/Scripts/WIP/emu/test_decryptor_contentid.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

Cachyos/Scripts/WIP/emu/test_decryptor_contentid.py#L29

Method name "test_extract_content_ids_valid_file" doesn't conform to '[a-z_][a-z0-9_]{2,30}$' pattern
content = (
"ContentId: 00000001\n"
"Some other line\n"
"ContentId: 00000002 \n"
"ContentId:\n" # Invalid, empty after split
)
self.content_txt.write_text(content)
expected = [1, 2]
self.assertEqual(decryptor._extract_content_ids(self.content_txt), expected)

def test_build_ncch_args_contentid(self):
content = (
"ContentId: 0000000A\n"
"ContentId: 0000000B\n"
)
self.content_txt.write_text(content)

# Create some fake ncch files
(self.bin_dir / "tmp.0.ncch").touch()
(self.bin_dir / "tmp.1.ncch").touch()
(self.bin_dir / "tmp.2.ncch").touch() # More files than IDs

ncch0 = self.bin_dir / "tmp.0.ncch"
ncch1 = self.bin_dir / "tmp.1.ncch"
ncch2 = self.bin_dir / "tmp.2.ncch"

args = decryptor.build_ncch_args_contentid(self.bin_dir, self.content_txt)

# Expect content ids 10 and 11, and fallback to 2 for the last one
expected_parts = [
f'-i "{ncch0}:0:10"',
f'-i "{ncch1}:1:11"',
f'-i "{ncch2}:2:2"',
]
self.assertEqual(args, " ".join(expected_parts))

if __name__ == '__main__':
unittest.main()
53 changes: 53 additions & 0 deletions Cachyos/Scripts/WIP/emu/test_decryptor_parser.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
#!/usr/bin/env python3
import importlib.util
import unittest
import sys
from pathlib import Path

# Dynamically import cia_3ds_decryptor.py
file_path = Path(__file__).parent / "cia_3ds_decryptor.py"
spec = importlib.util.spec_from_file_location("cia_3ds_decryptor", str(file_path))
if spec is None:
raise ImportError(f"Could not load {file_path}")
decryptor = importlib.util.module_from_spec(spec)
sys.modules["cia_3ds_decryptor"] = decryptor
spec.loader.exec_module(decryptor)

Check warning on line 14 in Cachyos/Scripts/WIP/emu/test_decryptor_parser.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

Cachyos/Scripts/WIP/emu/test_decryptor_parser.py#L14

Item "None" of "Loader | None" has no attribute "exec_module". (union-attr)

class TestParser(unittest.TestCase):
def test_parse_ctrtool_output_full(self):
text = """
Title id: 0004000000000100
TitleVersion: 10
Crypto Key: Secure
"""
info = decryptor.parse_ctrtool_output(text)
self.assertEqual(info.title_id, "0004000000000100")
self.assertEqual(info.title_version, "10")
self.assertEqual(info.crypto_key, "Crypto Key: Secure")

def test_parse_ctrtool_output_partial(self):

Check notice on line 28 in Cachyos/Scripts/WIP/emu/test_decryptor_parser.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

Cachyos/Scripts/WIP/emu/test_decryptor_parser.py#L28

Method name "test_parse_ctrtool_output_partial" doesn't conform to '[a-z_][a-z0-9_]{2,30}$' pattern
text = "Title id: 0004000000000100"
info = decryptor.parse_ctrtool_output(text)
self.assertEqual(info.title_id, "0004000000000100")
self.assertEqual(info.title_version, "0")
self.assertEqual(info.crypto_key, "")

def test_parse_ctrtool_output_empty(self):
info = decryptor.parse_ctrtool_output("")
self.assertEqual(info.title_id, "")
self.assertEqual(info.title_version, "0")
self.assertEqual(info.crypto_key, "")

def test_parse_twl_ctrtool_output_full(self):

Check notice on line 41 in Cachyos/Scripts/WIP/emu/test_decryptor_parser.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

Cachyos/Scripts/WIP/emu/test_decryptor_parser.py#L41

Method name "test_parse_twl_ctrtool_output_full" doesn't conform to '[a-z_][a-z0-9_]{2,30}$' pattern
text = """
TitleId: 0004800000000100
TitleVersion: 5
Encrypted: YES
"""
info = decryptor.parse_twl_ctrtool_output(text)
self.assertEqual(info.title_id, "0004800000000100")
self.assertEqual(info.title_version, "5")
self.assertEqual(info.crypto_key, "YES")

if __name__ == '__main__':

Check notice on line 52 in Cachyos/Scripts/WIP/emu/test_decryptor_parser.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

Cachyos/Scripts/WIP/emu/test_decryptor_parser.py#L52

expected 2 blank lines after class or function definition, found 1 (E305)
unittest.main()
48 changes: 48 additions & 0 deletions Cachyos/Scripts/WIP/emu/test_sanitize_filename.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
#!/usr/bin/env python3
import importlib.util
import unittest
import sys
from pathlib import Path

# Dynamically import cia_3ds_decryptor.py
file_path = Path(__file__).parent / "cia_3ds_decryptor.py"
spec = importlib.util.spec_from_file_location("cia_3ds_decryptor", str(file_path))
if spec is None:
raise ImportError(f"Could not load {file_path}")
decryptor = importlib.util.module_from_spec(spec)
sys.modules["cia_3ds_decryptor"] = decryptor
spec.loader.exec_module(decryptor)

Check warning on line 14 in Cachyos/Scripts/WIP/emu/test_sanitize_filename.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

Cachyos/Scripts/WIP/emu/test_sanitize_filename.py#L14

Item "None" of "Loader | None" has no attribute "exec_module". (union-attr)


class TestSanitizeFilename(unittest.TestCase):
def test_preservation_of_valid_characters(self):

Check notice on line 18 in Cachyos/Scripts/WIP/emu/test_sanitize_filename.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

Cachyos/Scripts/WIP/emu/test_sanitize_filename.py#L18

Method name "test_preservation_of_valid_characters" doesn't conform to '[a-z_][a-z0-9_]{2,30}$' pattern
# a-z, A-Z, 0-9, -, _, ., and spaces
input_name = "test-FILE_123.3ds"
expected = "test-FILE_123.3ds"
self.assertEqual(decryptor.sanitize_filename(input_name), expected)

def test_removal_of_invalid_characters(self):

Check notice on line 24 in Cachyos/Scripts/WIP/emu/test_sanitize_filename.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

Cachyos/Scripts/WIP/emu/test_sanitize_filename.py#L24

Method name "test_removal_of_invalid_characters" doesn't conform to '[a-z_][a-z0-9_]{2,30}$' pattern
# !@#$%^&*()+={}[]|\:;"'<>,/? should be removed
input_name = "test!@#$ %^&*()_+= file.cia"
# VALID_CHARS = frozenset("-_abcdefghijklmnopqrstuvwxyz1234567890. ")
# "test", " ", "_", " ", "file.cia" are valid
expected = "test _ file.cia"
self.assertEqual(decryptor.sanitize_filename(input_name), expected)

def test_fallback_behavior(self):
# If all characters are removed, the original name is returned.
input_name = "!!!@@@###"
# All are invalid, so 'out' would be empty, returns original
expected = "!!!@@@###"
self.assertEqual(decryptor.sanitize_filename(input_name), expected)

def test_mixed_case_preservation(self):
input_name = "MixedCaseFILENAME.3ds"
expected = "MixedCaseFILENAME.3ds"
self.assertEqual(decryptor.sanitize_filename(input_name), expected)

def test_empty_string(self):
self.assertEqual(decryptor.sanitize_filename(""), "")

if __name__ == '__main__':
unittest.main()
164 changes: 117 additions & 47 deletions Cachyos/Scripts/WIP/gphotos/Splitter.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import os
import shutil
import argparse
import sys


# Function to calculate folder size recursively
Expand All @@ -27,25 +28,115 @@ def create_new_folder(root_folder, folder_name):

def get_latest_group_info(photos_folder):
"""Finds the highest numbered Group_N folder under photos_folder and its size."""
if not os.path.exists(photos_folder):
return 1, None, 0

max_group_num = 0
latest_group_folder = None

if os.path.exists(photos_folder):
for entry in os.scandir(photos_folder):
if entry.name.startswith("Group_") and entry.is_dir():
try:
num = int(entry.name.split("_")[1])
if num > max_group_num:
max_group_num = num
latest_group_folder = entry.path
except (ValueError, IndexError):
continue
for entry in os.scandir(photos_folder):
if not (entry.name.startswith("Group_") and entry.is_dir()):
Comment on lines 29 to +38
continue
try:
num = int(entry.name.split("_")[1])
if num > max_group_num:
max_group_num = num
latest_group_folder = entry.path
except (ValueError, IndexError):
continue

if max_group_num == 0 or latest_group_folder is None:
return 1, None, 0

return max_group_num, latest_group_folder, get_folder_size(latest_group_folder)
# Main function


def process_file(
file_path,
target_folder_size,
photos_folder,
current_group_num,
current_group_folder,
current_group_size,
abs_group_folder,
):
try:
file_size = os.path.getsize(file_path)
except OSError:
return current_group_num, current_group_folder, current_group_size, abs_group_folder

# Skip moving files larger than target group size
if file_size > target_folder_size:
print(
f"Skipping photo '{file_path}' because it's larger than the target group size."
)
return current_group_num, current_group_folder, current_group_size, abs_group_folder

(
current_group_num,
current_group_folder,
current_group_size,
abs_group_folder,
) = ensure_space_in_group(
photos_folder,
current_group_num,
current_group_folder,
current_group_size,
file_size,
target_folder_size,
abs_group_folder,
)

current_group_size = move_file_to_group(
file_path,
current_group_folder,
abs_group_folder,
file_size,
current_group_size,
)

return current_group_num, current_group_folder, current_group_size, abs_group_folder


def move_file_to_group(
file_path, current_group_folder, abs_group_folder, file_size, current_group_size
):
"""Moves the file to the current group folder if it's not already there."""
abs_file_path = os.path.abspath(file_path)

if os.path.commonpath([abs_file_path, abs_group_folder]) != abs_group_folder:
try:
shutil.move(file_path, current_group_folder)
print(f"Moved photo '{file_path}' to '{current_group_folder}'")
return current_group_size + file_size
except Exception as e:
print(f"Failed to move photo '{file_path}': {e}")
return current_group_size


def ensure_space_in_group(
photos_folder,
current_group_num,
current_group_folder,
current_group_size,
file_size,
target_folder_size,
abs_group_folder,
):
"""Checks if current group is full, and moves to next until we find one with space or create new."""
while current_group_size + file_size > target_folder_size:
current_group_num += 1
current_group_folder = os.path.join(photos_folder, f"Group_{current_group_num}")
if os.path.exists(current_group_folder):
current_group_size = get_folder_size(current_group_folder)
else:
create_new_folder(photos_folder, f"Group_{current_group_num}")
current_group_size = 0
abs_group_folder = os.path.abspath(current_group_folder)
return current_group_num, current_group_folder, current_group_size, abs_group_folder


def group_photos(photos_folder, target_folder_size):
print(
f"Grouping photos in '{photos_folder}' with target size {target_folder_size} bytes..."
Expand All @@ -60,48 +151,28 @@ def group_photos(photos_folder, target_folder_size):
photos_folder, f"Group_{current_group_num}"
)

abs_group_folder = os.path.abspath(current_group_folder)

for root, dirs, files in os.walk(photos_folder):
# Exclude generated group folders from os.walk
dirs[:] = [d for d in dirs if not d.startswith("Group_")]

for file in files:
file_path = os.path.join(root, file)
try:
file_size = os.path.getsize(file_path)
except OSError:
continue

# Skip moving files larger than target group size
if file_size > target_folder_size:
print(
f"Skipping photo '{file_path}' because it's larger than the target group size."
)
continue

# Check if current group is full, and move to next until we find one with space or create new
while current_group_size + file_size > target_folder_size:
current_group_num += 1
current_group_folder = os.path.join(
photos_folder, f"Group_{current_group_num}"
)
if os.path.exists(current_group_folder):
current_group_size = get_folder_size(current_group_folder)
else:
create_new_folder(photos_folder, f"Group_{current_group_num}")
current_group_size = 0

# Move the file to the current group folder if it's not already there
# We use absolute paths for comparison to be safe
abs_file_path = os.path.abspath(file_path)
abs_group_folder = os.path.abspath(current_group_folder)

if os.path.commonpath([abs_file_path, abs_group_folder]) != abs_group_folder:
try:
shutil.move(file_path, current_group_folder)
print(f"Moved photo '{file_path}' to '{current_group_folder}'")
current_group_size += file_size
except Exception as e:
print(f"Failed to move photo '{file_path}': {e}")
(
current_group_num,
current_group_folder,
current_group_size,
abs_group_folder,
) = process_file(
file_path,
target_folder_size,
photos_folder,
current_group_num,
current_group_folder,
current_group_size,
abs_group_folder,
)

print("Grouping completed.")

Expand Down Expand Up @@ -140,7 +211,6 @@ def parse_size(size_str):
args = parser.parse_args()

if not os.path.isdir(args.photos_folder):
import sys
print(f"Error: '{args.photos_folder}' is not a directory.", file=sys.stderr)
sys.exit(1)

Expand Down
6 changes: 6 additions & 0 deletions Cachyos/Scripts/WIP/gphotos/test_splitter.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,12 @@
with self.assertRaises(argparse.ArgumentTypeError):
Splitter.parse_size("invalid")

def test_get_latest_group_info_missing_directory(self):

Check notice on line 86 in Cachyos/Scripts/WIP/gphotos/test_splitter.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

Cachyos/Scripts/WIP/gphotos/test_splitter.py#L86

Method name "test_get_latest_group_info_missing_directory" doesn't conform to '[a-z_][a-z0-9_]{2,30}$' pattern
self.assertEqual(
Splitter.get_latest_group_info(os.path.join(self.TEST_DIR, "missing")),
(1, None, 0),
)

def test_skip_large_files(self):
# Create a file larger than target size
self.create_dummy_file("huge.jpg", self.TARGET_SIZE + 1)
Expand Down
Loading
Loading