Compare commits

..

7 Commits

Author SHA1 Message Date
sHa
67cf6a0c8b feat: Enhance caching mechanism with MD5 hashing for instance identifiers 2025-12-30 09:08:14 +00:00
sHa
dff245c37f Refactor code structure for improved readability and maintainability 2025-12-30 09:03:20 +00:00
sHa
6b343681a5 feat: Bump version to 0.5.5 and update frame class detection logic 2025-12-30 06:31:51 +00:00
sHa
a7682bcd24 Refactor code structure for improved readability and maintainability 2025-12-29 22:18:20 +00:00
sHa
6694567ab4 Add unit tests for MediaInfo frame class detection
- Created a JSON file containing various test cases for different video resolutions and their expected frame classes.
- Implemented a pytest test script that loads the test cases and verifies the frame class detection functionality of the MediaInfoExtractor.
- Utilized mocking to simulate the behavior of the MediaInfoExtractor and its video track attributes.
2025-12-29 22:03:41 +00:00
sHa
e0637e9981 fix: Remove unused resolution frame class method from formatter order 2025-12-29 20:10:34 +00:00
sHa
50de7e1d4a added media catalog mode, impooved cache 2025-12-29 19:47:55 +00:00
29 changed files with 1147 additions and 148 deletions

40
ToDo.md
View File

@@ -27,4 +27,42 @@ TODO Steps:
24. Implement metadata editing capabilities (future enhancement) 24. Implement metadata editing capabilities (future enhancement)
25. Add batch rename operations (future enhancement) 25. Add batch rename operations (future enhancement)
26. Add configuration file support (future enhancement) 26. Add configuration file support (future enhancement)
27. Add plugin system for custom extractors/formatters (future enhancement) 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)

BIN
dist/renamer-0.5.1-py3-none-any.whl vendored Normal file

Binary file not shown.

BIN
dist/renamer-0.5.2-py3-none-any.whl vendored Normal file

Binary file not shown.

BIN
dist/renamer-0.5.3-py3-none-any.whl vendored Normal file

Binary file not shown.

BIN
dist/renamer-0.5.4-py3-none-any.whl vendored Normal file

Binary file not shown.

BIN
dist/renamer-0.5.5-py3-none-any.whl vendored Normal file

Binary file not shown.

BIN
dist/renamer-0.5.6-py3-none-any.whl vendored Normal file

Binary file not shown.

BIN
dist/renamer-0.5.7-py3-none-any.whl vendored Normal file

Binary file not shown.

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "renamer" name = "renamer"
version = "0.4.7" version = "0.5.7"
description = "Terminal-based media file renamer and metadata viewer" description = "Terminal-based media file renamer and metadata viewer"
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"
@@ -12,6 +12,7 @@ dependencies = [
"pytest>=7.0.0", "pytest>=7.0.0",
"langcodes>=3.5.1", "langcodes>=3.5.1",
"requests>=2.31.0", "requests>=2.31.0",
"rich-pixels>=1.0.0",
] ]
[project.scripts] [project.scripts]

View File

