import os import json import zipfile import hashlib import re from typing import Dict, Any, List, Tuple, Optional try: from PIL import Image except ImportError: Image = None class MediaResolver: """ Parses Media.zip files. Supports explicit media.json overrides and automatic folder hierarchy fallback parsing. Computes SHA-256 checksums and image metadata. Enforces canonical media_key pipeline rules. """ SUPPORTED_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.webp', '.gif', '.bmp', '.tiff'} @staticmethod def canonicalize_media_key(raw_key: Optional[str]) -> Optional[str]: """ Canonicalizes raw media keys: - None -> None - Empty/whitespace -> None - Lowercase - Replace spaces, underscores, slashes, backslashes with hyphens - Collapse repeated hyphens - Strip leading/trailing hyphens """ if raw_key is None: return None s = str(raw_key).strip() if not s: return None s = s.lower() s = re.sub(r'[\s_/\\]+', '-', s) s = re.sub(r'-+', '-', s) s = s.strip('-') return s if s else None @classmethod def detect_archive_wrapper(cls, zip_entries: List[Any]) -> Optional[str]: """ Deterministically detects if ALL file entries in a ZIP share a single top-level wrapper directory. Ignores pure directory entries and __MACOSX / .DS_Store system files. """ candidate_wrapper = None has_file_entries = False for entry in zip_entries: name = entry.filename if hasattr(entry, 'filename') else str(entry) name = name.replace("\\", "/") # Skip directory entries and system files if name.endswith("/") or "__MACOSX/" in name or os.path.basename(name).startswith("."): continue parts = [p for p in name.split("/") if p] if not parts: continue has_file_entries = True # File is at the root level of the ZIP archive -> No wrapper if len(parts) == 1: return None top_dir = parts[0] if candidate_wrapper is None: candidate_wrapper = top_dir elif candidate_wrapper != top_dir: # Multiple distinct top-level directories -> No single wrapper return None return candidate_wrapper if has_file_entries else None @staticmethod def compute_sha256(file_path: str) -> str: sha256_hash = hashlib.sha256() with open(file_path, "rb") as f: for byte_block in iter(lambda: f.read(65536), b""): sha256_hash.update(byte_block) return sha256_hash.hexdigest() @classmethod def derive_media_key_from_path(cls, rel_dir: str, wrapper_prefix: Optional[str] = None) -> Optional[str]: """ Converts folder paths like 'Samsung/Galaxy S24/Matte' to canonical 'samsung-galaxy-s24-matte'. Strips wrapper_prefix if present. """ if not rel_dir: return None clean_path = rel_dir.replace("\\", "/") if wrapper_prefix: clean_wrapper = wrapper_prefix.replace("\\", "/").strip("/") if clean_path == clean_wrapper: return None if clean_path.startswith(clean_wrapper + "/"): clean_path = clean_path[len(clean_wrapper) + 1:] parts = [p.strip() for p in clean_path.split("/") if p.strip()] if not parts: return None raw_key = "-".join(parts) return cls.canonicalize_media_key(raw_key) @classmethod def process_zip_archive(cls, zip_path: str, extract_to_dir: str) -> Tuple[Dict[str, Dict[str, Any]], List[Dict[str, Any]]]: """ Extracts ZIP archive and scans all folders. Returns: media_groups: Dict[canonical_media_key, metadata_dict] media_files: List[file_metadata_dict] """ os.makedirs(extract_to_dir, exist_ok=True) with zipfile.ZipFile(zip_path, 'r') as zip_ref: entries = zip_ref.infolist() wrapper_prefix = cls.detect_archive_wrapper(entries) zip_ref.extractall(extract_to_dir) media_groups: Dict[str, Dict[str, Any]] = {} media_files: List[Dict[str, Any]] = [] for root, dirs, files in os.walk(extract_to_dir): rel_dir = os.path.relpath(root, extract_to_dir) if rel_dir == ".": rel_dir = "" # 1. Check for media.json override in current directory media_json_path = os.path.join(root, "media.json") dir_media_key = None dir_brand = None dir_model = None dir_variant = None source_type = "FOLDER_PATH" if os.path.exists(media_json_path): try: with open(media_json_path, 'r', encoding='utf-8') as f: meta = json.load(f) explicit_key = meta.get("mediaKey") or meta.get("media_key") if explicit_key: dir_media_key = cls.canonicalize_media_key(explicit_key) dir_brand = meta.get("brand") dir_model = meta.get("model") dir_variant = meta.get("variant") source_type = "MEDIA_JSON" except Exception as e: print(f"Warning: Failed to parse media.json in {root}: {e}") if not dir_media_key and rel_dir: dir_media_key = cls.derive_media_key_from_path(rel_dir, wrapper_prefix=wrapper_prefix) if not dir_media_key: dir_media_key = "general-media" if dir_media_key not in media_groups: parts = [p.strip() for p in rel_dir.split(os.sep) if p.strip()] brand = dir_brand or (parts[0] if len(parts) > 0 else "Unknown") model = dir_model or (parts[1] if len(parts) > 1 else "Unknown") variant = dir_variant or (parts[2] if len(parts) > 2 else "Standard") media_groups[dir_media_key] = { "media_key": dir_media_key, "source_type": source_type, "brand_name": brand, "model_name": model, "variant_tag": variant } # 2. Process image files in directory import struct PIL_Image = None for filename in files: ext = os.path.splitext(filename)[1].lower() if ext not in cls.SUPPORTED_EXTENSIONS: continue full_path = os.path.join(root, filename) file_size = os.path.getsize(full_path) try: with open(full_path, "rb") as f: file_bytes = f.read() except Exception: continue checksum = hashlib.sha256(file_bytes).hexdigest() width, height = 0, 0 if ext == ".png" and len(file_bytes) >= 24: if file_bytes[:8] == b'\x89PNG\r\n\x1a\n' and file_bytes[12:16] == b'IHDR': try: width, height = struct.unpack(">II", file_bytes[16:24]) except Exception: pass if width == 0 or height == 0: try: if PIL_Image is None: from PIL import Image as PIL_Image with PIL_Image.open(full_path) as img: width, height = img.size except Exception: pass rel_path = os.path.relpath(full_path, extract_to_dir) media_files.append({ "media_key": dir_media_key, "original_filename": filename, "relative_path": rel_path, "local_path": full_path, "file_size_bytes": file_size, "mime_type": f"image/{ext.replace('.', '')}", "width": width, "height": height, "sha256_checksum": checksum }) return media_groups, media_files