added media catalog mode, impooved cache
This commit is contained in:
38
ToDo.md
38
ToDo.md
@@ -28,3 +28,41 @@ TODO Steps:
|
|||||||
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
BIN
dist/renamer-0.5.1-py3-none-any.whl
vendored
Normal file
Binary file not shown.
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "renamer"
|
name = "renamer"
|
||||||
version = "0.4.7"
|
version = "0.5.1"
|
||||||
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]
|
||||||
|
|||||||
@@ -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,8 +176,17 @@ 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
187
renamer/cache.py
Normal 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
|
||||||
@@ -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],
|
||||||
|
|||||||
4
renamer/decorators/__init__.py
Normal file
4
renamer/decorators/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# Decorators package
|
||||||
|
from .caching import cached_method
|
||||||
|
|
||||||
|
__all__ = ['cached_method']
|
||||||
49
renamer/decorators/caching.py
Normal file
49
renamer/decorators/caching.py
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
"""Caching decorators for extractors."""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Callable, Optional
|
||||||
|
from renamer.cache import Cache
|
||||||
|
|
||||||
|
|
||||||
|
# Global cache instance
|
||||||
|
_cache = Cache()
|
||||||
|
|
||||||
|
|
||||||
|
def cached_method(ttl_seconds: int = 3600) -> Callable:
|
||||||
|
"""Decorator to cache method results with TTL.
|
||||||
|
|
||||||
|
Caches the result of a method call using a global file-based cache.
|
||||||
|
The cache key includes class name, method name, and parameters hash.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ttl_seconds: Time to live for cached results in seconds (default 1 hour)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The decorated method with caching
|
||||||
|
"""
|
||||||
|
def decorator(func: Callable) -> Callable:
|
||||||
|
def wrapper(self, *args, **kwargs) -> Any:
|
||||||
|
# Generate cache key: class_name.method_name.param_hash
|
||||||
|
class_name = self.__class__.__name__
|
||||||
|
method_name = func.__name__
|
||||||
|
|
||||||
|
# Create hash from args and kwargs
|
||||||
|
param_str = json.dumps((args, kwargs), sort_keys=True, default=str)
|
||||||
|
param_hash = hashlib.md5(param_str.encode('utf-8')).hexdigest()
|
||||||
|
|
||||||
|
cache_key = f"{class_name}.{method_name}.{param_hash}"
|
||||||
|
|
||||||
|
# Try to get from cache
|
||||||
|
cached_result = _cache.get_object(cache_key)
|
||||||
|
if cached_result is not None:
|
||||||
|
return cached_result
|
||||||
|
|
||||||
|
# Compute result and cache it
|
||||||
|
result = func(self, *args, **kwargs)
|
||||||
|
_cache.set_object(cache_key, result, ttl_seconds)
|
||||||
|
return result
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
return decorator
|
||||||
@@ -10,12 +10,38 @@ from .default_extractor import DefaultExtractor
|
|||||||
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."""
|
||||||
|
if cache:
|
||||||
|
cache_key = f"extractor_{file_path}"
|
||||||
|
cached_obj = cache.get_object(cache_key)
|
||||||
|
if cached_obj:
|
||||||
|
print(f"Loaded MediaExtractor object from cache for {file_path.name}")
|
||||||
|
return cached_obj
|
||||||
|
|
||||||
|
# Create new instance
|
||||||
|
instance = cls(file_path, cache, ttl_seconds)
|
||||||
|
|
||||||
|
# Cache the object
|
||||||
|
if cache:
|
||||||
|
cache_key = f"extractor_{file_path}"
|
||||||
|
cache.set_object(cache_key, instance, ttl_seconds)
|
||||||
|
print(f"Cached MediaExtractor object for {file_path.name}")
|
||||||
|
|
||||||
|
return instance
|
||||||
|
|
||||||
|
def __init__(self, file_path: Path, cache=None, ttl_seconds: int = 21600):
|
||||||
|
self.file_path = file_path
|
||||||
|
self.cache = cache
|
||||||
|
self.ttl_seconds = ttl_seconds
|
||||||
|
self.cache_key = f"file_data_{file_path}"
|
||||||
|
|
||||||
self.filename_extractor = FilenameExtractor(file_path)
|
self.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
|
||||||
@@ -165,8 +191,15 @@ 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
|
||||||
|
|||||||
@@ -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('.')
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
@@ -62,6 +65,7 @@ class MediaInfoExtractor:
|
|||||||
return getattr(track, 'duration', 0) / 1000 if getattr(track, 'duration', None) else None
|
return getattr(track, 'duration', 0) / 1000 if getattr(track, 'duration', None) else None
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@cached_method()
|
||||||
def extract_frame_class(self) -> str | None:
|
def extract_frame_class(self) -> str | None:
|
||||||
"""Extract frame class from media info (480p, 720p, 1080p, etc.)"""
|
"""Extract frame class from media info (480p, 720p, 1080p, etc.)"""
|
||||||
if not self.video_tracks:
|
if not self.video_tracks:
|
||||||
@@ -106,6 +110,7 @@ class MediaInfoExtractor:
|
|||||||
return f"{closest_height}{scan_type}"
|
return f"{closest_height}{scan_type}"
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@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 +121,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 +131,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 +141,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 +162,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 +178,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 +192,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 +204,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 +217,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 +227,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 +247,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():
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,70 @@ 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_movie_db(self) -> Optional[Tuple[str, str]]:
|
def extract_duration(self) -> Optional[str]:
|
||||||
"""Extract TMDB database info as (name, id) tuple"""
|
"""Extract TMDB runtime in minutes"""
|
||||||
movie_id = self.extract_tmdb_id()
|
movie_info = self._get_movie_info()
|
||||||
if movie_id:
|
if movie_info and movie_info.get('runtime'):
|
||||||
return ("tmdb", movie_id)
|
return str(movie_info['runtime'])
|
||||||
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
|
||||||
|
|||||||
107
renamer/formatters/catalog_formatter.py
Normal file
107
renamer/formatters/catalog_formatter.py
Normal 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}"
|
||||||
@@ -35,7 +35,6 @@ class FormatterApplier:
|
|||||||
DateFormatter.format_year,
|
DateFormatter.format_year,
|
||||||
ExtensionFormatter.format_extension_info,
|
ExtensionFormatter.format_extension_info,
|
||||||
ResolutionFormatter.get_frame_class_from_resolution,
|
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,
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from renamer.constants import FRAME_CLASSES
|
||||||
|
|
||||||
class ResolutionFormatter:
|
class ResolutionFormatter:
|
||||||
"""Class for formatting video resolutions and frame classes"""
|
"""Class for formatting video resolutions and frame classes"""
|
||||||
|
|
||||||
@@ -20,39 +22,20 @@ class ResolutionFormatter:
|
|||||||
else:
|
else:
|
||||||
return 'Unclassified'
|
return 'Unclassified'
|
||||||
|
|
||||||
if height == 4320:
|
# Find the closest frame class based on nominal height
|
||||||
return '4320p'
|
closest_class = 'Unclassified'
|
||||||
elif height >= 2160:
|
min_diff = float('inf')
|
||||||
return '2160p'
|
for frame_class, info in FRAME_CLASSES.items():
|
||||||
elif height >= 1440:
|
nominal_height = info['nominal_height']
|
||||||
return '1440p'
|
diff = abs(height - nominal_height)
|
||||||
elif height >= 1080:
|
if diff < min_diff:
|
||||||
return '1080p'
|
min_diff = diff
|
||||||
elif height >= 720:
|
closest_class = frame_class
|
||||||
return '720p'
|
|
||||||
elif height >= 576:
|
return closest_class
|
||||||
return '576p'
|
|
||||||
elif height >= 480:
|
|
||||||
return '480p'
|
|
||||||
else:
|
|
||||||
return 'Unclassified'
|
|
||||||
except (ValueError, IndexError):
|
except (ValueError, IndexError):
|
||||||
return 'Unclassified'
|
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"""
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
@@ -238,3 +240,91 @@ Do you want to proceed with renaming?
|
|||||||
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
72
renamer/settings.py
Normal 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()
|
||||||
104
uv.lock
generated
104
uv.lock
generated
@@ -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.1"
|
||||||
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"
|
||||||
|
|||||||
Reference in New Issue
Block a user