@@ -1,6 +1,7 @@
from textual.app import App, ComposeResult from textual.app import App, ComposeResult
from textual.widgets import Tree, Static, Footer, LoadingIndicator from textual.widgets import Tree, Static, Footer, LoadingIndicator
from textual.containers import Horizontal, Container, ScrollableContainer, Vertical from textual.containers import Horizontal, Container, ScrollableContainer, Vertical
from textual.widget import Widget
from rich.markup import escape from rich.markup import escape
from pathlib import Path from pathlib import Path
import threading import threading
@@ -9,11 +10,14 @@ import logging
import os import os
from .constants import MEDIA_TYPES from .constants import MEDIA_TYPES
from .screens import OpenScreen, HelpScreen, RenameConfirmScreen from .screens import OpenScreen, HelpScreen, RenameConfirmScreen, SettingsScreen
from .extractors.extractor import MediaExtractor from .extractors.extractor import MediaExtractor
from .formatters.media_formatter import MediaFormatter from .formatters.media_formatter import MediaFormatter
from .formatters.proposed_name_formatter import ProposedNameFormatter from .formatters.proposed_name_formatter import ProposedNameFormatter
from .formatters.text_formatter import TextFormatter from .formatters.text_formatter import TextFormatter
from .formatters.catalog_formatter import CatalogFormatter
from .settings import Settings
from .cache import Cache
# Set up logging conditionally # Set up logging conditionally
@@ -43,13 +47,17 @@ class RenamerApp(App):
("f", "refresh", "Refresh"), ("f", "refresh", "Refresh"),
("r", "rename", "Rename"), ("r", "rename", "Rename"),
("p", "expand", "Toggle Tree"), ("p", "expand", "Toggle Tree"),
("m", "toggle_mode", "Toggle Mode"),
("h", "help", "Help"), ("h", "help", "Help"),
("ctrl+s", "settings", "Settings"),
] ]
def __init__(self, scan_dir): def __init__(self, scan_dir):
super().__init__() super().__init__()
self.scan_dir = Path(scan_dir) if scan_dir else None self.scan_dir = Path(scan_dir) if scan_dir else None
self.tree_expanded = False self.tree_expanded = False
self.settings = Settings()
self.cache = Cache()
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
with Horizontal(): with Horizontal():
@@ -60,7 +68,10 @@ class RenamerApp(App):
yield LoadingIndicator(id="loading") yield LoadingIndicator(id="loading")
with ScrollableContainer(id="details_container"): with ScrollableContainer(id="details_container"):
yield Static( 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 Static("", id="proposed", markup=True)
yield Footer() yield Footer()
@@ -73,7 +84,7 @@ class RenamerApp(App):
def scan_files(self): def scan_files(self):
logging.info("scan_files called") logging.info("scan_files called")
if not self.scan_dir or not self.scan_dir.exists() or not self.scan_dir.is_dir(): 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") details.update("Error: Directory does not exist or is not a directory")
return return
tree = self.query_one("#file_tree", Tree) tree = self.query_one("#file_tree", Tree)
@@ -105,7 +116,11 @@ class RenamerApp(App):
def _start_loading_animation(self): def _start_loading_animation(self):
loading = self.query_one("#loading", LoadingIndicator) loading = self.query_one("#loading", LoadingIndicator)
loading.display = True 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") details.update("Retrieving media data")
proposed = self.query_one("#proposed", Static) proposed = self.query_one("#proposed", Static)
proposed.update("") proposed.update("")
@@ -119,7 +134,10 @@ class RenamerApp(App):
if node.data and isinstance(node.data, Path): if node.data and isinstance(node.data, Path):
if node.data.is_dir(): if node.data.is_dir():
self._stop_loading_animation() 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") details.update("Directory")
proposed = self.query_one("#proposed", Static) proposed = self.query_one("#proposed", Static)
proposed.update("") proposed.update("")
@@ -133,12 +151,20 @@ class RenamerApp(App):
time.sleep(1) # Minimum delay to show loading time.sleep(1) # Minimum delay to show loading
try: try:
# Initialize extractors and formatters # 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 # Update UI
self.call_later( self.call_later(
self._update_details, self._update_details,
MediaFormatter(extractor).file_info_panel(), full_info,
ProposedNameFormatter(extractor).rename_line_formatted(file_path), ProposedNameFormatter(extractor).rename_line_formatted(file_path),
) )
except Exception as e: except Exception as e:
@@ -150,9 +176,18 @@ class RenamerApp(App):
def _update_details(self, full_info: str, display_string: str): def _update_details(self, full_info: str, display_string: str):
self._stop_loading_animation() self._stop_loading_animation()
details = self.query_one("#details", Static) details_technical = self.query_one("#details_technical", Static)
details.update(full_info) 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 = self.query_one("#proposed", Static)
proposed.update(display_string) proposed.update(display_string)
@@ -170,6 +205,11 @@ class RenamerApp(App):
tree = self.query_one("#file_tree", Tree) tree = self.query_one("#file_tree", Tree)
node = tree.cursor_node node = tree.cursor_node
if node and node.data and isinstance(node.data, Path) and node.data.is_file(): 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() self._start_loading_animation()
threading.Thread( threading.Thread(
target=self._extract_and_show_details, args=(node.data,) target=self._extract_and_show_details, args=(node.data,)
@@ -178,12 +218,29 @@ class RenamerApp(App):
async def action_help(self): async def action_help(self):
self.push_screen(HelpScreen()) 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): async def action_rename(self):
tree = self.query_one("#file_tree", Tree) tree = self.query_one("#file_tree", Tree)
node = tree.cursor_node node = tree.cursor_node
if node and node.data and isinstance(node.data, Path) and node.data.is_file(): if node and node.data and isinstance(node.data, Path) and node.data.is_file():
# Get the proposed name from the extractor # 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) proposed_formatter = ProposedNameFormatter(extractor)
new_name = str(proposed_formatter) new_name = str(proposed_formatter)
logging.info(f"Proposed new name: {new_name!r} for file: {node.data}") 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.""" """Update the tree node for a renamed file."""
logging.info(f"update_renamed_file called with old_path={old_path}, new_path={new_path}") 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) 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}") logging.info(f"Before update: cursor_node.data = {tree.cursor_node.data if tree.cursor_node else None}")

187
renamer/cache.py Normal file
View File

@@ -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

View File

@@ -47,6 +47,7 @@ SOURCE_DICT = {
"DVDRip": ["DVDRip", "DVD-Rip", "DVDRIP"], "DVDRip": ["DVDRip", "DVD-Rip", "DVDRIP"],
"HDTVRip": ["HDTVRip", "HDTV"], "HDTVRip": ["HDTVRip", "HDTV"],
"BluRay": ["BluRay", "BLURAY", "Blu-ray"], "BluRay": ["BluRay", "BLURAY", "Blu-ray"],
"SATRip": ["SATRip", "SAT-Rip", "SATRIP"],
"VHSRecord": [ "VHSRecord": [
"VHSRecord", "VHSRecord",
"VHS Record", "VHS Record",
@@ -69,6 +70,11 @@ FRAME_CLASSES = {
"typical_widths": [640, 704, 720], "typical_widths": [640, 704, 720],
"description": "Standard Definition (SD) interlaced - NTSC quality", "description": "Standard Definition (SD) interlaced - NTSC quality",
}, },
"360p": {
"nominal_height": 360,
"typical_widths": [480, 640],
"description": "Low Definition (LD) - 360p",
},
"576p": { "576p": {
"nominal_height": 576, "nominal_height": 576,
"typical_widths": [720, 768], "typical_widths": [720, 768],
@@ -187,7 +193,7 @@ SPECIAL_EDITIONS = {
"Workprint": ["Workprint"], "Workprint": ["Workprint"],
"Rough Cut": ["Rough Cut"], "Rough Cut": ["Rough Cut"],
"Special Assembly Cut": ["Special Assembly Cut"], "Special Assembly Cut": ["Special Assembly Cut"],
"Amazon Edition": ["Amazon Edition", "Amazon"], "Amazon Edition": ["Amazon Edition", "Amazon", "AMZN"],
"Netflix Edition": ["Netflix Edition"], "Netflix Edition": ["Netflix Edition"],
"HBO Edition": ["HBO Edition"], "HBO Edition": ["HBO Edition"],
} }

View File

@@ -0,0 +1,4 @@
# Decorators package
from .caching import cached_method
__all__ = ['cached_method']

View File

@@ -0,0 +1,54 @@
"""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, instance identifier, 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.instance_id.param_hash
class_name = self.__class__.__name__
method_name = func.__name__
# Use instance identifier (file_path for extractors)
instance_id = getattr(self, 'file_path', str(id(self)))
if isinstance(instance_id, Path):
instance_id = hashlib.md5(str(instance_id).encode('utf-8')).hexdigest()
# Create hash from args and kwargs (excluding self)
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}.{instance_id}.{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

View File

@@ -5,19 +5,45 @@ from .mediainfo_extractor import MediaInfoExtractor
from .fileinfo_extractor import FileInfoExtractor from .fileinfo_extractor import FileInfoExtractor
from .tmdb_extractor import TMDBExtractor from .tmdb_extractor import TMDBExtractor
from .default_extractor import DefaultExtractor from .default_extractor import DefaultExtractor
import hashlib
class MediaExtractor: class MediaExtractor:
"""Class to extract various metadata from media files using specialized extractors""" """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."""
cache_key = f"extractor_{hashlib.md5(str(file_path).encode('utf-8')).hexdigest()}"
if cache:
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.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.filename_extractor = FilenameExtractor(file_path)
self.metadata_extractor = MetadataExtractor(file_path) self.metadata_extractor = MetadataExtractor(file_path)
self.mediainfo_extractor = MediaInfoExtractor(file_path) self.mediainfo_extractor = MediaInfoExtractor(file_path)
self.fileinfo_extractor = FileInfoExtractor(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() self.default_extractor = DefaultExtractor()
# Extractor mapping # Extractor mapping
self._extractors = { self._extractors = {
"Metadata": self.metadata_extractor, "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): def get(self, key: str, source: str | None = None):
"""Get extracted data by key, optionally from specific source""" """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: if source:
# Specific source requested - find the extractor and call the method directly # Specific source requested - find the extractor and call the method directly
for extractor_name, extractor in self._extractors.items(): for extractor_name, extractor in self._extractors.items():
@@ -174,27 +207,20 @@ class MediaExtractor:
method = f"extract_{key}" method = f"extract_{key}"
if hasattr(extractor, method): if hasattr(extractor, method):
val = getattr(extractor, method)() val = getattr(extractor, method)()
# Apply condition if specified return val if val is not None else None
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 None return None
# Fallback mode - try sources in order # Fallback mode - try sources in order
if key in self._data: if key in self._data:
data = self._data[key] sources = self._data[key]["sources"]
sources = data["sources"]
condition = data.get("condition", lambda x: x is not None)
else: else:
# Try extractors in order for unconfigured keys # Try extractors in order for unconfigured keys
sources = [(name, f"extract_{key}") for name in ["MediaInfo", "Metadata", "Filename", "FileInfo"]] 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 # Try each source in order until a valid value is found
for src, method in sources: for src, method in sources:
if src in self._extractors and hasattr(self._extractors[src], method): if src in self._extractors and hasattr(self._extractors[src], method):
val = getattr(self._extractors[src], method)() val = getattr(self._extractors[src], method)()
if condition(val): if val is not None:
return val return val
return None return None

View File

@@ -1,6 +1,7 @@
from pathlib import Path from pathlib import Path
import logging import logging
import os import os
from ..decorators import cached_method
# Set up logging conditionally # Set up logging conditionally
if os.getenv('FORMATTER_LOG', '0') == '1': if os.getenv('FORMATTER_LOG', '0') == '1':
@@ -19,24 +20,30 @@ class FileInfoExtractor:
self._modification_time = file_path.stat().st_mtime self._modification_time = file_path.stat().st_mtime
self._file_name = file_path.name self._file_name = file_path.name
self._file_path = str(file_path) 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}") logging.info(f"FileInfoExtractor: file_name={self._file_name!r}, file_path={self._file_path!r}")
@cached_method()
def extract_size(self) -> int: def extract_size(self) -> int:
"""Extract file size in bytes""" """Extract file size in bytes"""
return self._size return self._size
@cached_method()
def extract_modification_time(self) -> float: def extract_modification_time(self) -> float:
"""Extract file modification time""" """Extract file modification time"""
return self._modification_time return self._modification_time
@cached_method()
def extract_file_name(self) -> str: def extract_file_name(self) -> str:
"""Extract file name""" """Extract file name"""
return self._file_name return self._file_name
@cached_method()
def extract_file_path(self) -> str: def extract_file_path(self) -> str:
"""Extract full file path as string""" """Extract full file path as string"""
return self._file_path return self._file_path
@cached_method()
def extract_extension(self) -> str: def extract_extension(self) -> str:
"""Extract file extension without the dot""" """Extract file extension without the dot"""
return self.file_path.suffix.lower().lstrip('.') return self.file_path.suffix.lower().lstrip('.')

View File

@@ -2,6 +2,7 @@ import re
from pathlib import Path from pathlib import Path
from collections import Counter from collections import Counter
from ..constants import SOURCE_DICT, FRAME_CLASSES, MOVIE_DB_DICT, SPECIAL_EDITIONS from ..constants import SOURCE_DICT, FRAME_CLASSES, MOVIE_DB_DICT, SPECIAL_EDITIONS
from ..decorators import cached_method
import langcodes import langcodes
@@ -34,6 +35,7 @@ class FilenameExtractor:
return frame_class return frame_class
return None return None
@cached_method()
def extract_title(self) -> str | None: def extract_title(self) -> str | None:
"""Extract movie title from filename""" """Extract movie title from filename"""
# Find positions of year, source, and quality brackets # Find positions of year, source, and quality brackets
@@ -120,6 +122,7 @@ class FilenameExtractor:
return title if title else None return title if title else None
@cached_method()
def extract_year(self) -> str | None: def extract_year(self) -> str | None:
"""Extract year from filename""" """Extract year from filename"""
# First try to find year in parentheses (most common and reliable) # First try to find year in parentheses (most common and reliable)
@@ -144,6 +147,7 @@ class FilenameExtractor:
return None return None
@cached_method()
def extract_source(self) -> str | None: def extract_source(self) -> str | None:
"""Extract video source from filename""" """Extract video source from filename"""
temp_name = re.sub(r'\s*\(\d{4}\)\s*|\s*\d{4}\s*|\.\d{4}\.', ' ', self.file_name) 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 src
return None return None
@cached_method()
def extract_order(self) -> str | None: def extract_order(self) -> str | None:
"""Extract collection order number from filename (at the beginning)""" """Extract collection order number from filename (at the beginning)"""
# Look for order patterns at the start of filename # Look for order patterns at the start of filename
@@ -176,6 +181,7 @@ class FilenameExtractor:
return None return None
@cached_method()
def extract_frame_class(self) -> str | None: def extract_frame_class(self) -> str | None:
"""Extract frame class from filename (480p, 720p, 1080p, 2160p, etc.)""" """Extract frame class from filename (480p, 720p, 1080p, 2160p, etc.)"""
# Normalize Cyrillic characters for resolution parsing # Normalize Cyrillic characters for resolution parsing
@@ -200,6 +206,7 @@ class FilenameExtractor:
return None return None
@cached_method()
def extract_hdr(self) -> str | None: def extract_hdr(self) -> str | None:
"""Extract HDR information from filename""" """Extract HDR information from filename"""
# Check for SDR first - indicates no HDR # Check for SDR first - indicates no HDR
@@ -212,6 +219,7 @@ class FilenameExtractor:
return None return None
@cached_method()
def extract_movie_db(self) -> list[str] | None: def extract_movie_db(self) -> list[str] | None:
"""Extract movie database identifier from filename""" """Extract movie database identifier from filename"""
# Look for patterns at the end of filename in brackets or braces # Look for patterns at the end of filename in brackets or braces
@@ -233,6 +241,7 @@ class FilenameExtractor:
return None return None
@cached_method()
def extract_special_info(self) -> list[str] | None: def extract_special_info(self) -> list[str] | None:
"""Extract special edition information from filename""" """Extract special edition information from filename"""
# Look for special edition indicators in brackets or as standalone text # 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 return special_info if special_info else None
@cached_method()
def extract_audio_langs(self) -> str: def extract_audio_langs(self) -> str:
"""Extract audio languages from filename""" """Extract audio languages from filename"""
# Look for language patterns in brackets and outside brackets # 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()] audio_langs = [f"{count}{lang}" if count > 1 else lang for lang, count in lang_counts.items()]
return ','.join(audio_langs) return ','.join(audio_langs)
@cached_method()
def extract_audio_tracks(self) -> list[dict]: def extract_audio_tracks(self) -> list[dict]:
"""Extract audio track data from filename (simplified version with only language)""" """Extract audio track data from filename (simplified version with only language)"""
# Similar to extract_audio_langs but returns list of dicts # Similar to extract_audio_langs but returns list of dicts

View File

@@ -2,6 +2,7 @@ from pathlib import Path
from pymediainfo import MediaInfo from pymediainfo import MediaInfo
from collections import Counter from collections import Counter
from ..constants import FRAME_CLASSES, MEDIA_TYPES from ..constants import FRAME_CLASSES, MEDIA_TYPES
from ..decorators import cached_method
import langcodes import langcodes
@@ -10,6 +11,7 @@ class MediaInfoExtractor:
def __init__(self, file_path: Path): def __init__(self, file_path: Path):
self.file_path = file_path self.file_path = file_path
self._cache = {} # Internal cache for method results
try: try:
self.media_info = MediaInfo.parse(file_path) self.media_info = MediaInfo.parse(file_path)
self.video_tracks = [t for t in self.media_info.tracks if t.track_type == 'Video'] 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 closest
return None return None
@cached_method()
def extract_duration(self) -> float | None: def extract_duration(self) -> float | None:
"""Extract duration from media info in seconds""" """Extract duration from media info in seconds"""
if self.media_info: if self.media_info:
@@ -75,37 +78,50 @@ class MediaInfoExtractor:
interlaced = getattr(self.video_tracks[0], 'interlaced', None) interlaced = getattr(self.video_tracks[0], 'interlaced', None)
scan_type = 'i' if interlaced == 'Yes' else 'p' scan_type = 'i' if interlaced == 'Yes' else 'p'
# First, try to match width to typical widths # Calculate effective height for frame class determination
matching_classes = [] aspect_ratio = 16 / 9
for frame_class, info in FRAME_CLASSES.items(): if height > width:
if width in info['typical_widths'] and frame_class.endswith(scan_type): effective_height = height / aspect_ratio
matching_classes.append((frame_class, info)) else:
effective_height = height
if matching_classes: # First, try to match width to typical widths
# If multiple matches, choose the one with closest height width_matches = []
closest = min(matching_classes, key=lambda x: abs(height - x[1]['nominal_height'])) for frame_class, info in FRAME_CLASSES.items():
return closest[0] for tw in info['typical_widths']:
if abs(width - tw) <= 5 and frame_class.endswith(scan_type):
diff = abs(height - info['nominal_height'])
width_matches.append((frame_class, diff))
if width_matches:
# Choose the frame class with the smallest height difference
width_matches.sort(key=lambda x: x[1])
return width_matches[0][0]
# If no width match, fall back to height-based matching # If no width match, fall back to height-based matching
# First try exact match # First try exact match with standard frame classes
frame_class = f"{height}{scan_type}" frame_class = f"{int(round(effective_height))}{scan_type}"
if frame_class in FRAME_CLASSES: if frame_class in FRAME_CLASSES:
return frame_class return frame_class
# Find closest height with same scan type # Find closest standard height match
closest_height = None closest_class = None
min_diff = float('inf') min_diff = float('inf')
for fc, info in FRAME_CLASSES.items(): for fc, info in FRAME_CLASSES.items():
if fc.endswith(scan_type): if fc.endswith(scan_type):
diff = abs(height - info['nominal_height']) diff = abs(effective_height - info['nominal_height'])
if diff < min_diff: if diff < min_diff:
min_diff = diff min_diff = diff
closest_height = info['nominal_height'] closest_class = fc
if closest_height and min_diff <= 100: # Return closest standard match if within reasonable distance (20 pixels)
return f"{closest_height}{scan_type}" if closest_class and min_diff <= 20:
return None return closest_class
# For non-standard resolutions, create a custom frame class
return frame_class
@cached_method()
def extract_resolution(self) -> tuple[int, int] | None: def extract_resolution(self) -> tuple[int, int] | None:
"""Extract actual video resolution as (width, height) tuple from media info""" """Extract actual video resolution as (width, height) tuple from media info"""
if not self.video_tracks: if not self.video_tracks:
@@ -116,6 +132,7 @@ class MediaInfoExtractor:
return width, height return width, height
return None return None
@cached_method()
def extract_aspect_ratio(self) -> str | None: def extract_aspect_ratio(self) -> str | None:
"""Extract video aspect ratio from media info""" """Extract video aspect ratio from media info"""
if not self.video_tracks: if not self.video_tracks:
@@ -125,6 +142,7 @@ class MediaInfoExtractor:
return str(aspect_ratio) return str(aspect_ratio)
return None return None
@cached_method()
def extract_hdr(self) -> str | None: def extract_hdr(self) -> str | None:
"""Extract HDR info from media info""" """Extract HDR info from media info"""
if not self.video_tracks: if not self.video_tracks:
@@ -134,6 +152,7 @@ class MediaInfoExtractor:
return 'HDR' return 'HDR'
return None return None
@cached_method()
def extract_audio_langs(self) -> str | None: def extract_audio_langs(self) -> str | None:
"""Extract audio languages from media info""" """Extract audio languages from media info"""
if not self.audio_tracks: if not self.audio_tracks:
@@ -154,6 +173,7 @@ class MediaInfoExtractor:
audio_langs = [f"{count}{lang}" if count > 1 else lang for lang, count in lang_counts.items()] audio_langs = [f"{count}{lang}" if count > 1 else lang for lang, count in lang_counts.items()]
return ','.join(audio_langs) return ','.join(audio_langs)
@cached_method()
def extract_video_tracks(self) -> list[dict]: def extract_video_tracks(self) -> list[dict]:
"""Extract video track data""" """Extract video track data"""
tracks = [] tracks = []
@@ -169,6 +189,7 @@ class MediaInfoExtractor:
tracks.append(track_data) tracks.append(track_data)
return tracks return tracks
@cached_method()
def extract_audio_tracks(self) -> list[dict]: def extract_audio_tracks(self) -> list[dict]:
"""Extract audio track data""" """Extract audio track data"""
tracks = [] tracks = []
@@ -182,6 +203,7 @@ class MediaInfoExtractor:
tracks.append(track_data) tracks.append(track_data)
return tracks return tracks
@cached_method()
def extract_subtitle_tracks(self) -> list[dict]: def extract_subtitle_tracks(self) -> list[dict]:
"""Extract subtitle track data""" """Extract subtitle track data"""
tracks = [] tracks = []
@@ -193,6 +215,7 @@ class MediaInfoExtractor:
tracks.append(track_data) tracks.append(track_data)
return tracks return tracks
@cached_method()
def is_3d(self) -> bool: def is_3d(self) -> bool:
"""Check if the video is 3D""" """Check if the video is 3D"""
if not self.video_tracks: if not self.video_tracks:
@@ -205,6 +228,7 @@ class MediaInfoExtractor:
return True return True
return False return False
@cached_method()
def extract_anamorphic(self) -> str | None: def extract_anamorphic(self) -> str | None:
"""Extract anamorphic info for 3D videos""" """Extract anamorphic info for 3D videos"""
if not self.video_tracks: if not self.video_tracks:
@@ -214,6 +238,7 @@ class MediaInfoExtractor:
return 'Anamorphic:Yes' return 'Anamorphic:Yes'
return None return None
@cached_method()
def extract_extension(self) -> str | None: def extract_extension(self) -> str | None:
"""Extract file extension based on container format""" """Extract file extension based on container format"""
if not self.media_info: if not self.media_info:
@@ -233,6 +258,7 @@ class MediaInfoExtractor:
return exts[0] if exts else None return exts[0] if exts else None
return None return None
@cached_method()
def extract_3d_layout(self) -> str | None: def extract_3d_layout(self) -> str | None:
"""Extract 3D stereoscopic layout from MediaInfo""" """Extract 3D stereoscopic layout from MediaInfo"""
if not self.is_3d(): if not self.is_3d():

View File

@@ -1,6 +1,7 @@
import mutagen import mutagen
from pathlib import Path from pathlib import Path
from ..constants import MEDIA_TYPES from ..constants import MEDIA_TYPES
from ..decorators import cached_method
class MetadataExtractor: class MetadataExtractor:
@@ -8,36 +9,40 @@ class MetadataExtractor:
def __init__(self, file_path: Path): def __init__(self, file_path: Path):
self.file_path = file_path self.file_path = file_path
self._cache = {} # Internal cache for method results
try: try:
self.info = mutagen.File(file_path) # type: ignore self.info = mutagen.File(file_path) # type: ignore
except Exception: except Exception:
self.info = None self.info = None
@cached_method()
def extract_title(self) -> str | None: def extract_title(self) -> str | None:
"""Extract title from metadata""" """Extract title from metadata"""
if self.info: 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 getattr(self.info, 'title', None) or getattr(self.info, 'get', lambda x, default=None: default)('title', [None])[0] # type: ignore
return None return None
@cached_method()
def extract_duration(self) -> float | None: def extract_duration(self) -> float | None:
"""Extract duration from metadata""" """Extract duration from metadata"""
if self.info: if self.info:
return getattr(self.info, 'length', None) return getattr(self.info, 'length', None)
return None return None
@cached_method()
def extract_artist(self) -> str | None: def extract_artist(self) -> str | None:
"""Extract artist from metadata""" """Extract artist from metadata"""
if self.info: 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 getattr(self.info, 'artist', None) or getattr(self.info, 'get', lambda x, default=None: default)('artist', [None])[0] # type: ignore
return None return None
@cached_method()
def extract_meta_type(self) -> str: def extract_meta_type(self) -> str:
"""Extract meta type from metadata""" """Extract meta type from metadata"""
if self.info: if self.info:
return type(self.info).__name__ return type(self.info).__name__
return self._detect_by_mime() return self._detect_by_mime()
def _detect_by_mime(self) -> str: def _detect_by_mime(self) -> str:
"""Detect meta type by MIME""" """Detect meta type by MIME"""
try: try:

View File

@@ -11,53 +11,22 @@ from ..secrets import TMDB_API_KEY, TMDB_ACCESS_TOKEN
class TMDBExtractor: class TMDBExtractor:
"""Class to extract TMDB movie information""" """Class to extract TMDB movie information"""
CACHE_DIR = Path.home() / ".cache" / "renamer" / "tmdb" def __init__(self, file_path: Path, cache=None, ttl_seconds: int = 21600):
CACHE_DURATION = 5 * 24 * 60 * 60 # 5 days in seconds
def __init__(self, file_path: Path):
self.file_path = file_path self.file_path = file_path
self.cache = cache
self.ttl_seconds = ttl_seconds
self._movie_db_info = None 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]]: def _get_cached_data(self, cache_key: str) -> Optional[Dict[str, Any]]:
"""Get data from cache if valid""" """Get data from cache if valid"""
if not self._is_cache_valid(cache_key): if self.cache:
return None return self.cache.get(f"tmdb_{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
def _set_cached_data(self, cache_key: str, data: Dict[str, Any]): def _set_cached_data(self, cache_key: str, data: Dict[str, Any]):
"""Store data in cache""" """Store data in cache"""
try: if self.cache:
self.CACHE_DIR.mkdir(parents=True, exist_ok=True) self.cache.set(f"tmdb_{cache_key}", data, self.ttl_seconds)
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
def _make_tmdb_request(self, endpoint: str, params: Optional[Dict[str, Any]] = None) -> Optional[Dict[str, Any]]: def _make_tmdb_request(self, endpoint: str, params: Optional[Dict[str, Any]] = None) -> Optional[Dict[str, Any]]:
"""Make a request to TMDB API""" """Make a request to TMDB API"""
@@ -230,9 +199,77 @@ class TMDBExtractor:
return f"https://www.themoviedb.org/movie/{movie_id}" return f"https://www.themoviedb.org/movie/{movie_id}"
return None return None
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_movie_db(self) -> Optional[Tuple[str, str]]: def extract_movie_db(self) -> Optional[Tuple[str, str]]:
"""Extract TMDB database info as (name, id) tuple""" """Extract TMDB database info as (name, id) tuple"""
movie_id = self.extract_tmdb_id() movie_id = self.extract_tmdb_id()
if movie_id: if movie_id:
return ("tmdb", movie_id) return ("tmdb", movie_id)
return None 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

View File

@@ -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}"

View File

@@ -34,8 +34,6 @@ class FormatterApplier:
DateFormatter.format_modification_date, DateFormatter.format_modification_date,
DateFormatter.format_year, DateFormatter.format_year,
ExtensionFormatter.format_extension_info, ExtensionFormatter.format_extension_info,
ResolutionFormatter.get_frame_class_from_resolution,
ResolutionFormatter.format_resolution_p,
ResolutionFormatter.format_resolution_dimensions, ResolutionFormatter.format_resolution_dimensions,
TrackFormatter.format_video_track, TrackFormatter.format_video_track,
TrackFormatter.format_audio_track, TrackFormatter.format_audio_track,

View File

@@ -1,58 +1,6 @@
class ResolutionFormatter: class ResolutionFormatter:
"""Class for formatting video resolutions and frame classes""" """Class for formatting video resolutions and frame classes"""
@staticmethod
def get_frame_class_from_resolution(resolution: str) -> str:
"""Convert resolution string (WIDTHxHEIGHT) to frame class (480p, 720p, etc.)"""
if not resolution:
return 'Unclassified'
try:
# Extract height from WIDTHxHEIGHT format
if 'x' in resolution:
height = int(resolution.split('x')[1])
else:
# Try to extract number directly
import re
match = re.search(r'(\d{3,4})', resolution)
if match:
height = int(match.group(1))
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'
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 @staticmethod
def format_resolution_dimensions(resolution: tuple[int, int]) -> str: def format_resolution_dimensions(resolution: tuple[int, int]) -> str:
"""Format resolution as WIDTHxHEIGHT""" """Format resolution as WIDTHxHEIGHT"""

View File

@@ -58,6 +58,8 @@ ACTIONS:
• f: Refresh - Reload metadata for selected file • f: Refresh - Reload metadata for selected file
• r: Rename - Rename selected file with proposed name • r: Rename - Rename selected file with proposed name
• p: Expand/Collapse - Toggle expansion of selected directory • 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 • h: Help - Show this help screen
• q: Quit - Exit the application • q: Quit - Exit the application
@@ -177,9 +179,9 @@ Do you want to proceed with renaming?
if event.button.id == "rename": if event.button.id == "rename":
try: try:
logging.info(f"Renaming {self.old_path} to {self.new_path}") logging.info(f"Renaming {self.old_path} to {self.new_path}")
self.old_path.rename(self.new_path) self.old_path.rename(self.old_path.with_name(self.new_path)) # type: ignore
# Update the tree node # Update the tree node
self.app.update_renamed_file(self.old_path, self.new_path) # type: ignore self.app.update_renamed_file(self.old_path, self.old_path.with_name(self.new_path)) # type: ignore
self.app.pop_screen() self.app.pop_screen()
except Exception as e: except Exception as e:
# Show error # Show error
@@ -227,9 +229,9 @@ Do you want to proceed with renaming?
# Trigger rename # Trigger rename
try: try:
logging.info(f"Hotkey renaming {self.old_path} to {self.new_path}") logging.info(f"Hotkey renaming {self.old_path} to {self.new_path}")
self.old_path.rename(self.new_path) self.old_path.rename(self.old_path.with_name(self.new_path)) # type: ignore
# Update the tree node # Update the tree node
self.app.update_renamed_file(self.old_path, self.new_path) # type: ignore self.app.update_renamed_file(self.old_path, self.old_path.with_name(self.new_path)) # type: ignore
self.app.pop_screen() self.app.pop_screen()
except Exception as e: except Exception as e:
# Show error # Show error
@@ -237,4 +239,92 @@ Do you want to proceed with renaming?
content.update(f"Error renaming file: {str(e)}") content.update(f"Error renaming file: {str(e)}")
elif event.key == "n": elif event.key == "n":
# Cancel # Cancel
self.app.pop_screen() 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

72
renamer/settings.py Normal file
View File

@@ -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()

View File

@@ -1,6 +1,7 @@
import pytest import pytest
from pathlib import Path from pathlib import Path
from renamer.extractors.mediainfo_extractor import MediaInfoExtractor from renamer.extractors.mediainfo_extractor import MediaInfoExtractor
import json
class TestMediaInfoExtractor: class TestMediaInfoExtractor:
@@ -13,6 +14,13 @@ class TestMediaInfoExtractor:
"""Use the filenames.txt file for testing""" """Use the filenames.txt file for testing"""
return Path(__file__).parent / "filenames.txt" return Path(__file__).parent / "filenames.txt"
@pytest.fixture
def frame_class_cases(self):
"""Load test cases for frame class extraction"""
cases_file = Path(__file__).parent / "test_mediainfo_frame_class_cases.json"
with open(cases_file, 'r') as f:
return json.load(f)
def test_extract_resolution(self, extractor, test_file): def test_extract_resolution(self, extractor, test_file):
"""Test extracting resolution from media info""" """Test extracting resolution from media info"""
resolution = extractor.extract_resolution() resolution = extractor.extract_resolution()
@@ -47,4 +55,22 @@ class TestMediaInfoExtractor:
"""Test checking if video is 3D""" """Test checking if video is 3D"""
is_3d = extractor.is_3d() is_3d = extractor.is_3d()
# Text files don't have video tracks # Text files don't have video tracks
assert is_3d is False assert is_3d is False
@pytest.mark.parametrize("case", [
pytest.param(case, id=case["testname"])
for case in json.load(open(Path(__file__).parent / "test_mediainfo_frame_class_cases.json"))
])
def test_extract_frame_class(self, case):
"""Test extracting frame class from various resolutions"""
# Create a mock extractor with the test resolution
extractor = MediaInfoExtractor.__new__(MediaInfoExtractor)
extractor.video_tracks = [{
'width': case["resolution"][0],
'height': case["resolution"][1],
'interlaced': 'Yes' if case["interlaced"] else None
}]
result = extractor.extract_frame_class()
print(f"Case: {case['testname']}, resolution: {case['resolution']}, expected: {case['expected_frame_class']}, got: {result}")
assert result == case["expected_frame_class"], f"Failed for {case['testname']}: expected {case['expected_frame_class']}, got {result}"

View File

@@ -0,0 +1,152 @@
[
{
"testname": "test-480p-sd",
"resolution": [720, 480],
"interlaced": false,
"expected_frame_class": "480p"
},
{
"testname": "test-576p-pal",
"resolution": [720, 576],
"interlaced": false,
"expected_frame_class": "576p"
},
{
"testname": "test-720p-hd",
"resolution": [1280, 720],
"interlaced": false,
"expected_frame_class": "720p"
},
{
"testname": "test-1080p-fullhd",
"resolution": [1920, 1080],
"interlaced": false,
"expected_frame_class": "1080p"
},
{
"testname": "test-1080i-broadcast",
"resolution": [1920, 1080],
"interlaced": true,
"expected_frame_class": "1080i"
},
{
"testname": "test-1440p-qhd",
"resolution": [2560, 1440],
"interlaced": false,
"expected_frame_class": "1440p"
},
{
"testname": "test-2160p-uhd",
"resolution": [3840, 2160],
"interlaced": false,
"expected_frame_class": "2160p"
},
{
"testname": "test-4320p-8k",
"resolution": [7680, 4320],
"interlaced": false,
"expected_frame_class": "4320p"
},
{
"testname": "test-1080p-cinema-240",
"resolution": [1920, 804],
"interlaced": false,
"expected_frame_class": "1080p"
},
{
"testname": "test-1080p-cinema-235",
"resolution": [1920, 816],
"interlaced": false,
"expected_frame_class": "1080p"
},
{
"testname": "test-720p-cinema",
"resolution": [1280, 536],
"interlaced": false,
"expected_frame_class": "720p"
},
{
"testname": "test-2160p-cinema",
"resolution": [3840, 1608],
"interlaced": false,
"expected_frame_class": "2160p"
},
{
"testname": "test-mobile-vertical-iphone",
"resolution": [1170, 2532],
"interlaced": false,
"expected_frame_class": "1440p"
},
{
"testname": "test-mobile-vertical-4k",
"resolution": [2160, 3840],
"interlaced": false,
"expected_frame_class": "2160p"
},
{
"testname": "test-square-video",
"resolution": [1080, 1080],
"interlaced": false,
"expected_frame_class": "1080p"
},
{
"testname": "test-vhs-capture",
"resolution": [720, 404],
"interlaced": false,
"expected_frame_class": "480p"
},
{
"testname": "test-miniDV-pal",
"resolution": [720, 576],
"interlaced": true,
"expected_frame_class": "576i"
},
{
"testname": "test-old-digital-camera-4by3",
"resolution": [1024, 768],
"interlaced": false,
"expected_frame_class": "768p"
},
{
"testname": "test-old-digital-camera-lowres",
"resolution": [800, 600],
"interlaced": false,
"expected_frame_class": "600p"
},
{
"testname": "test-webcam-legacy",
"resolution": [640, 480],
"interlaced": false,
"expected_frame_class": "480p"
},
{
"testname": "test-odd-nonstandard-wide",
"resolution": [1600, 900],
"interlaced": false,
"expected_frame_class": "900p"
},
{
"testname": "test-odd-nonstandard-small",
"resolution": [854, 480],
"interlaced": false,
"expected_frame_class": "480p"
},
{
"testname": "test-ultrawide-monitor-capture",
"resolution": [3440, 1440],
"interlaced": false,
"expected_frame_class": "1440p"
},
{
"testname": "test-strange-lowres",
"resolution": [512, 288],
"interlaced": false,
"expected_frame_class": "288p"
},
{
"resolution": [1918, 812],
"interlaced": false,
"expected_frame_class": "1080p",
"testname": "test-mistakenly-high-height"
}
]

View File

@@ -0,0 +1,40 @@
#!/usr/bin/env python3
"""Test script for MediaInfo frame class detection by resolution"""
import json
import pytest
from unittest.mock import MagicMock
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
from renamer.extractors.mediainfo_extractor import MediaInfoExtractor
test_cases = json.load(open('renamer/test/test_mediainfo_frame_class.json'))
@pytest.mark.parametrize("test_case", test_cases, ids=[tc['testname'] for tc in test_cases])
def test_frame_class_detection(test_case):
"""Test frame class detection for various resolutions"""
testname = test_case['testname']
width, height = test_case['resolution']
interlaced = test_case['interlaced']
expected = test_case['expected_frame_class']
# Create a mock MediaInfoExtractor
extractor = MagicMock(spec=MediaInfoExtractor)
from pathlib import Path
extractor.file_path = Path(f"test_{testname}") # Set a unique file_path for caching
# Mock the video_tracks
mock_track = MagicMock()
mock_track.height = height
mock_track.width = width
mock_track.interlaced = 'Yes' if interlaced else 'No'
extractor.video_tracks = [mock_track]
# Test the method
actual = MediaInfoExtractor.extract_frame_class(extractor)
assert actual == expected, f"{testname}: expected {expected}, got {actual}"

104
uv.lock generated
View File

@@ -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" }, { 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]] [[package]]
name = "platformdirs" name = "platformdirs"
version = "4.5.1" version = "4.5.1"
@@ -255,7 +342,7 @@ wheels = [
[[package]] [[package]]
name = "renamer" name = "renamer"
version = "0.4.7" version = "0.5.7"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "langcodes" }, { name = "langcodes" },
@@ -264,6 +351,7 @@ dependencies = [
{ name = "pytest" }, { name = "pytest" },
{ name = "python-magic" }, { name = "python-magic" },
{ name = "requests" }, { name = "requests" },
{ name = "rich-pixels" },
{ name = "textual" }, { name = "textual" },
] ]
@@ -275,6 +363,7 @@ requires-dist = [
{ name = "pytest", specifier = ">=7.0.0" }, { name = "pytest", specifier = ">=7.0.0" },
{ name = "python-magic", specifier = ">=0.4.27" }, { name = "python-magic", specifier = ">=0.4.27" },
{ name = "requests", specifier = ">=2.31.0" }, { name = "requests", specifier = ">=2.31.0" },
{ name = "rich-pixels", specifier = ">=1.0.0" },
{ name = "textual", specifier = ">=6.11.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" }, { 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]] [[package]]
name = "textual" name = "textual"
version = "6.11.0" version = "6.11.0"