ifixkart-backend/Backend/app/services/migration_engine/media_resolver.py

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