230 lines
8.4 KiB
Python
230 lines
8.4 KiB
Python
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
|
|
|