import json import os import platform import re import shutil import subprocess import unicodedata from typing import Dict, List, Optional from .config import ENABLE_JSON_SIDECAR, METADATA_EXT from .logger import get_logger # Supported extensions IMAGE_EXTS = (".png", ".jpg", ".jpeg", ".webp", ".gif") VIDEO_EXTS = (".mp4", ".mov", ".webm", ".mkv") AUDIO_EXTS = (".wav", ".mp3", ".flac", ".ogg", ".m4a", ".aac") MODEL3D_EXTS = (".obj", ".fbx", ".glb", ".gltf", ".stl") WINDOWS_RATING_INDEX = 14 # fallback WINDOWS_TAGS_INDEX = 21 # fallback STAR_CHARS = ("\u2605", "\u2606", "\u22c6", "\u272d", "\u272e", "\u2730") RATING_KEYS = ( "rating", "note", "notation", "evaluation", "evaluations", "etoile", "etoiles", "star", "stars", ) TAG_KEYS = ("tag", "mot-cle", "mot cle", "mots cle", "keywords", "keyword") _WIN32COM: Optional[object] = None _WIN32COM_CHECKED = False _CACHED_RATING_INDEX: Optional[int] = None _CACHED_TAGS_INDEX: Optional[int] = None _EXIFTOOL_PATH: Optional[str] = None _EXIFTOOL_CHECKED = False _META_CACHE: Dict[str, tuple[Optional[float], int, dict]] = {} _CACHE_EPOCH = 0 log = get_logger(__name__) def _env_timeout(name: str, default: float, min_s: float = 0.5, max_s: float = 60.0) -> float: """ Parse a timeout value from env and clamp it to a safe range. Keeps external metadata tools from hanging the server. """ try: raw = os.environ.get(name, None) val = float(default) if raw is None else float(raw) except Exception: val = float(default) try: return max(float(min_s), min(float(val), float(max_s))) except Exception: return float(default) def _force_sidecar_enabled() -> bool: val = str(os.environ.get("MJR_FORCE_SIDECAR", "0")).lower() return val in ("1", "true", "yes", "on") def _sidecar_allowed_for(path: str) -> bool: return ENABLE_JSON_SIDECAR or _force_sidecar_enabled() def _normalize_label(text) -> str: try: normalized = unicodedata.normalize("NFKD", str(text or "")) normalized = "".join(ch for ch in normalized if not unicodedata.combining(ch)) return normalized.lower().strip() except Exception: return str(text or "").lower().strip() def _get_mtime_safe(path: str) -> Optional[float]: try: return os.path.getmtime(path) except Exception: return None def safe_import_win32com(): global _WIN32COM, _WIN32COM_CHECKED if _WIN32COM_CHECKED: return _WIN32COM _WIN32COM_CHECKED = True if platform.system().lower() == "windows": try: import win32com.client # type: ignore _WIN32COM = win32com.client except ImportError: log.warning("Windows logic disabled (pywin32 not found)") _WIN32COM = None else: _WIN32COM = None return _WIN32COM def _get_exiftool_path() -> Optional[str]: global _EXIFTOOL_PATH, _EXIFTOOL_CHECKED if _EXIFTOOL_CHECKED: return _EXIFTOOL_PATH _EXIFTOOL_CHECKED = True _EXIFTOOL_PATH = shutil.which("exiftool") or shutil.which("exiftool.exe") return _EXIFTOOL_PATH def _parse_rating_value(raw) -> int: if raw in (None, ""): return 0 s_raw = str(raw).strip() if not s_raw: return 0 star_count = sum(s_raw.count(ch) for ch in STAR_CHARS) if star_count: return max(0, min(star_count, 5)) try: numeric = float(s_raw.replace(",", ".").split()[0]) except Exception: return 0 if numeric <= 0: return 0 if numeric > 5: if numeric <= 25: return 2 if numeric <= 50: return 3 if numeric <= 75: return 4 return 5 return int(round(min(numeric, 5))) def _coerce_rating_to_stars(raw) -> int: """ Normalize arbitrary rating inputs to 0..5 stars. Accepts: - 0..5 stars (int/float/str) - Windows percent scale (0..100) - star glyphs (e.g. ★★★☆☆) """ if raw in (None, ""): return 0 try: num = float(str(raw).strip().replace(",", ".").split()[0]) except Exception: return max(0, min(_parse_rating_value(raw), 5)) if num <= 0: return 0 if 0 <= num <= 5: return max(0, min(int(round(num)), 5)) if num <= 100: return windows_percent_to_stars(int(round(num))) return max(0, min(_parse_rating_value(raw), 5)) def _normalize_tags(raw) -> List[str]: """ Normalize tags to a deduplicated, order-preserving list of non-empty strings. Accepts list or comma/semicolon-delimited strings. """ if raw is None: return [] if isinstance(raw, list): candidates = raw elif isinstance(raw, str): candidates = [t.strip() for t in re.split(r"[;,]", raw)] else: return [] out: List[str] = [] seen = set() for t in candidates: s = str(t).strip() if not s or s in seen: continue seen.add(s) out.append(s) return out def rating_to_windows_percent(stars: int) -> int: """ Map 0..5 stars to Windows percent scale expected by SharedUserRating. """ try: r = int(stars) except Exception: r = 0 mapping = {0: 0, 1: 1, 2: 25, 3: 50, 4: 75, 5: 99} return max(0, min(99, mapping.get(r, 0 if r < 0 else r))) def windows_percent_to_stars(percent: int) -> int: """ Convert Windows percent (SharedUserRating) to 0..5 stars using thresholds. """ try: p = int(percent) except Exception: return 0 if p <= 0: return 0 if p <= 12: return 1 if p <= 37: return 2 if p <= 62: return 3 if p <= 87: return 4 return 5 def tags_to_windows_category(tags: list) -> str: """ Join tags for Microsoft:Category; use '; ' which Explorer parses. """ if not isinstance(tags, list): return "" cleaned = [str(t).strip() for t in tags if str(t).strip()] return "; ".join(cleaned) def windows_category_to_tags(cat: str) -> list: """ Split Microsoft:Category back into a list; preserve order, drop empties. """ if not cat: return [] parts = [p.strip() for p in str(cat).split(";") if p.strip()] return parts def get_windows_metadata(file_path: str) -> dict: """Fetch file metadata via the Windows Shell API.""" try: win32com = safe_import_win32com() if not win32com: return {"rating": 0, "tags": []} shell = win32com.Dispatch("Shell.Application") folder = shell.Namespace(os.path.dirname(file_path)) file = folder.ParseName(os.path.basename(file_path)) rating_idx, tags_idx = _resolve_shell_indices(folder) rating_raw = folder.GetDetailsOf(file, rating_idx) tags_raw = folder.GetDetailsOf(file, tags_idx) rating = 0 # Windows may expose either stars or SharedUserRating percent; normalize to stars try: numeric = float(str(rating_raw).strip()) if 0 <= numeric <= 100 and numeric > 5: rating = windows_percent_to_stars(int(round(numeric))) else: rating = _parse_rating_value(numeric) except Exception: rating = _parse_rating_value(rating_raw) tags = [] if tags_raw: tag_text = str(tags_raw) for sep in [",", ";"]: if sep in tag_text: tags = [t.strip() for t in tag_text.split(sep) if t.strip()] break else: t = tag_text.strip() if t: tags = [t] return { "rating": rating, "tags": tags, } except Exception as e: log.warning("Error reading metadata for %s: %s", file_path, e) return {"rating": 0, "tags": []} # Canonical ExifTool reader for rating/tags (avoid duplicate implementations) def get_exif_metadata(file_path: str) -> dict: """ Fast read via exiftool when available. Returns {"rating": int, "tags": List[str]} or {} on fallback. """ exe = _get_exiftool_path() if not exe: return {} try: timeout_s = _env_timeout("MJR_EXIFTOOL_READ_TIMEOUT", 2.0, min_s=0.5, max_s=60.0) cmd = [ exe, "-n", "-json", "-Rating", "-RatingPercent", "-Microsoft:SharedUserRating", "-Microsoft:Category", "-Subject", "-Keywords", "-XPKeywords", file_path, ] out = subprocess.check_output(cmd, timeout=timeout_s) data = json.loads(out)[0] is_video = file_path.lower().endswith((".mp4", ".mov", ".m4v", ".webm", ".mkv")) rating_raw = data.get("Rating") if rating_raw is None: rating_raw = data.get("RatingPercent") if rating_raw is None: rating_raw = data.get("Microsoft:SharedUserRating") rating = 0 if rating_raw is not None: try: num = float(rating_raw) except Exception: num = None if num is not None and 0 <= num <= 5: rating = int(round(num)) elif num is not None and num > 5: rating = windows_percent_to_stars(int(round(num))) else: rating = _parse_rating_value(rating_raw) tags: List[str] = [] if is_video: cat = data.get("Microsoft:Category") if cat: tags = windows_category_to_tags(cat) if not tags: for key in ("Subject", "Keywords", "XPKeywords"): val = data.get(key) if isinstance(val, list): tags.extend([str(v).strip() for v in val if str(v).strip()]) elif isinstance(val, str): tags.extend([t.strip() for t in val.split(",") if t.strip()]) if tags: tags = sorted(set(tags)) return {"rating": rating, "tags": tags} except subprocess.TimeoutExpired: return {} except Exception: return {} # Self-check for rating normalization; kept lightweight def _test_rating_normalization(): assert windows_percent_to_stars(5) == 1 # percent-like but low assert windows_percent_to_stars(99) == 5 assert windows_percent_to_stars(75) == 4 assert windows_percent_to_stars(1) == 1 assert _parse_rating_value(5) == 5 assert _parse_rating_value(0) == 0 _test_rating_normalization() # Canonical ExifTool writer for rating/tags (video-aware); do not duplicate def set_exif_metadata(file_path: str, rating: int, tags: list) -> bool: """ Write Rating/Keywords via exiftool when available. Returns True on success. """ exe = _get_exiftool_path() if not exe: return False global _CACHE_EPOCH _CACHE_EPOCH += 1 epoch = _CACHE_EPOCH original_mtime = _get_mtime_safe(file_path) def _collect_preserve_args_for_video() -> list: if not exe: return [] try: timeout_s = _env_timeout("MJR_EXIFTOOL_PRESERVE_TIMEOUT", 3.0, min_s=0.5, max_s=60.0) cmd = [ exe, "-j", "-Comment", "-UserComment", "-Description", "-UserData", "-XMP:Comment", "-XMP:Description", "-ItemList:UserComment", file_path, ] out = subprocess.check_output(cmd, stderr=subprocess.DEVNULL, timeout=timeout_s) data_list = json.loads(out) if out else [] if not data_list: return [] entry = data_list[0] preserve_args = [] for key in ("Comment", "UserComment", "Description", "UserData", "XMP:Comment", "XMP:Description", "ItemList:UserComment"): val = entry.get(key) if isinstance(val, list): val = " ".join(str(v) for v in val if str(v).strip()) if isinstance(val, str): val = val.strip() if val: preserve_args.append(f"-{key}={val}") return preserve_args except subprocess.TimeoutExpired: return [] except Exception: return [] try: write_timeout_s = _env_timeout("MJR_EXIFTOOL_WRITE_TIMEOUT", 3.0, min_s=0.5, max_s=120.0) r = _coerce_rating_to_stars(rating) win_r = rating_to_windows_percent(r) is_video = file_path.lower().endswith((".mp4", ".mov", ".m4v", ".webm", ".mkv")) preserve_args = _collect_preserve_args_for_video() if is_video else [] cmd = [exe, "-overwrite_original", *preserve_args] cmd += [ f"-xmp:rating={r}", f"-rating={r}", f"-ratingpercent={win_r}", "-Subject=", "-Keywords=", "-XPKeywords=", ] if is_video: cmd.append(f"-Microsoft:SharedUserRating={win_r}") cat = tags_to_windows_category(tags) cmd.append(f"-Microsoft:Category={cat}") clean_tags = _normalize_tags(tags) # Always write Category for video, even if empty, to clear stale values if is_video and not clean_tags: cmd.append(f"-Microsoft:Category=") if clean_tags: for t in clean_tags: cmd.append(f"-xmp:subject+={t}") cmd.append(f"-iptc:keywords+={t}") # XPKeywords expects a semicolon-separated string on Windows cmd.append(f"-xpkeywords={'; '.join(clean_tags)}") cmd.append(file_path) subprocess.run(cmd, check=True, capture_output=True, timeout=write_timeout_s) # Restore mtime to avoid reordering the grid after rating/tag updates if original_mtime is not None: try: os.utime(file_path, (original_mtime, original_mtime)) except Exception: pass # Update cache with current epoch _META_CACHE[file_path] = ( original_mtime if original_mtime is not None else _get_mtime_safe(file_path), epoch, {"rating": rating, "tags": tags}, ) return True except subprocess.TimeoutExpired: return False except Exception: return False def set_windows_metadata(file_path: str, rating: int, tags: list) -> bool: """Write metadata via the Windows Shell API.""" try: win32com = safe_import_win32com() if not win32com: return False original_mtime = _get_mtime_safe(file_path) shell = win32com.Dispatch("Shell.Application") folder = shell.Namespace(os.path.dirname(file_path)) file = folder.ParseName(os.path.basename(file_path)) rating_idx, tags_idx = _resolve_shell_indices(folder) # Rating (SharedUserRating percent 0..99); accept stars or percent and normalize if rating is not None: stars = _coerce_rating_to_stars(rating) rating_val = rating_to_windows_percent(stars) folder.GetDetailsOf(file, rating_idx) # force index load folder.SetDetailsOf(file, rating_idx, str(rating_val)) tags_list = _normalize_tags(tags) if tags_list: folder.GetDetailsOf(file, tags_idx) folder.SetDetailsOf(file, tags_idx, "; ".join(tags_list)) elif tags == []: folder.GetDetailsOf(file, tags_idx) folder.SetDetailsOf(file, tags_idx, "") # Restore mtime to avoid reordering the grid after rating/tag updates if original_mtime is not None: try: os.utime(file_path, (original_mtime, original_mtime)) except Exception: pass global _CACHE_EPOCH _CACHE_EPOCH += 1 epoch = _CACHE_EPOCH _META_CACHE[file_path] = ( original_mtime if original_mtime is not None else _get_mtime_safe(file_path), epoch, {"rating": _coerce_rating_to_stars(rating), "tags": tags_list}, ) return True except Exception: return False def _resolve_shell_indices(folder) -> (int, int): """ Tente de localiser les colonnes "Rating"/"Tags" en fonction de la locale. Retourne un tuple (rating_idx, tags_idx) avec fallback sur les constantes. """ global _CACHED_RATING_INDEX, _CACHED_TAGS_INDEX if _CACHED_RATING_INDEX is not None and _CACHED_TAGS_INDEX is not None: return _CACHED_RATING_INDEX, _CACHED_TAGS_INDEX rating_idx = WINDOWS_RATING_INDEX tags_idx = WINDOWS_TAGS_INDEX try: for i in range(0, 256): col_name = _normalize_label(folder.GetDetailsOf(None, i)) if not col_name: continue if _CACHED_RATING_INDEX is None and any(key in col_name for key in RATING_KEYS): _CACHED_RATING_INDEX = i if _CACHED_TAGS_INDEX is None and any(key in col_name for key in TAG_KEYS): _CACHED_TAGS_INDEX = i if _CACHED_RATING_INDEX is not None and _CACHED_TAGS_INDEX is not None: break except Exception: pass if _CACHED_RATING_INDEX is None: _CACHED_RATING_INDEX = rating_idx if _CACHED_TAGS_INDEX is None: _CACHED_TAGS_INDEX = tags_idx return _CACHED_RATING_INDEX, _CACHED_TAGS_INDEX def apply_windows_metadata(file_path: str, rating, tags: list) -> dict: """ Apply rating/tags via Windows API while handling defaults. Retourne {"rating": int, "tags": List[str]}. """ try: rating_int = int(rating) if rating is not None else 0 except Exception: rating_int = 0 tags_list: List[str] = [] if isinstance(tags, list): tags_list = [str(t) for t in tags if str(t).strip()] set_windows_metadata(file_path, rating_int, tags_list) return {"rating": rating_int, "tags": tags_list} # Canonical metadata updater (Windows + ExifTool + optional sidecar); keep single definition def update_metadata_with_windows(file_path: str, updates: dict) -> dict: """ Update rating/tags via Windows + sidecar (if enabled). Returns final metadata. """ global _CACHE_EPOCH start_epoch = _CACHE_EPOCH # Load current values (prefer system metadata, fallback to sidecar) sys_meta = get_system_metadata(file_path) or {} sidecar = load_metadata(file_path) or {} current_rating = sys_meta.get("rating", sidecar.get("rating", 0)) current_tags = sys_meta.get("tags", sidecar.get("tags", [])) has_rating = "rating" in updates has_tags = "tags" in updates if not has_rating and not has_tags: return {"rating": current_rating, "tags": current_tags} rating = _coerce_rating_to_stars(updates.get("rating", current_rating)) tags = _normalize_tags(updates.get("tags", current_tags)) # Only write tags when provided; otherwise preserve existing tags in system metadata target_tags = tags if has_tags else _normalize_tags(current_tags) ok_win = False ok_exif = False if has_rating or has_tags: ok_win = set_windows_metadata(file_path, rating if has_rating else current_rating, target_tags if has_tags else current_tags) ok_exif = set_exif_metadata(file_path, rating if has_rating else current_rating, target_tags if has_tags else current_tags) sidecar_saved = False if _sidecar_allowed_for(file_path): save_metadata(file_path, {"rating": rating if has_rating else current_rating, "tags": target_tags}) sidecar_saved = True effective_rating = (rating if has_rating else current_rating) if (ok_win or ok_exif or sidecar_saved) else current_rating effective_tags = target_tags # Ensure cache invalidates even when mtime is preserved (Windows/ExifTool writes). if (ok_win or ok_exif or sidecar_saved) and _CACHE_EPOCH == start_epoch: _CACHE_EPOCH += 1 _META_CACHE[file_path] = ( _get_mtime_safe(file_path), _CACHE_EPOCH, {"rating": _coerce_rating_to_stars(effective_rating), "tags": _normalize_tags(effective_tags)}, ) return {"rating": effective_rating, "tags": effective_tags} def apply_system_metadata(file_path: str, rating, tags: list) -> dict: """ Try exiftool first (if installed), otherwise Windows Shell. """ global _CACHE_EPOCH start_epoch = _CACHE_EPOCH rating_int = _coerce_rating_to_stars(rating) tags_list = _normalize_tags(tags) # exiftool (meilleure compat Windows + autres OS) if set_exif_metadata(file_path, rating_int, tags_list): return {"rating": rating_int, "tags": tags_list} # fallback shell API meta = apply_windows_metadata(file_path, rating_int, tags_list) if _CACHE_EPOCH == start_epoch: _CACHE_EPOCH += 1 _META_CACHE[file_path] = (_get_mtime_safe(file_path), _CACHE_EPOCH, meta) return meta def get_system_metadata(file_path: str) -> dict: """ Prefer ExifTool metadata (Rating/Keywords), otherwise Windows API. """ mtime = _get_mtime_safe(file_path) cached = _META_CACHE.get(file_path) if cached and cached[0] == mtime and cached[1] == _CACHE_EPOCH: return dict(cached[2]) meta = get_exif_metadata(file_path) if meta.get("rating") or meta.get("tags"): _META_CACHE[file_path] = (mtime, _CACHE_EPOCH, meta) return meta meta = get_windows_metadata(file_path) if (meta.get("rating") or meta.get("tags")) and not (meta.get("rating") == 0 and not meta.get("tags")): _META_CACHE[file_path] = (mtime, _CACHE_EPOCH, meta) return meta # Sidecar fallback only when enabled/forced if _sidecar_allowed_for(file_path): sidecar = load_metadata(file_path) if sidecar: r = sidecar.get("rating", 0) t = sidecar.get("tags", []) meta = {"rating": r if isinstance(r, int) else 0, "tags": t if isinstance(t, list) else []} _META_CACHE[file_path] = (mtime, _CACHE_EPOCH, meta) return meta _META_CACHE[file_path] = (mtime, _CACHE_EPOCH, meta) return meta def classify_ext(lower_name: str) -> str: """Retourne image / video / audio / model3d / other.""" if lower_name.endswith(IMAGE_EXTS): return "image" if lower_name.endswith(VIDEO_EXTS): return "video" if lower_name.endswith(AUDIO_EXTS): return "audio" if lower_name.endswith(MODEL3D_EXTS): return "model3d" return "other" def ensure_dir(path): if not os.path.exists(path): os.makedirs(path) def is_image(fname: str): f = fname.lower() return f.endswith(IMAGE_EXTS) def metadata_path(image_path: str): if not _sidecar_allowed_for(image_path): return None return image_path + METADATA_EXT def load_metadata(image_path): meta_file = metadata_path(image_path) if not meta_file: return {} if not os.path.exists(meta_file): return {} try: with open(meta_file, "r", encoding="utf-8") as f: return json.load(f) except Exception: return {} def save_metadata(image_path, meta: dict): meta_file = metadata_path(image_path) if not meta_file: return with open(meta_file, "w", encoding="utf-8") as f: json.dump(meta, f, indent=2, ensure_ascii=False)