From 50de7e1d4ad5daffc081596cc3fd83f63120ad70 Mon Sep 17 00:00:00 2001 From: sHa Date: Mon, 29 Dec 2025 19:47:55 +0000 Subject: [PATCH] added media catalog mode, impooved cache --- ToDo.md | 40 ++++- dist/renamer-0.5.1-py3-none-any.whl | Bin 0 -> 46589 bytes pyproject.toml | 3 +- renamer/app.py | 86 ++++++++-- renamer/cache.py | 187 +++++++++++++++++++++ renamer/constants.py | 6 + renamer/decorators/__init__.py | 4 + renamer/decorators/caching.py | 49 ++++++ renamer/extractors/extractor.py | 54 ++++-- renamer/extractors/fileinfo_extractor.py | 7 + renamer/extractors/filename_extractor.py | 11 ++ renamer/extractors/mediainfo_extractor.py | 15 ++ renamer/extractors/metadata_extractor.py | 7 +- renamer/extractors/tmdb_extractor.py | 118 ++++++++----- renamer/formatters/catalog_formatter.py | 107 ++++++++++++ renamer/formatters/formatter.py | 1 - renamer/formatters/resolution_formatter.py | 43 ++--- renamer/screens.py | 92 +++++++++- renamer/settings.py | 72 ++++++++ uv.lock | 104 +++++++++++- 20 files changed, 900 insertions(+), 106 deletions(-) create mode 100644 dist/renamer-0.5.1-py3-none-any.whl create mode 100644 renamer/cache.py create mode 100644 renamer/decorators/__init__.py create mode 100644 renamer/decorators/caching.py create mode 100644 renamer/formatters/catalog_formatter.py create mode 100644 renamer/settings.py diff --git a/ToDo.md b/ToDo.md index aff9a8a..c85aa03 100644 --- a/ToDo.md +++ b/ToDo.md @@ -27,4 +27,42 @@ TODO Steps: 24. Implement metadata editing capabilities (future enhancement) 25. Add batch rename operations (future enhancement) 26. Add configuration file support (future enhancement) -27. Add plugin system for custom extractors/formatters (future enhancement) \ No newline at end of file +27. Add plugin system for custom extractors/formatters (future enhancement) + +--- + +## Media Catalog Mode Implementation Plan + +**New big app evolution step: Add media catalog mode with settings, caching, and enhanced TMDB display.** + +### Phase 1: Settings Management Foundation +1. ✅ Create settings module (`renamer/settings.py`) for JSON config in `~/.config/renamer/config.json` with schema: mode, cache TTLs +2. ✅ Integrate settings into app startup (load/save on launch/exit) +3. ✅ Add settings window to UI with fields for mode and TTLs +4. ✅ Add "Open Settings" command to command panel +5. ✅ Order setting menu item in the action bar by right side, close to the sysytem menu item ^p palette + +### Phase 2: Mode Toggle and UI Switching +5. ✅ Add "Toggle Mode" command to switch between "technical" and "catalog" modes +6. ✅ Modify right pane for mode-aware display (technical vs catalog info) +7. ✅ Persist and restore mode state from settings + +### Phase 3: Caching System +8. ✅ Create caching module (`renamer/cache.py`) for file-based cache with TTL support +9. ✅ Integrate caching into extractors (check cache first, store results) +10. ✅ Add refresh command to force re-extraction and cache update +11. ✅ Handle cache cleanup on file rename (invalidate old filename) + +### Phase 4: Media Catalog Display +12. ✅ Update TMDB extractor for catalog data: title, year, duration, rates, overview, genres codes, poster_path +13. ✅ Create catalog formatter (`formatters/catalog_formatter.py`) for beautiful display +14. ✅ Integrate catalog display into right pane + +### Phase 5: Poster Handling and Display +15. ✅ Add poster caching (images in cache dir with 1-month TTL) +16. ✅ Implement terminal image display (research rich-pixels or alternatives, add poster_display.py) + +### Additional TODOs from Plan +- Retrieve full movie details from TMDB (future) +- Expand genres to full names instead of codes (future) +- Optimize poster quality and display (future) \ No newline at end of file diff --git a/dist/renamer-0.5.1-py3-none-any.whl b/dist/renamer-0.5.1-py3-none-any.whl new file mode 100644 index 0000000000000000000000000000000000000000..9ab4d3ea3e996693add2bc421cf1d9d535fc06d0 GIT binary patch literal 46589 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.0.0", "langcodes>=3.5.1", "requests>=2.31.0", + "rich-pixels>=1.0.0", ] [project.scripts] diff --git a/renamer/app.py b/renamer/app.py index 7793119..b8d48d7 100644 --- a/renamer/app.py +++ b/renamer/app.py @@ -1,6 +1,7 @@ from textual.app import App, ComposeResult from textual.widgets import Tree, Static, Footer, LoadingIndicator from textual.containers import Horizontal, Container, ScrollableContainer, Vertical +from textual.widget import Widget from rich.markup import escape from pathlib import Path import threading @@ -9,11 +10,14 @@ import logging import os from .constants import MEDIA_TYPES -from .screens import OpenScreen, HelpScreen, RenameConfirmScreen +from .screens import OpenScreen, HelpScreen, RenameConfirmScreen, SettingsScreen from .extractors.extractor import MediaExtractor from .formatters.media_formatter import MediaFormatter from .formatters.proposed_name_formatter import ProposedNameFormatter from .formatters.text_formatter import TextFormatter +from .formatters.catalog_formatter import CatalogFormatter +from .settings import Settings +from .cache import Cache # Set up logging conditionally @@ -43,13 +47,17 @@ class RenamerApp(App): ("f", "refresh", "Refresh"), ("r", "rename", "Rename"), ("p", "expand", "Toggle Tree"), + ("m", "toggle_mode", "Toggle Mode"), ("h", "help", "Help"), + ("ctrl+s", "settings", "Settings"), ] def __init__(self, scan_dir): super().__init__() self.scan_dir = Path(scan_dir) if scan_dir else None self.tree_expanded = False + self.settings = Settings() + self.cache = Cache() def compose(self) -> ComposeResult: with Horizontal(): @@ -60,7 +68,10 @@ class RenamerApp(App): yield LoadingIndicator(id="loading") with ScrollableContainer(id="details_container"): yield Static( - "Select a file to view details", id="details", markup=True + "Select a file to view details", id="details_technical", markup=True + ) + yield Static( + "", id="details_catalog", markup=False ) yield Static("", id="proposed", markup=True) yield Footer() @@ -73,7 +84,7 @@ class RenamerApp(App): def scan_files(self): logging.info("scan_files called") if not self.scan_dir or not self.scan_dir.exists() or not self.scan_dir.is_dir(): - details = self.query_one("#details", Static) + details = self.query_one("#details_technical", Static) details.update("Error: Directory does not exist or is not a directory") return tree = self.query_one("#file_tree", Tree) @@ -105,7 +116,11 @@ class RenamerApp(App): def _start_loading_animation(self): loading = self.query_one("#loading", LoadingIndicator) loading.display = True - details = self.query_one("#details", Static) + mode = self.settings.get("mode") + if mode == "technical": + details = self.query_one("#details_technical", Static) + else: + details = self.query_one("#details_catalog", Static) details.update("Retrieving media data") proposed = self.query_one("#proposed", Static) proposed.update("") @@ -119,7 +134,10 @@ class RenamerApp(App): if node.data and isinstance(node.data, Path): if node.data.is_dir(): self._stop_loading_animation() - details = self.query_one("#details", Static) + details = self.query_one("#details_technical", Static) + details.display = True + details_catalog = self.query_one("#details_catalog", Static) + details_catalog.display = False details.update("Directory") proposed = self.query_one("#proposed", Static) proposed.update("") @@ -133,12 +151,20 @@ class RenamerApp(App): time.sleep(1) # Minimum delay to show loading try: # Initialize extractors and formatters - extractor = MediaExtractor(file_path) - + extractor = MediaExtractor.create(file_path, self.cache, self.settings.get("cache_ttl_extractors")) + + mode = self.settings.get("mode") + if mode == "technical": + formatter = MediaFormatter(extractor) + full_info = formatter.file_info_panel() + else: # catalog + formatter = CatalogFormatter(extractor) + full_info = formatter.format_catalog_info() + # Update UI self.call_later( self._update_details, - MediaFormatter(extractor).file_info_panel(), + full_info, ProposedNameFormatter(extractor).rename_line_formatted(file_path), ) except Exception as e: @@ -150,9 +176,18 @@ class RenamerApp(App): def _update_details(self, full_info: str, display_string: str): self._stop_loading_animation() - details = self.query_one("#details", Static) - details.update(full_info) - + details_technical = self.query_one("#details_technical", Static) + details_catalog = self.query_one("#details_catalog", Static) + mode = self.settings.get("mode") + if mode == "technical": + details_technical.display = True + details_catalog.display = False + details_technical.update(full_info) + else: + details_technical.display = False + details_catalog.display = True + details_catalog.update(full_info) + proposed = self.query_one("#proposed", Static) proposed.update(display_string) @@ -170,6 +205,11 @@ class RenamerApp(App): tree = self.query_one("#file_tree", Tree) node = tree.cursor_node if node and node.data and isinstance(node.data, Path) and node.data.is_file(): + # Clear cache for this file + cache_key_base = str(node.data) + # Invalidate all keys for this file (we can improve this later) + for key in ["title", "year", "source", "extension", "video_tracks", "audio_tracks", "subtitle_tracks"]: + self.cache.invalidate(f"{cache_key_base}_{key}") self._start_loading_animation() threading.Thread( target=self._extract_and_show_details, args=(node.data,) @@ -178,12 +218,29 @@ class RenamerApp(App): async def action_help(self): self.push_screen(HelpScreen()) + async def action_settings(self): + self.push_screen(SettingsScreen()) + + async def action_toggle_mode(self): + current_mode = self.settings.get("mode") + new_mode = "catalog" if current_mode == "technical" else "technical" + self.settings.set("mode", new_mode) + self.notify(f"Switched to {new_mode} mode", severity="information", timeout=2) + # Refresh current file display if any + tree = self.query_one("#file_tree", Tree) + node = tree.cursor_node + if node and node.data and isinstance(node.data, Path) and node.data.is_file(): + self._start_loading_animation() + threading.Thread( + target=self._extract_and_show_details, args=(node.data,) + ).start() + async def action_rename(self): tree = self.query_one("#file_tree", Tree) node = tree.cursor_node if node and node.data and isinstance(node.data, Path) and node.data.is_file(): # Get the proposed name from the extractor - extractor = MediaExtractor(node.data) + extractor = MediaExtractor.create(node.data, self.cache, self.settings.get("cache_ttl_extractors")) proposed_formatter = ProposedNameFormatter(extractor) new_name = str(proposed_formatter) logging.info(f"Proposed new name: {new_name!r} for file: {node.data}") @@ -216,6 +273,11 @@ class RenamerApp(App): """Update the tree node for a renamed file.""" logging.info(f"update_renamed_file called with old_path={old_path}, new_path={new_path}") + # Clear cache for old file + cache_key_base = str(old_path) + for key in ["title", "year", "source", "extension", "video_tracks", "audio_tracks", "subtitle_tracks"]: + self.cache.invalidate(f"{cache_key_base}_{key}") + tree = self.query_one("#file_tree", Tree) logging.info(f"Before update: cursor_node.data = {tree.cursor_node.data if tree.cursor_node else None}") diff --git a/renamer/cache.py b/renamer/cache.py new file mode 100644 index 0000000..11368be --- /dev/null +++ b/renamer/cache.py @@ -0,0 +1,187 @@ +import json +import os +import time +import hashlib +import pickle +from pathlib import Path +from typing import Any, Optional + + +class Cache: + """File-based cache with TTL support.""" + + def __init__(self, cache_dir: Optional[Path] = None): + if cache_dir is None: + cache_dir = Path.home() / ".cache" / "renamer" + self.cache_dir = cache_dir + self.cache_dir.mkdir(parents=True, exist_ok=True) + + def _get_cache_file(self, key: str) -> Path: + """Get cache file path with hashed filename and subdirs.""" + # Parse key format: ClassName.method_name.param_hash + if '.' in key: + parts = key.split('.') + if len(parts) >= 3: + class_name = parts[0] + method_name = parts[1] + param_hash = parts[2] + + # Use class name as subdir + cache_subdir = self.cache_dir / class_name + cache_subdir.mkdir(parents=True, exist_ok=True) + + # Use method_name.param_hash as filename + return cache_subdir / f"{method_name}.{param_hash}.pkl" + + # Fallback for old keys (tmdb_, poster_, etc.) + if key.startswith("tmdb_"): + subdir = "tmdb" + subkey = key[5:] # Remove "tmdb_" prefix + elif key.startswith("poster_"): + subdir = "posters" + subkey = key[7:] # Remove "poster_" prefix + else: + subdir = "general" + subkey = key + + # Create subdir + cache_subdir = self.cache_dir / subdir + cache_subdir.mkdir(parents=True, exist_ok=True) + + # Hash the subkey for filename + key_hash = hashlib.md5(subkey.encode('utf-8')).hexdigest() + return cache_subdir / f"{key_hash}.json" + + def get(self, key: str) -> Optional[Any]: + """Get cached value if not expired.""" + cache_file = self._get_cache_file(key) + if not cache_file.exists(): + return None + + try: + with open(cache_file, 'r') as f: + data = json.load(f) + + if time.time() > data.get('expires', 0): + # Expired, remove file + cache_file.unlink(missing_ok=True) + return None + + return data.get('value') + except (json.JSONDecodeError, IOError): + # Corrupted, remove + cache_file.unlink(missing_ok=True) + return None + + def set(self, key: str, value: Any, ttl_seconds: int) -> None: + """Set cached value with TTL.""" + cache_file = self._get_cache_file(key) + data = { + 'value': value, + 'expires': time.time() + ttl_seconds + } + try: + with open(cache_file, 'w') as f: + json.dump(data, f) + except IOError: + pass # Silently fail + + def invalidate(self, key: str) -> None: + """Remove cache entry.""" + cache_file = self._get_cache_file(key) + cache_file.unlink(missing_ok=True) + + def get_image(self, key: str) -> Optional[Path]: + """Get cached image path if not expired.""" + cache_file = self._get_cache_file(key) + if not cache_file.exists(): + return None + + try: + with open(cache_file, 'r') as f: + data = json.load(f) + + if time.time() > data.get('expires', 0): + # Expired, remove file and image + image_path = data.get('image_path') + if image_path and Path(image_path).exists(): + Path(image_path).unlink(missing_ok=True) + cache_file.unlink(missing_ok=True) + return None + + image_path = data.get('image_path') + if image_path and Path(image_path).exists(): + return Path(image_path) + return None + except (json.JSONDecodeError, IOError): + cache_file.unlink(missing_ok=True) + return None + + def set_image(self, key: str, image_data: bytes, ttl_seconds: int) -> Optional[Path]: + """Set cached image and return path.""" + # Determine subdir and subkey + if key.startswith("poster_"): + subdir = "posters" + subkey = key[7:] + else: + subdir = "images" + subkey = key + + # Create subdir + image_dir = self.cache_dir / subdir + image_dir.mkdir(parents=True, exist_ok=True) + + # Hash for filename + key_hash = hashlib.md5(subkey.encode('utf-8')).hexdigest() + image_path = image_dir / f"{key_hash}.jpg" + + try: + with open(image_path, 'wb') as f: + f.write(image_data) + + # Cache metadata + data = { + 'image_path': str(image_path), + 'expires': time.time() + ttl_seconds + } + cache_file = self._get_cache_file(key) + with open(cache_file, 'w') as f: + json.dump(data, f) + + return image_path + except IOError: + return None + + def get_object(self, key: str) -> Optional[Any]: + """Get pickled object from cache if not expired.""" + cache_file = self._get_cache_file(key) + if not cache_file.exists(): + return None + + try: + with open(cache_file, 'rb') as f: + data = pickle.load(f) + + if time.time() > data.get('expires', 0): + # Expired, remove file + cache_file.unlink(missing_ok=True) + return None + + return data.get('value') + except (pickle.PickleError, IOError): + # Corrupted, remove + cache_file.unlink(missing_ok=True) + return None + + def set_object(self, key: str, obj: Any, ttl_seconds: int) -> None: + """Pickle and cache object with TTL.""" + cache_file = self._get_cache_file(key) + data = { + 'value': obj, + 'expires': time.time() + ttl_seconds + } + try: + with open(cache_file, 'wb') as f: + pickle.dump(data, f) + except IOError: + pass # Silently fail \ No newline at end of file diff --git a/renamer/constants.py b/renamer/constants.py index 95df75c..93f236f 100644 --- a/renamer/constants.py +++ b/renamer/constants.py @@ -47,6 +47,7 @@ SOURCE_DICT = { "DVDRip": ["DVDRip", "DVD-Rip", "DVDRIP"], "HDTVRip": ["HDTVRip", "HDTV"], "BluRay": ["BluRay", "BLURAY", "Blu-ray"], + "SATRip": ["SATRip", "SAT-Rip", "SATRIP"], "VHSRecord": [ "VHSRecord", "VHS Record", @@ -69,6 +70,11 @@ FRAME_CLASSES = { "typical_widths": [640, 704, 720], "description": "Standard Definition (SD) interlaced - NTSC quality", }, + "360p": { + "nominal_height": 360, + "typical_widths": [480, 640], + "description": "Low Definition (LD) - 360p", + }, "576p": { "nominal_height": 576, "typical_widths": [720, 768], diff --git a/renamer/decorators/__init__.py b/renamer/decorators/__init__.py new file mode 100644 index 0000000..370c635 --- /dev/null +++ b/renamer/decorators/__init__.py @@ -0,0 +1,4 @@ +# Decorators package +from .caching import cached_method + +__all__ = ['cached_method'] \ No newline at end of file diff --git a/renamer/decorators/caching.py b/renamer/decorators/caching.py new file mode 100644 index 0000000..c28967a --- /dev/null +++ b/renamer/decorators/caching.py @@ -0,0 +1,49 @@ +"""Caching decorators for extractors.""" + +import hashlib +import json +from pathlib import Path +from typing import Any, Callable, Optional +from renamer.cache import Cache + + +# Global cache instance +_cache = Cache() + + +def cached_method(ttl_seconds: int = 3600) -> Callable: + """Decorator to cache method results with TTL. + + Caches the result of a method call using a global file-based cache. + The cache key includes class name, method name, and parameters hash. + + Args: + ttl_seconds: Time to live for cached results in seconds (default 1 hour) + + Returns: + The decorated method with caching + """ + def decorator(func: Callable) -> Callable: + def wrapper(self, *args, **kwargs) -> Any: + # Generate cache key: class_name.method_name.param_hash + class_name = self.__class__.__name__ + method_name = func.__name__ + + # Create hash from args and kwargs + param_str = json.dumps((args, kwargs), sort_keys=True, default=str) + param_hash = hashlib.md5(param_str.encode('utf-8')).hexdigest() + + cache_key = f"{class_name}.{method_name}.{param_hash}" + + # Try to get from cache + cached_result = _cache.get_object(cache_key) + if cached_result is not None: + return cached_result + + # Compute result and cache it + result = func(self, *args, **kwargs) + _cache.set_object(cache_key, result, ttl_seconds) + return result + + return wrapper + return decorator \ No newline at end of file diff --git a/renamer/extractors/extractor.py b/renamer/extractors/extractor.py index 05d10d7..3cd14ab 100644 --- a/renamer/extractors/extractor.py +++ b/renamer/extractors/extractor.py @@ -10,14 +10,40 @@ from .default_extractor import DefaultExtractor class MediaExtractor: """Class to extract various metadata from media files using specialized extractors""" - def __init__(self, file_path: Path): + @classmethod + def create(cls, file_path: Path, cache=None, ttl_seconds: int = 21600): + """Factory method that returns cached object if available, else creates new.""" + if cache: + cache_key = f"extractor_{file_path}" + cached_obj = cache.get_object(cache_key) + if cached_obj: + print(f"Loaded MediaExtractor object from cache for {file_path.name}") + return cached_obj + + # Create new instance + instance = cls(file_path, cache, ttl_seconds) + + # Cache the object + if cache: + cache_key = f"extractor_{file_path}" + cache.set_object(cache_key, instance, ttl_seconds) + print(f"Cached MediaExtractor object for {file_path.name}") + + return instance + + def __init__(self, file_path: Path, cache=None, ttl_seconds: int = 21600): + self.file_path = file_path + self.cache = cache + self.ttl_seconds = ttl_seconds + self.cache_key = f"file_data_{file_path}" + self.filename_extractor = FilenameExtractor(file_path) self.metadata_extractor = MetadataExtractor(file_path) self.mediainfo_extractor = MediaInfoExtractor(file_path) self.fileinfo_extractor = FileInfoExtractor(file_path) - self.tmdb_extractor = TMDBExtractor(file_path) + self.tmdb_extractor = TMDBExtractor(file_path, cache, ttl_seconds) self.default_extractor = DefaultExtractor() - + # Extractor mapping self._extractors = { "Metadata": self.metadata_extractor, @@ -164,9 +190,16 @@ class MediaExtractor: ], }, } - + + # No caching logic here - handled in create() method + def get(self, key: str, source: str | None = None): """Get extracted data by key, optionally from specific source""" + print(f"Extracting real data for key '{key}' in {self.file_path.name}") + return self._get_uncached(key, source) + + def _get_uncached(self, key: str, source: str | None = None): + """Original get logic without caching""" if source: # Specific source requested - find the extractor and call the method directly for extractor_name, extractor in self._extractors.items(): @@ -174,27 +207,20 @@ class MediaExtractor: method = f"extract_{key}" if hasattr(extractor, method): val = getattr(extractor, method)() - # Apply condition if specified - if key in self._data and "condition" in self._data[key]: - condition = self._data[key]["condition"] - return val if condition(val) else None - return val + return val if val is not None else None return None # Fallback mode - try sources in order if key in self._data: - data = self._data[key] - sources = data["sources"] - condition = data.get("condition", lambda x: x is not None) + sources = self._data[key]["sources"] else: # Try extractors in order for unconfigured keys sources = [(name, f"extract_{key}") for name in ["MediaInfo", "Metadata", "Filename", "FileInfo"]] - condition = lambda x: x is not None # Try each source in order until a valid value is found for src, method in sources: if src in self._extractors and hasattr(self._extractors[src], method): val = getattr(self._extractors[src], method)() - if condition(val): + if val is not None: return val return None diff --git a/renamer/extractors/fileinfo_extractor.py b/renamer/extractors/fileinfo_extractor.py index e93f3cc..9626942 100644 --- a/renamer/extractors/fileinfo_extractor.py +++ b/renamer/extractors/fileinfo_extractor.py @@ -1,6 +1,7 @@ from pathlib import Path import logging import os +from ..decorators import cached_method # Set up logging conditionally if os.getenv('FORMATTER_LOG', '0') == '1': @@ -19,24 +20,30 @@ class FileInfoExtractor: self._modification_time = file_path.stat().st_mtime self._file_name = file_path.name self._file_path = str(file_path) + self._cache = {} # Internal cache for method results logging.info(f"FileInfoExtractor: file_name={self._file_name!r}, file_path={self._file_path!r}") + @cached_method() def extract_size(self) -> int: """Extract file size in bytes""" return self._size + @cached_method() def extract_modification_time(self) -> float: """Extract file modification time""" return self._modification_time + @cached_method() def extract_file_name(self) -> str: """Extract file name""" return self._file_name + @cached_method() def extract_file_path(self) -> str: """Extract full file path as string""" return self._file_path + @cached_method() def extract_extension(self) -> str: """Extract file extension without the dot""" return self.file_path.suffix.lower().lstrip('.') \ No newline at end of file diff --git a/renamer/extractors/filename_extractor.py b/renamer/extractors/filename_extractor.py index f0d2116..7ec554c 100644 --- a/renamer/extractors/filename_extractor.py +++ b/renamer/extractors/filename_extractor.py @@ -2,6 +2,7 @@ import re from pathlib import Path from collections import Counter from ..constants import SOURCE_DICT, FRAME_CLASSES, MOVIE_DB_DICT, SPECIAL_EDITIONS +from ..decorators import cached_method import langcodes @@ -34,6 +35,7 @@ class FilenameExtractor: return frame_class return None + @cached_method() def extract_title(self) -> str | None: """Extract movie title from filename""" # Find positions of year, source, and quality brackets @@ -120,6 +122,7 @@ class FilenameExtractor: return title if title else None + @cached_method() def extract_year(self) -> str | None: """Extract year from filename""" # First try to find year in parentheses (most common and reliable) @@ -144,6 +147,7 @@ class FilenameExtractor: return None + @cached_method() def extract_source(self) -> str | None: """Extract video source from filename""" temp_name = re.sub(r'\s*\(\d{4}\)\s*|\s*\d{4}\s*|\.\d{4}\.', ' ', self.file_name) @@ -154,6 +158,7 @@ class FilenameExtractor: return src return None + @cached_method() def extract_order(self) -> str | None: """Extract collection order number from filename (at the beginning)""" # Look for order patterns at the start of filename @@ -176,6 +181,7 @@ class FilenameExtractor: return None + @cached_method() def extract_frame_class(self) -> str | None: """Extract frame class from filename (480p, 720p, 1080p, 2160p, etc.)""" # Normalize Cyrillic characters for resolution parsing @@ -200,6 +206,7 @@ class FilenameExtractor: return None + @cached_method() def extract_hdr(self) -> str | None: """Extract HDR information from filename""" # Check for SDR first - indicates no HDR @@ -212,6 +219,7 @@ class FilenameExtractor: return None + @cached_method() def extract_movie_db(self) -> list[str] | None: """Extract movie database identifier from filename""" # Look for patterns at the end of filename in brackets or braces @@ -233,6 +241,7 @@ class FilenameExtractor: return None + @cached_method() def extract_special_info(self) -> list[str] | None: """Extract special edition information from filename""" # Look for special edition indicators in brackets or as standalone text @@ -258,6 +267,7 @@ class FilenameExtractor: return special_info if special_info else None + @cached_method() def extract_audio_langs(self) -> str: """Extract audio languages from filename""" # Look for language patterns in brackets and outside brackets @@ -389,6 +399,7 @@ class FilenameExtractor: audio_langs = [f"{count}{lang}" if count > 1 else lang for lang, count in lang_counts.items()] return ','.join(audio_langs) + @cached_method() def extract_audio_tracks(self) -> list[dict]: """Extract audio track data from filename (simplified version with only language)""" # Similar to extract_audio_langs but returns list of dicts diff --git a/renamer/extractors/mediainfo_extractor.py b/renamer/extractors/mediainfo_extractor.py index 79b62a6..1b7b5fb 100644 --- a/renamer/extractors/mediainfo_extractor.py +++ b/renamer/extractors/mediainfo_extractor.py @@ -2,6 +2,7 @@ from pathlib import Path from pymediainfo import MediaInfo from collections import Counter from ..constants import FRAME_CLASSES, MEDIA_TYPES +from ..decorators import cached_method import langcodes @@ -10,6 +11,7 @@ class MediaInfoExtractor: def __init__(self, file_path: Path): self.file_path = file_path + self._cache = {} # Internal cache for method results try: self.media_info = MediaInfo.parse(file_path) self.video_tracks = [t for t in self.media_info.tracks if t.track_type == 'Video'] @@ -54,6 +56,7 @@ class MediaInfoExtractor: return closest return None + @cached_method() def extract_duration(self) -> float | None: """Extract duration from media info in seconds""" if self.media_info: @@ -62,6 +65,7 @@ class MediaInfoExtractor: return getattr(track, 'duration', 0) / 1000 if getattr(track, 'duration', None) else None return None + @cached_method() def extract_frame_class(self) -> str | None: """Extract frame class from media info (480p, 720p, 1080p, etc.)""" if not self.video_tracks: @@ -106,6 +110,7 @@ class MediaInfoExtractor: return f"{closest_height}{scan_type}" return None + @cached_method() def extract_resolution(self) -> tuple[int, int] | None: """Extract actual video resolution as (width, height) tuple from media info""" if not self.video_tracks: @@ -116,6 +121,7 @@ class MediaInfoExtractor: return width, height return None + @cached_method() def extract_aspect_ratio(self) -> str | None: """Extract video aspect ratio from media info""" if not self.video_tracks: @@ -125,6 +131,7 @@ class MediaInfoExtractor: return str(aspect_ratio) return None + @cached_method() def extract_hdr(self) -> str | None: """Extract HDR info from media info""" if not self.video_tracks: @@ -134,6 +141,7 @@ class MediaInfoExtractor: return 'HDR' return None + @cached_method() def extract_audio_langs(self) -> str | None: """Extract audio languages from media info""" if not self.audio_tracks: @@ -154,6 +162,7 @@ class MediaInfoExtractor: audio_langs = [f"{count}{lang}" if count > 1 else lang for lang, count in lang_counts.items()] return ','.join(audio_langs) + @cached_method() def extract_video_tracks(self) -> list[dict]: """Extract video track data""" tracks = [] @@ -169,6 +178,7 @@ class MediaInfoExtractor: tracks.append(track_data) return tracks + @cached_method() def extract_audio_tracks(self) -> list[dict]: """Extract audio track data""" tracks = [] @@ -182,6 +192,7 @@ class MediaInfoExtractor: tracks.append(track_data) return tracks + @cached_method() def extract_subtitle_tracks(self) -> list[dict]: """Extract subtitle track data""" tracks = [] @@ -193,6 +204,7 @@ class MediaInfoExtractor: tracks.append(track_data) return tracks + @cached_method() def is_3d(self) -> bool: """Check if the video is 3D""" if not self.video_tracks: @@ -205,6 +217,7 @@ class MediaInfoExtractor: return True return False + @cached_method() def extract_anamorphic(self) -> str | None: """Extract anamorphic info for 3D videos""" if not self.video_tracks: @@ -214,6 +227,7 @@ class MediaInfoExtractor: return 'Anamorphic:Yes' return None + @cached_method() def extract_extension(self) -> str | None: """Extract file extension based on container format""" if not self.media_info: @@ -233,6 +247,7 @@ class MediaInfoExtractor: return exts[0] if exts else None return None + @cached_method() def extract_3d_layout(self) -> str | None: """Extract 3D stereoscopic layout from MediaInfo""" if not self.is_3d(): diff --git a/renamer/extractors/metadata_extractor.py b/renamer/extractors/metadata_extractor.py index 20498d2..a2c1f00 100644 --- a/renamer/extractors/metadata_extractor.py +++ b/renamer/extractors/metadata_extractor.py @@ -1,6 +1,7 @@ import mutagen from pathlib import Path from ..constants import MEDIA_TYPES +from ..decorators import cached_method class MetadataExtractor: @@ -8,36 +9,40 @@ class MetadataExtractor: def __init__(self, file_path: Path): self.file_path = file_path + self._cache = {} # Internal cache for method results try: self.info = mutagen.File(file_path) # type: ignore except Exception: self.info = None + @cached_method() def extract_title(self) -> str | None: """Extract title from metadata""" if self.info: return getattr(self.info, 'title', None) or getattr(self.info, 'get', lambda x, default=None: default)('title', [None])[0] # type: ignore return None + @cached_method() def extract_duration(self) -> float | None: """Extract duration from metadata""" if self.info: return getattr(self.info, 'length', None) return None + @cached_method() def extract_artist(self) -> str | None: """Extract artist from metadata""" if self.info: return getattr(self.info, 'artist', None) or getattr(self.info, 'get', lambda x, default=None: default)('artist', [None])[0] # type: ignore return None + @cached_method() def extract_meta_type(self) -> str: """Extract meta type from metadata""" if self.info: return type(self.info).__name__ return self._detect_by_mime() - def _detect_by_mime(self) -> str: """Detect meta type by MIME""" try: diff --git a/renamer/extractors/tmdb_extractor.py b/renamer/extractors/tmdb_extractor.py index 85f7f6d..efe1d66 100644 --- a/renamer/extractors/tmdb_extractor.py +++ b/renamer/extractors/tmdb_extractor.py @@ -11,53 +11,22 @@ from ..secrets import TMDB_API_KEY, TMDB_ACCESS_TOKEN class TMDBExtractor: """Class to extract TMDB movie information""" - CACHE_DIR = Path.home() / ".cache" / "renamer" / "tmdb" - CACHE_DURATION = 5 * 24 * 60 * 60 # 5 days in seconds - - def __init__(self, file_path: Path): + def __init__(self, file_path: Path, cache=None, ttl_seconds: int = 21600): self.file_path = file_path + self.cache = cache + self.ttl_seconds = ttl_seconds self._movie_db_info = None - def _get_cache_file_path(self, cache_key: str) -> Path: - """Get the cache file path for a given cache key""" - # Create a hash of the cache key for the filename - key_hash = hashlib.md5(cache_key.encode('utf-8')).hexdigest() - return self.CACHE_DIR / f"{key_hash}.json" - - def _is_cache_valid(self, cache_key: str) -> bool: - """Check if cache entry is still valid""" - cache_file = self._get_cache_file_path(cache_key) - if not cache_file.exists(): - return False - - try: - # Check file modification time - stat = cache_file.stat() - return time.time() - stat.st_mtime < self.CACHE_DURATION - except OSError: - return False - def _get_cached_data(self, cache_key: str) -> Optional[Dict[str, Any]]: """Get data from cache if valid""" - if not self._is_cache_valid(cache_key): - return None - - cache_file = self._get_cache_file_path(cache_key) - try: - with open(cache_file, 'r', encoding='utf-8') as f: - return json.load(f) - except (json.JSONDecodeError, OSError): - return None + if self.cache: + return self.cache.get(f"tmdb_{cache_key}") + return None def _set_cached_data(self, cache_key: str, data: Dict[str, Any]): """Store data in cache""" - try: - self.CACHE_DIR.mkdir(parents=True, exist_ok=True) - cache_file = self._get_cache_file_path(cache_key) - with open(cache_file, 'w', encoding='utf-8') as f: - json.dump(data, f, indent=2, ensure_ascii=False) - except OSError: - pass # Silently fail if we can't save cache + if self.cache: + self.cache.set(f"tmdb_{cache_key}", data, self.ttl_seconds) def _make_tmdb_request(self, endpoint: str, params: Optional[Dict[str, Any]] = None) -> Optional[Dict[str, Any]]: """Make a request to TMDB API""" @@ -230,9 +199,70 @@ class TMDBExtractor: return f"https://www.themoviedb.org/movie/{movie_id}" return None - def extract_movie_db(self) -> Optional[Tuple[str, str]]: - """Extract TMDB database info as (name, id) tuple""" - movie_id = self.extract_tmdb_id() - if movie_id: - return ("tmdb", movie_id) + def extract_duration(self) -> Optional[str]: + """Extract TMDB runtime in minutes""" + movie_info = self._get_movie_info() + if movie_info and movie_info.get('runtime'): + return str(movie_info['runtime']) return None + + def extract_popularity(self) -> Optional[str]: + """Extract TMDB popularity""" + movie_info = self._get_movie_info() + if movie_info: + return str(movie_info.get('popularity', '')) + return None + + def extract_vote_average(self) -> Optional[str]: + """Extract TMDB vote average""" + movie_info = self._get_movie_info() + if movie_info: + return str(movie_info.get('vote_average', '')) + return None + + def extract_overview(self) -> Optional[str]: + """Extract TMDB overview""" + movie_info = self._get_movie_info() + if movie_info: + return movie_info.get('overview') + return None + + def extract_genres(self) -> Optional[str]: + """Extract TMDB genres as codes""" + movie_info = self._get_movie_info() + if movie_info and movie_info.get('genres'): + return ', '.join(genre['name'] for genre in movie_info['genres']) + return None + + def extract_poster_path(self) -> Optional[str]: + """Extract TMDB poster path""" + movie_info = self._get_movie_info() + if movie_info: + return movie_info.get('poster_path') + return None + + def extract_poster_image_path(self) -> Optional[str]: + """Download and cache poster image, return local path""" + poster_path = self.extract_poster_path() + if not poster_path or not self.cache: + return None + + cache_key = f"poster_{poster_path}" + cached_path = self.cache.get_image(cache_key) + if cached_path: + return str(cached_path) + + # Download poster + base_url = "https://image.tmdb.org/t/p/w500" # Medium size + url = f"{base_url}{poster_path}" + + try: + response = requests.get(url, timeout=10) + response.raise_for_status() + image_data = response.content + + # Cache image + local_path = self.cache.set_image(cache_key, image_data, self.ttl_seconds) + return str(local_path) if local_path else None + except requests.RequestException: + return None diff --git a/renamer/formatters/catalog_formatter.py b/renamer/formatters/catalog_formatter.py new file mode 100644 index 0000000..3939850 --- /dev/null +++ b/renamer/formatters/catalog_formatter.py @@ -0,0 +1,107 @@ +from .text_formatter import TextFormatter +import os + + +class CatalogFormatter: + """Formatter for catalog mode display""" + + def __init__(self, extractor): + self.extractor = extractor + + def format_catalog_info(self) -> str: + """Format catalog information for display""" + lines = [] + + # Title + title = self.extractor.get("title", "TMDB") + if title: + lines.append(f"{TextFormatter.bold('Title:')} {title}") + + # Year + year = self.extractor.get("year", "TMDB") + if year: + lines.append(f"{TextFormatter.bold('Year:')} {year}") + + # Duration + duration = self.extractor.get("duration", "TMDB") + if duration: + lines.append(f"{TextFormatter.bold('Duration:')} {duration} minutes") + + # Rates + popularity = self.extractor.get("popularity", "TMDB") + vote_average = self.extractor.get("vote_average", "TMDB") + if popularity or vote_average: + rates = [] + if popularity: + rates.append(f"Popularity: {popularity}") + if vote_average: + rates.append(f"Rating: {vote_average}/10") + lines.append(f"{TextFormatter.bold('Rates:')} {', '.join(rates)}") + + # Overview + overview = self.extractor.get("overview", "TMDB") + if overview: + lines.append(f"{TextFormatter.bold('Overview:')}") + lines.append(overview) + + # Genres + genres = self.extractor.get("genres", "TMDB") + if genres: + lines.append(f"{TextFormatter.bold('Genres:')} {genres}") + + # Poster + poster_image_path = self.extractor.tmdb_extractor.extract_poster_image_path() + if poster_image_path: + lines.append(f"{TextFormatter.bold('Poster:')}") + lines.append(self._display_poster(poster_image_path)) + else: + poster_path = self.extractor.get("poster_path", "TMDB") + if poster_path: + lines.append(f"{TextFormatter.bold('Poster:')} {poster_path} (not cached yet)") + + full_text = "\n\n".join(lines) if lines else "No catalog information available" + + # Render markup to ANSI + from rich.console import Console + from io import StringIO + console = Console(file=StringIO(), width=120, legacy_windows=False) + console.print(full_text, markup=True) + return console.file.getvalue() + + def _display_poster(self, image_path: str) -> str: + """Display poster image in terminal using simple ASCII art""" + try: + from PIL import Image + import os + + if not os.path.exists(image_path): + return f"Image file not found: {image_path}" + + # Open and resize image + img = Image.open(image_path).convert('L').resize((80, 40), Image.Resampling.LANCZOS) + + # ASCII characters from dark to light + ascii_chars = '@%#*+=-:. ' + + # Convert to ASCII + pixels = img.getdata() + width, height = img.size + + ascii_art = [] + for y in range(0, height, 2): # Skip every other row for aspect ratio + row = [] + for x in range(width): + # Average of two rows for better aspect + pixel1 = pixels[y * width + x] if y < height else 255 + pixel2 = pixels[(y + 1) * width + x] if y + 1 < height else 255 + avg = (pixel1 + pixel2) // 2 + char = ascii_chars[avg * len(ascii_chars) // 256] + row.append(char) + ascii_art.append(''.join(row)) + + return '\n'.join(ascii_art) + + except ImportError: + return f"Image at {image_path} (PIL not available)" + except Exception as e: + return f"Failed to display image at {image_path}: {e}" \ No newline at end of file diff --git a/renamer/formatters/formatter.py b/renamer/formatters/formatter.py index 9cece64..f719991 100644 --- a/renamer/formatters/formatter.py +++ b/renamer/formatters/formatter.py @@ -35,7 +35,6 @@ class FormatterApplier: DateFormatter.format_year, ExtensionFormatter.format_extension_info, ResolutionFormatter.get_frame_class_from_resolution, - ResolutionFormatter.format_resolution_p, ResolutionFormatter.format_resolution_dimensions, TrackFormatter.format_video_track, TrackFormatter.format_audio_track, diff --git a/renamer/formatters/resolution_formatter.py b/renamer/formatters/resolution_formatter.py index ead706c..cdf401d 100644 --- a/renamer/formatters/resolution_formatter.py +++ b/renamer/formatters/resolution_formatter.py @@ -1,3 +1,5 @@ +from renamer.constants import FRAME_CLASSES + class ResolutionFormatter: """Class for formatting video resolutions and frame classes""" @@ -20,39 +22,20 @@ class ResolutionFormatter: else: return 'Unclassified' - if height == 4320: - return '4320p' - elif height >= 2160: - return '2160p' - elif height >= 1440: - return '1440p' - elif height >= 1080: - return '1080p' - elif height >= 720: - return '720p' - elif height >= 576: - return '576p' - elif height >= 480: - return '480p' - else: - return 'Unclassified' + # Find the closest frame class based on nominal height + closest_class = 'Unclassified' + min_diff = float('inf') + for frame_class, info in FRAME_CLASSES.items(): + nominal_height = info['nominal_height'] + diff = abs(height - nominal_height) + if diff < min_diff: + min_diff = diff + closest_class = frame_class + + return closest_class except (ValueError, IndexError): return 'Unclassified' - @staticmethod - def format_resolution_p(height: int) -> str: - """Format resolution as 2160p, 1080p, etc.""" - if height >= 2160: - return '2160p' - elif height >= 1080: - return '1080p' - elif height >= 720: - return '720p' - elif height >= 480: - return '480p' - else: - return f'{height}p' - @staticmethod def format_resolution_dimensions(resolution: tuple[int, int]) -> str: """Format resolution as WIDTHxHEIGHT""" diff --git a/renamer/screens.py b/renamer/screens.py index 2819fec..8ce06e5 100644 --- a/renamer/screens.py +++ b/renamer/screens.py @@ -58,6 +58,8 @@ ACTIONS: • f: Refresh - Reload metadata for selected file • r: Rename - Rename selected file with proposed name • p: Expand/Collapse - Toggle expansion of selected directory +• m: Toggle Mode - Switch between technical and catalog display modes +• ctrl+s: Settings - Open settings window • h: Help - Show this help screen • q: Quit - Exit the application @@ -237,4 +239,92 @@ Do you want to proceed with renaming? content.update(f"Error renaming file: {str(e)}") elif event.key == "n": # Cancel - self.app.pop_screen() \ No newline at end of file + self.app.pop_screen() + + +class SettingsScreen(Screen): + CSS = """ + #settings_content { + text-align: center; + } + Button:focus { + background: $primary; + } + #buttons { + align: center middle; + } + .input_field { + width: 100%; + margin: 1 0; + } + .label { + text-align: left; + margin-bottom: 0; + } + """ + + def compose(self): + from .formatters.text_formatter import TextFormatter + + settings = self.app.settings # type: ignore + + content = f""" +{TextFormatter.bold("SETTINGS")} + +Configure application settings. + """.strip() + + with Center(): + with Vertical(): + yield Static(content, id="settings_content", markup=True) + + # Mode selection + yield Static("Display Mode:", classes="label") + with Horizontal(): + yield Button("Technical", id="mode_technical", variant="primary" if settings.get("mode") == "technical" else "default") + yield Button("Catalog", id="mode_catalog", variant="primary" if settings.get("mode") == "catalog" else "default") + + # TTL inputs + yield Static("Cache TTL - Extractors (hours):", classes="label") + yield Input(value=str(settings.get("cache_ttl_extractors") // 3600), id="ttl_extractors", classes="input_field") + + yield Static("Cache TTL - TMDB (hours):", classes="label") + yield Input(value=str(settings.get("cache_ttl_tmdb") // 3600), id="ttl_tmdb", classes="input_field") + + yield Static("Cache TTL - Posters (days):", classes="label") + yield Input(value=str(settings.get("cache_ttl_posters") // 86400), id="ttl_posters", classes="input_field") + + with Horizontal(id="buttons"): + yield Button("Save", id="save") + yield Button("Cancel", id="cancel") + + def on_button_pressed(self, event): + if event.button.id == "save": + self.save_settings() + self.app.pop_screen() # type: ignore + elif event.button.id == "cancel": + self.app.pop_screen() # type: ignore + elif event.button.id.startswith("mode_"): + # Toggle mode buttons + mode = event.button.id.split("_")[1] + self.app.settings.set("mode", mode) # type: ignore + # Update button variants + tech_btn = self.query_one("#mode_technical", Button) + cat_btn = self.query_one("#mode_catalog", Button) + tech_btn.variant = "primary" if mode == "technical" else "default" + cat_btn.variant = "primary" if mode == "catalog" else "default" + + def save_settings(self): + try: + # Get values and convert to seconds + ttl_extractors = int(self.query_one("#ttl_extractors", Input).value) * 3600 + ttl_tmdb = int(self.query_one("#ttl_tmdb", Input).value) * 3600 + ttl_posters = int(self.query_one("#ttl_posters", Input).value) * 86400 + + self.app.settings.set("cache_ttl_extractors", ttl_extractors) # type: ignore + self.app.settings.set("cache_ttl_tmdb", ttl_tmdb) # type: ignore + self.app.settings.set("cache_ttl_posters", ttl_posters) # type: ignore + + self.app.notify("Settings saved!", severity="information", timeout=2) # type: ignore + except ValueError: + self.app.notify("Invalid TTL values. Please enter numbers only.", severity="error", timeout=3) # type: ignore \ No newline at end of file diff --git a/renamer/settings.py b/renamer/settings.py new file mode 100644 index 0000000..f21a86c --- /dev/null +++ b/renamer/settings.py @@ -0,0 +1,72 @@ +import json +import os +from pathlib import Path +from typing import Dict, Any + + +class Settings: + """Manages application settings stored in a JSON file.""" + + DEFAULTS = { + "mode": "technical", # "technical" or "catalog" + "cache_ttl_extractors": 21600, # 6 hours in seconds + "cache_ttl_tmdb": 21600, # 6 hours in seconds + "cache_ttl_posters": 2592000, # 30 days in seconds + } + + def __init__(self, config_dir: Path = None): + if config_dir is None: + config_dir = Path.home() / ".config" / "renamer" + self.config_dir = config_dir + self.config_file = self.config_dir / "config.json" + self._settings = self.DEFAULTS.copy() + self.load() + + def load(self) -> None: + """Load settings from file, using defaults if file doesn't exist.""" + if self.config_file.exists(): + try: + with open(self.config_file, 'r') as f: + data = json.load(f) + # Validate and merge with defaults + for key, default_value in self.DEFAULTS.items(): + if key in data: + # Basic type checking + if isinstance(data[key], type(default_value)): + self._settings[key] = data[key] + else: + print(f"Warning: Invalid type for {key}, using default") + except (json.JSONDecodeError, IOError) as e: + print(f"Warning: Could not load settings: {e}, using defaults") + else: + # Create config directory and file with defaults + self.save() + + def save(self) -> None: + """Save current settings to file.""" + try: + self.config_dir.mkdir(parents=True, exist_ok=True) + with open(self.config_file, 'w') as f: + json.dump(self._settings, f, indent=2) + except IOError as e: + print(f"Error: Could not save settings: {e}") + + def get(self, key: str) -> Any: + """Get a setting value.""" + return self._settings.get(key, self.DEFAULTS.get(key)) + + def set(self, key: str, value: Any) -> None: + """Set a setting value and save.""" + if key in self.DEFAULTS: + # Basic type checking + if isinstance(value, type(self.DEFAULTS[key])): + self._settings[key] = value + self.save() + else: + raise ValueError(f"Invalid type for setting {key}") + else: + raise KeyError(f"Unknown setting: {key}") + + def get_all(self) -> Dict[str, Any]: + """Get all current settings.""" + return self._settings.copy() \ No newline at end of file diff --git a/uv.lock b/uv.lock index 156c340..ceb5dc0 100644 --- a/uv.lock +++ b/uv.lock @@ -188,6 +188,93 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, ] +[[package]] +name = "pillow" +version = "12.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/cace85a1b0c9775a9f8f5d5423c8261c858760e2466c79b2dd184638b056/pillow-12.0.0.tar.gz", hash = "sha256:87d4f8125c9988bfbed67af47dd7a953e2fc7b0cc1e7800ec6d2080d490bb353", size = 47008828, upload-time = "2025-10-15T18:24:14.008Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/5a/a2f6773b64edb921a756eb0729068acad9fc5208a53f4a349396e9436721/pillow-12.0.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:0fd00cac9c03256c8b2ff58f162ebcd2587ad3e1f2e397eab718c47e24d231cc", size = 5289798, upload-time = "2025-10-15T18:21:47.763Z" }, + { url = "https://files.pythonhosted.org/packages/2e/05/069b1f8a2e4b5a37493da6c5868531c3f77b85e716ad7a590ef87d58730d/pillow-12.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3475b96f5908b3b16c47533daaa87380c491357d197564e0ba34ae75c0f3257", size = 4650589, upload-time = "2025-10-15T18:21:49.515Z" }, + { url = "https://files.pythonhosted.org/packages/61/e3/2c820d6e9a36432503ead175ae294f96861b07600a7156154a086ba7111a/pillow-12.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:110486b79f2d112cf6add83b28b627e369219388f64ef2f960fef9ebaf54c642", size = 6230472, upload-time = "2025-10-15T18:21:51.052Z" }, + { url = "https://files.pythonhosted.org/packages/4f/89/63427f51c64209c5e23d4d52071c8d0f21024d3a8a487737caaf614a5795/pillow-12.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5269cc1caeedb67e6f7269a42014f381f45e2e7cd42d834ede3c703a1d915fe3", size = 8033887, upload-time = "2025-10-15T18:21:52.604Z" }, + { url = "https://files.pythonhosted.org/packages/f6/1b/c9711318d4901093c15840f268ad649459cd81984c9ec9887756cca049a5/pillow-12.0.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa5129de4e174daccbc59d0a3b6d20eaf24417d59851c07ebb37aeb02947987c", size = 6343964, upload-time = "2025-10-15T18:21:54.619Z" }, + { url = "https://files.pythonhosted.org/packages/41/1e/db9470f2d030b4995083044cd8738cdd1bf773106819f6d8ba12597d5352/pillow-12.0.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bee2a6db3a7242ea309aa7ee8e2780726fed67ff4e5b40169f2c940e7eb09227", size = 7034756, upload-time = "2025-10-15T18:21:56.151Z" }, + { url = "https://files.pythonhosted.org/packages/cc/b0/6177a8bdd5ee4ed87cba2de5a3cc1db55ffbbec6176784ce5bb75aa96798/pillow-12.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:90387104ee8400a7b4598253b4c406f8958f59fcf983a6cea2b50d59f7d63d0b", size = 6458075, upload-time = "2025-10-15T18:21:57.759Z" }, + { url = "https://files.pythonhosted.org/packages/bc/5e/61537aa6fa977922c6a03253a0e727e6e4a72381a80d63ad8eec350684f2/pillow-12.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bc91a56697869546d1b8f0a3ff35224557ae7f881050e99f615e0119bf934b4e", size = 7125955, upload-time = "2025-10-15T18:21:59.372Z" }, + { url = "https://files.pythonhosted.org/packages/1f/3d/d5033539344ee3cbd9a4d69e12e63ca3a44a739eb2d4c8da350a3d38edd7/pillow-12.0.0-cp311-cp311-win32.whl", hash = "sha256:27f95b12453d165099c84f8a8bfdfd46b9e4bda9e0e4b65f0635430027f55739", size = 6298440, upload-time = "2025-10-15T18:22:00.982Z" }, + { url = "https://files.pythonhosted.org/packages/4d/42/aaca386de5cc8bd8a0254516957c1f265e3521c91515b16e286c662854c4/pillow-12.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:b583dc9070312190192631373c6c8ed277254aa6e6084b74bdd0a6d3b221608e", size = 6999256, upload-time = "2025-10-15T18:22:02.617Z" }, + { url = "https://files.pythonhosted.org/packages/ba/f1/9197c9c2d5708b785f631a6dfbfa8eb3fb9672837cb92ae9af812c13b4ed/pillow-12.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:759de84a33be3b178a64c8ba28ad5c135900359e85fb662bc6e403ad4407791d", size = 2436025, upload-time = "2025-10-15T18:22:04.598Z" }, + { url = "https://files.pythonhosted.org/packages/2c/90/4fcce2c22caf044e660a198d740e7fbc14395619e3cb1abad12192c0826c/pillow-12.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:53561a4ddc36facb432fae7a9d8afbfaf94795414f5cdc5fc52f28c1dca90371", size = 5249377, upload-time = "2025-10-15T18:22:05.993Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e0/ed960067543d080691d47d6938ebccbf3976a931c9567ab2fbfab983a5dd/pillow-12.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:71db6b4c1653045dacc1585c1b0d184004f0d7e694c7b34ac165ca70c0838082", size = 4650343, upload-time = "2025-10-15T18:22:07.718Z" }, + { url = "https://files.pythonhosted.org/packages/e7/a1/f81fdeddcb99c044bf7d6faa47e12850f13cee0849537a7d27eeab5534d4/pillow-12.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2fa5f0b6716fc88f11380b88b31fe591a06c6315e955c096c35715788b339e3f", size = 6232981, upload-time = "2025-10-15T18:22:09.287Z" }, + { url = "https://files.pythonhosted.org/packages/88/e1/9098d3ce341a8750b55b0e00c03f1630d6178f38ac191c81c97a3b047b44/pillow-12.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:82240051c6ca513c616f7f9da06e871f61bfd7805f566275841af15015b8f98d", size = 8041399, upload-time = "2025-10-15T18:22:10.872Z" }, + { url = "https://files.pythonhosted.org/packages/a7/62/a22e8d3b602ae8cc01446d0c57a54e982737f44b6f2e1e019a925143771d/pillow-12.0.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55f818bd74fe2f11d4d7cbc65880a843c4075e0ac7226bc1a23261dbea531953", size = 6347740, upload-time = "2025-10-15T18:22:12.769Z" }, + { url = "https://files.pythonhosted.org/packages/4f/87/424511bdcd02c8d7acf9f65caa09f291a519b16bd83c3fb3374b3d4ae951/pillow-12.0.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b87843e225e74576437fd5b6a4c2205d422754f84a06942cfaf1dc32243e45a8", size = 7040201, upload-time = "2025-10-15T18:22:14.813Z" }, + { url = "https://files.pythonhosted.org/packages/dc/4d/435c8ac688c54d11755aedfdd9f29c9eeddf68d150fe42d1d3dbd2365149/pillow-12.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c607c90ba67533e1b2355b821fef6764d1dd2cbe26b8c1005ae84f7aea25ff79", size = 6462334, upload-time = "2025-10-15T18:22:16.375Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f2/ad34167a8059a59b8ad10bc5c72d4d9b35acc6b7c0877af8ac885b5f2044/pillow-12.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:21f241bdd5080a15bc86d3466a9f6074a9c2c2b314100dd896ac81ee6db2f1ba", size = 7134162, upload-time = "2025-10-15T18:22:17.996Z" }, + { url = "https://files.pythonhosted.org/packages/0c/b1/a7391df6adacf0a5c2cf6ac1cf1fcc1369e7d439d28f637a847f8803beb3/pillow-12.0.0-cp312-cp312-win32.whl", hash = "sha256:dd333073e0cacdc3089525c7df7d39b211bcdf31fc2824e49d01c6b6187b07d0", size = 6298769, upload-time = "2025-10-15T18:22:19.923Z" }, + { url = "https://files.pythonhosted.org/packages/a2/0b/d87733741526541c909bbf159e338dcace4f982daac6e5a8d6be225ca32d/pillow-12.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:9fe611163f6303d1619bbcb653540a4d60f9e55e622d60a3108be0d5b441017a", size = 7001107, upload-time = "2025-10-15T18:22:21.644Z" }, + { url = "https://files.pythonhosted.org/packages/bc/96/aaa61ce33cc98421fb6088af2a03be4157b1e7e0e87087c888e2370a7f45/pillow-12.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:7dfb439562f234f7d57b1ac6bc8fe7f838a4bd49c79230e0f6a1da93e82f1fad", size = 2436012, upload-time = "2025-10-15T18:22:23.621Z" }, + { url = "https://files.pythonhosted.org/packages/62/f2/de993bb2d21b33a98d031ecf6a978e4b61da207bef02f7b43093774c480d/pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0869154a2d0546545cde61d1789a6524319fc1897d9ee31218eae7a60ccc5643", size = 4045493, upload-time = "2025-10-15T18:22:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/0e/b6/bc8d0c4c9f6f111a783d045310945deb769b806d7574764234ffd50bc5ea/pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a7921c5a6d31b3d756ec980f2f47c0cfdbce0fc48c22a39347a895f41f4a6ea4", size = 4120461, upload-time = "2025-10-15T18:22:27.286Z" }, + { url = "https://files.pythonhosted.org/packages/5d/57/d60d343709366a353dc56adb4ee1e7d8a2cc34e3fbc22905f4167cfec119/pillow-12.0.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1ee80a59f6ce048ae13cda1abf7fbd2a34ab9ee7d401c46be3ca685d1999a399", size = 3576912, upload-time = "2025-10-15T18:22:28.751Z" }, + { url = "https://files.pythonhosted.org/packages/a4/a4/a0a31467e3f83b94d37568294b01d22b43ae3c5d85f2811769b9c66389dd/pillow-12.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c50f36a62a22d350c96e49ad02d0da41dbd17ddc2e29750dbdba4323f85eb4a5", size = 5249132, upload-time = "2025-10-15T18:22:30.641Z" }, + { url = "https://files.pythonhosted.org/packages/83/06/48eab21dd561de2914242711434c0c0eb992ed08ff3f6107a5f44527f5e9/pillow-12.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5193fde9a5f23c331ea26d0cf171fbf67e3f247585f50c08b3e205c7aeb4589b", size = 4650099, upload-time = "2025-10-15T18:22:32.73Z" }, + { url = "https://files.pythonhosted.org/packages/fc/bd/69ed99fd46a8dba7c1887156d3572fe4484e3f031405fcc5a92e31c04035/pillow-12.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bde737cff1a975b70652b62d626f7785e0480918dece11e8fef3c0cf057351c3", size = 6230808, upload-time = "2025-10-15T18:22:34.337Z" }, + { url = "https://files.pythonhosted.org/packages/ea/94/8fad659bcdbf86ed70099cb60ae40be6acca434bbc8c4c0d4ef356d7e0de/pillow-12.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a6597ff2b61d121172f5844b53f21467f7082f5fb385a9a29c01414463f93b07", size = 8037804, upload-time = "2025-10-15T18:22:36.402Z" }, + { url = "https://files.pythonhosted.org/packages/20/39/c685d05c06deecfd4e2d1950e9a908aa2ca8bc4e6c3b12d93b9cafbd7837/pillow-12.0.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b817e7035ea7f6b942c13aa03bb554fc44fea70838ea21f8eb31c638326584e", size = 6345553, upload-time = "2025-10-15T18:22:38.066Z" }, + { url = "https://files.pythonhosted.org/packages/38/57/755dbd06530a27a5ed74f8cb0a7a44a21722ebf318edbe67ddbd7fb28f88/pillow-12.0.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4f1231b7dec408e8670264ce63e9c71409d9583dd21d32c163e25213ee2a344", size = 7037729, upload-time = "2025-10-15T18:22:39.769Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b6/7e94f4c41d238615674d06ed677c14883103dce1c52e4af16f000338cfd7/pillow-12.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e51b71417049ad6ab14c49608b4a24d8fb3fe605e5dfabfe523b58064dc3d27", size = 6459789, upload-time = "2025-10-15T18:22:41.437Z" }, + { url = "https://files.pythonhosted.org/packages/9c/14/4448bb0b5e0f22dd865290536d20ec8a23b64e2d04280b89139f09a36bb6/pillow-12.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d120c38a42c234dc9a8c5de7ceaaf899cf33561956acb4941653f8bdc657aa79", size = 7130917, upload-time = "2025-10-15T18:22:43.152Z" }, + { url = "https://files.pythonhosted.org/packages/dd/ca/16c6926cc1c015845745d5c16c9358e24282f1e588237a4c36d2b30f182f/pillow-12.0.0-cp313-cp313-win32.whl", hash = "sha256:4cc6b3b2efff105c6a1656cfe59da4fdde2cda9af1c5e0b58529b24525d0a098", size = 6302391, upload-time = "2025-10-15T18:22:44.753Z" }, + { url = "https://files.pythonhosted.org/packages/6d/2a/dd43dcfd6dae9b6a49ee28a8eedb98c7d5ff2de94a5d834565164667b97b/pillow-12.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:4cf7fed4b4580601c4345ceb5d4cbf5a980d030fd5ad07c4d2ec589f95f09905", size = 7007477, upload-time = "2025-10-15T18:22:46.838Z" }, + { url = "https://files.pythonhosted.org/packages/77/f0/72ea067f4b5ae5ead653053212af05ce3705807906ba3f3e8f58ddf617e6/pillow-12.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:9f0b04c6b8584c2c193babcccc908b38ed29524b29dd464bc8801bf10d746a3a", size = 2435918, upload-time = "2025-10-15T18:22:48.399Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5e/9046b423735c21f0487ea6cb5b10f89ea8f8dfbe32576fe052b5ba9d4e5b/pillow-12.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7fa22993bac7b77b78cae22bad1e2a987ddf0d9015c63358032f84a53f23cdc3", size = 5251406, upload-time = "2025-10-15T18:22:49.905Z" }, + { url = "https://files.pythonhosted.org/packages/12/66/982ceebcdb13c97270ef7a56c3969635b4ee7cd45227fa707c94719229c5/pillow-12.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f135c702ac42262573fe9714dfe99c944b4ba307af5eb507abef1667e2cbbced", size = 4653218, upload-time = "2025-10-15T18:22:51.587Z" }, + { url = "https://files.pythonhosted.org/packages/16/b3/81e625524688c31859450119bf12674619429cab3119eec0e30a7a1029cb/pillow-12.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c85de1136429c524e55cfa4e033b4a7940ac5c8ee4d9401cc2d1bf48154bbc7b", size = 6266564, upload-time = "2025-10-15T18:22:53.215Z" }, + { url = "https://files.pythonhosted.org/packages/98/59/dfb38f2a41240d2408096e1a76c671d0a105a4a8471b1871c6902719450c/pillow-12.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:38df9b4bfd3db902c9c2bd369bcacaf9d935b2fff73709429d95cc41554f7b3d", size = 8069260, upload-time = "2025-10-15T18:22:54.933Z" }, + { url = "https://files.pythonhosted.org/packages/dc/3d/378dbea5cd1874b94c312425ca77b0f47776c78e0df2df751b820c8c1d6c/pillow-12.0.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d87ef5795da03d742bf49439f9ca4d027cde49c82c5371ba52464aee266699a", size = 6379248, upload-time = "2025-10-15T18:22:56.605Z" }, + { url = "https://files.pythonhosted.org/packages/84/b0/d525ef47d71590f1621510327acec75ae58c721dc071b17d8d652ca494d8/pillow-12.0.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aff9e4d82d082ff9513bdd6acd4f5bd359f5b2c870907d2b0a9c5e10d40c88fe", size = 7066043, upload-time = "2025-10-15T18:22:58.53Z" }, + { url = "https://files.pythonhosted.org/packages/61/2c/aced60e9cf9d0cde341d54bf7932c9ffc33ddb4a1595798b3a5150c7ec4e/pillow-12.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:8d8ca2b210ada074d57fcee40c30446c9562e542fc46aedc19baf758a93532ee", size = 6490915, upload-time = "2025-10-15T18:23:00.582Z" }, + { url = "https://files.pythonhosted.org/packages/ef/26/69dcb9b91f4e59f8f34b2332a4a0a951b44f547c4ed39d3e4dcfcff48f89/pillow-12.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:99a7f72fb6249302aa62245680754862a44179b545ded638cf1fef59befb57ef", size = 7157998, upload-time = "2025-10-15T18:23:02.627Z" }, + { url = "https://files.pythonhosted.org/packages/61/2b/726235842220ca95fa441ddf55dd2382b52ab5b8d9c0596fe6b3f23dafe8/pillow-12.0.0-cp313-cp313t-win32.whl", hash = "sha256:4078242472387600b2ce8d93ade8899c12bf33fa89e55ec89fe126e9d6d5d9e9", size = 6306201, upload-time = "2025-10-15T18:23:04.709Z" }, + { url = "https://files.pythonhosted.org/packages/c0/3d/2afaf4e840b2df71344ababf2f8edd75a705ce500e5dc1e7227808312ae1/pillow-12.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2c54c1a783d6d60595d3514f0efe9b37c8808746a66920315bfd34a938d7994b", size = 7013165, upload-time = "2025-10-15T18:23:06.46Z" }, + { url = "https://files.pythonhosted.org/packages/6f/75/3fa09aa5cf6ed04bee3fa575798ddf1ce0bace8edb47249c798077a81f7f/pillow-12.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:26d9f7d2b604cd23aba3e9faf795787456ac25634d82cd060556998e39c6fa47", size = 2437834, upload-time = "2025-10-15T18:23:08.194Z" }, + { url = "https://files.pythonhosted.org/packages/54/2a/9a8c6ba2c2c07b71bec92cf63e03370ca5e5f5c5b119b742bcc0cde3f9c5/pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:beeae3f27f62308f1ddbcfb0690bf44b10732f2ef43758f169d5e9303165d3f9", size = 4045531, upload-time = "2025-10-15T18:23:10.121Z" }, + { url = "https://files.pythonhosted.org/packages/84/54/836fdbf1bfb3d66a59f0189ff0b9f5f666cee09c6188309300df04ad71fa/pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d4827615da15cd59784ce39d3388275ec093ae3ee8d7f0c089b76fa87af756c2", size = 4120554, upload-time = "2025-10-15T18:23:12.14Z" }, + { url = "https://files.pythonhosted.org/packages/0d/cd/16aec9f0da4793e98e6b54778a5fbce4f375c6646fe662e80600b8797379/pillow-12.0.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:3e42edad50b6909089750e65c91aa09aaf1e0a71310d383f11321b27c224ed8a", size = 3576812, upload-time = "2025-10-15T18:23:13.962Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b7/13957fda356dc46339298b351cae0d327704986337c3c69bb54628c88155/pillow-12.0.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e5d8efac84c9afcb40914ab49ba063d94f5dbdf5066db4482c66a992f47a3a3b", size = 5252689, upload-time = "2025-10-15T18:23:15.562Z" }, + { url = "https://files.pythonhosted.org/packages/fc/f5/eae31a306341d8f331f43edb2e9122c7661b975433de5e447939ae61c5da/pillow-12.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:266cd5f2b63ff316d5a1bba46268e603c9caf5606d44f38c2873c380950576ad", size = 4650186, upload-time = "2025-10-15T18:23:17.379Z" }, + { url = "https://files.pythonhosted.org/packages/86/62/2a88339aa40c4c77e79108facbd307d6091e2c0eb5b8d3cf4977cfca2fe6/pillow-12.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58eea5ebe51504057dd95c5b77d21700b77615ab0243d8152793dc00eb4faf01", size = 6230308, upload-time = "2025-10-15T18:23:18.971Z" }, + { url = "https://files.pythonhosted.org/packages/c7/33/5425a8992bcb32d1cb9fa3dd39a89e613d09a22f2c8083b7bf43c455f760/pillow-12.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f13711b1a5ba512d647a0e4ba79280d3a9a045aaf7e0cc6fbe96b91d4cdf6b0c", size = 8039222, upload-time = "2025-10-15T18:23:20.909Z" }, + { url = "https://files.pythonhosted.org/packages/d8/61/3f5d3b35c5728f37953d3eec5b5f3e77111949523bd2dd7f31a851e50690/pillow-12.0.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6846bd2d116ff42cba6b646edf5bf61d37e5cbd256425fa089fee4ff5c07a99e", size = 6346657, upload-time = "2025-10-15T18:23:23.077Z" }, + { url = "https://files.pythonhosted.org/packages/3a/be/ee90a3d79271227e0f0a33c453531efd6ed14b2e708596ba5dd9be948da3/pillow-12.0.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c98fa880d695de164b4135a52fd2e9cd7b7c90a9d8ac5e9e443a24a95ef9248e", size = 7038482, upload-time = "2025-10-15T18:23:25.005Z" }, + { url = "https://files.pythonhosted.org/packages/44/34/a16b6a4d1ad727de390e9bd9f19f5f669e079e5826ec0f329010ddea492f/pillow-12.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa3ed2a29a9e9d2d488b4da81dcb54720ac3104a20bf0bd273f1e4648aff5af9", size = 6461416, upload-time = "2025-10-15T18:23:27.009Z" }, + { url = "https://files.pythonhosted.org/packages/b6/39/1aa5850d2ade7d7ba9f54e4e4c17077244ff7a2d9e25998c38a29749eb3f/pillow-12.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d034140032870024e6b9892c692fe2968493790dd57208b2c37e3fb35f6df3ab", size = 7131584, upload-time = "2025-10-15T18:23:29.752Z" }, + { url = "https://files.pythonhosted.org/packages/bf/db/4fae862f8fad0167073a7733973bfa955f47e2cac3dc3e3e6257d10fab4a/pillow-12.0.0-cp314-cp314-win32.whl", hash = "sha256:1b1b133e6e16105f524a8dec491e0586d072948ce15c9b914e41cdadd209052b", size = 6400621, upload-time = "2025-10-15T18:23:32.06Z" }, + { url = "https://files.pythonhosted.org/packages/2b/24/b350c31543fb0107ab2599464d7e28e6f856027aadda995022e695313d94/pillow-12.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:8dc232e39d409036af549c86f24aed8273a40ffa459981146829a324e0848b4b", size = 7142916, upload-time = "2025-10-15T18:23:34.71Z" }, + { url = "https://files.pythonhosted.org/packages/0f/9b/0ba5a6fd9351793996ef7487c4fdbde8d3f5f75dbedc093bb598648fddf0/pillow-12.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:d52610d51e265a51518692045e372a4c363056130d922a7351429ac9f27e70b0", size = 2523836, upload-time = "2025-10-15T18:23:36.967Z" }, + { url = "https://files.pythonhosted.org/packages/f5/7a/ceee0840aebc579af529b523d530840338ecf63992395842e54edc805987/pillow-12.0.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1979f4566bb96c1e50a62d9831e2ea2d1211761e5662afc545fa766f996632f6", size = 5255092, upload-time = "2025-10-15T18:23:38.573Z" }, + { url = "https://files.pythonhosted.org/packages/44/76/20776057b4bfd1aef4eeca992ebde0f53a4dce874f3ae693d0ec90a4f79b/pillow-12.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b2e4b27a6e15b04832fe9bf292b94b5ca156016bbc1ea9c2c20098a0320d6cf6", size = 4653158, upload-time = "2025-10-15T18:23:40.238Z" }, + { url = "https://files.pythonhosted.org/packages/82/3f/d9ff92ace07be8836b4e7e87e6a4c7a8318d47c2f1463ffcf121fc57d9cb/pillow-12.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fb3096c30df99fd01c7bf8e544f392103d0795b9f98ba71a8054bcbf56b255f1", size = 6267882, upload-time = "2025-10-15T18:23:42.434Z" }, + { url = "https://files.pythonhosted.org/packages/9f/7a/4f7ff87f00d3ad33ba21af78bfcd2f032107710baf8280e3722ceec28cda/pillow-12.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7438839e9e053ef79f7112c881cef684013855016f928b168b81ed5835f3e75e", size = 8071001, upload-time = "2025-10-15T18:23:44.29Z" }, + { url = "https://files.pythonhosted.org/packages/75/87/fcea108944a52dad8cca0715ae6247e271eb80459364a98518f1e4f480c1/pillow-12.0.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d5c411a8eaa2299322b647cd932586b1427367fd3184ffbb8f7a219ea2041ca", size = 6380146, upload-time = "2025-10-15T18:23:46.065Z" }, + { url = "https://files.pythonhosted.org/packages/91/52/0d31b5e571ef5fd111d2978b84603fce26aba1b6092f28e941cb46570745/pillow-12.0.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7e091d464ac59d2c7ad8e7e08105eaf9dafbc3883fd7265ffccc2baad6ac925", size = 7067344, upload-time = "2025-10-15T18:23:47.898Z" }, + { url = "https://files.pythonhosted.org/packages/7b/f4/2dd3d721f875f928d48e83bb30a434dee75a2531bca839bb996bb0aa5a91/pillow-12.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:792a2c0be4dcc18af9d4a2dfd8a11a17d5e25274a1062b0ec1c2d79c76f3e7f8", size = 6491864, upload-time = "2025-10-15T18:23:49.607Z" }, + { url = "https://files.pythonhosted.org/packages/30/4b/667dfcf3d61fc309ba5a15b141845cece5915e39b99c1ceab0f34bf1d124/pillow-12.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:afbefa430092f71a9593a99ab6a4e7538bc9eabbf7bf94f91510d3503943edc4", size = 7158911, upload-time = "2025-10-15T18:23:51.351Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2f/16cabcc6426c32218ace36bf0d55955e813f2958afddbf1d391849fee9d1/pillow-12.0.0-cp314-cp314t-win32.whl", hash = "sha256:3830c769decf88f1289680a59d4f4c46c72573446352e2befec9a8512104fa52", size = 6408045, upload-time = "2025-10-15T18:23:53.177Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/e29aa0c9c666cf787628d3f0dcf379f4791fba79f4936d02f8b37165bdf8/pillow-12.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:905b0365b210c73afb0ebe9101a32572152dfd1c144c7e28968a331b9217b94a", size = 7148282, upload-time = "2025-10-15T18:23:55.316Z" }, + { url = "https://files.pythonhosted.org/packages/c1/70/6b41bdcddf541b437bbb9f47f94d2db5d9ddef6c37ccab8c9107743748a4/pillow-12.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:99353a06902c2e43b43e8ff74ee65a7d90307d82370604746738a1e0661ccca7", size = 2525630, upload-time = "2025-10-15T18:23:57.149Z" }, + { url = "https://files.pythonhosted.org/packages/1d/b3/582327e6c9f86d037b63beebe981425d6811104cb443e8193824ef1a2f27/pillow-12.0.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b22bd8c974942477156be55a768f7aa37c46904c175be4e158b6a86e3a6b7ca8", size = 5215068, upload-time = "2025-10-15T18:23:59.594Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d6/67748211d119f3b6540baf90f92fae73ae51d5217b171b0e8b5f7e5d558f/pillow-12.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:805ebf596939e48dbb2e4922a1d3852cfc25c38160751ce02da93058b48d252a", size = 4614994, upload-time = "2025-10-15T18:24:01.669Z" }, + { url = "https://files.pythonhosted.org/packages/2d/e1/f8281e5d844c41872b273b9f2c34a4bf64ca08905668c8ae730eedc7c9fa/pillow-12.0.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cae81479f77420d217def5f54b5b9d279804d17e982e0f2fa19b1d1e14ab5197", size = 5246639, upload-time = "2025-10-15T18:24:03.403Z" }, + { url = "https://files.pythonhosted.org/packages/94/5a/0d8ab8ffe8a102ff5df60d0de5af309015163bf710c7bb3e8311dd3b3ad0/pillow-12.0.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeaefa96c768fc66818730b952a862235d68825c178f1b3ffd4efd7ad2edcb7c", size = 6986839, upload-time = "2025-10-15T18:24:05.344Z" }, + { url = "https://files.pythonhosted.org/packages/20/2e/3434380e8110b76cd9eb00a363c484b050f949b4bbe84ba770bb8508a02c/pillow-12.0.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09f2d0abef9e4e2f349305a4f8cc784a8a6c2f58a8c4892eea13b10a943bd26e", size = 5313505, upload-time = "2025-10-15T18:24:07.137Z" }, + { url = "https://files.pythonhosted.org/packages/57/ca/5a9d38900d9d74785141d6580950fe705de68af735ff6e727cb911b64740/pillow-12.0.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bdee52571a343d721fb2eb3b090a82d959ff37fc631e3f70422e0c2e029f3e76", size = 5963654, upload-time = "2025-10-15T18:24:09.579Z" }, + { url = "https://files.pythonhosted.org/packages/95/7e/f896623c3c635a90537ac093c6a618ebe1a90d87206e42309cb5d98a1b9e/pillow-12.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b290fd8aa38422444d4b50d579de197557f182ef1068b75f5aa8558638b8d0a5", size = 6997850, upload-time = "2025-10-15T18:24:11.495Z" }, +] + [[package]] name = "platformdirs" version = "4.5.1" @@ -255,7 +342,7 @@ wheels = [ [[package]] name = "renamer" -version = "0.4.7" +version = "0.5.1" source = { editable = "." } dependencies = [ { name = "langcodes" }, @@ -264,6 +351,7 @@ dependencies = [ { name = "pytest" }, { name = "python-magic" }, { name = "requests" }, + { name = "rich-pixels" }, { name = "textual" }, ] @@ -275,6 +363,7 @@ requires-dist = [ { name = "pytest", specifier = ">=7.0.0" }, { name = "python-magic", specifier = ">=0.4.27" }, { name = "requests", specifier = ">=2.31.0" }, + { name = "rich-pixels", specifier = ">=1.0.0" }, { name = "textual", specifier = ">=6.11.0" }, ] @@ -306,6 +395,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" }, ] +[[package]] +name = "rich-pixels" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pillow" }, + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/31/71/6d5cd4b8d67cd49366eda19aaf37f20094ce562223a91166109202590237/rich_pixels-3.0.1.tar.gz", hash = "sha256:4a81977d45437ce5009cdcaf70af80256c3bdfab870e87ab802c577ba4133235", size = 24631, upload-time = "2024-03-30T09:37:52.834Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/72/7264494bc0944db1166b73c88f19d9ddfc584dbbc77c210cd0f52f59c511/rich_pixels-3.0.1-py3-none-any.whl", hash = "sha256:e82c5aa0d00885609675494f16e1ef814c68fa795634f1d6917cae9159b755e1", size = 6004, upload-time = "2024-03-30T09:37:51.169Z" }, +] + [[package]] name = "textual" version = "6.11.0"