feat: Add type hints and improve type safety across multiple modules

This commit is contained in:
sha
2026-04-12 22:41:47 +03:00
parent f2af482154
commit bcc6b03ae2
20 changed files with 79 additions and 51 deletions
+1
View File
@@ -18,6 +18,7 @@ dependencies = [
[project.optional-dependencies] [project.optional-dependencies]
dev = [ dev = [
"mypy>=1.0.0", "mypy>=1.0.0",
"types-requests>=2.31.0",
] ]
[project.scripts] [project.scripts]
+10 -11
View File
@@ -6,6 +6,7 @@ from textual.command import Provider, Hit
from rich.markup import escape from rich.markup import escape
from pathlib import Path from pathlib import Path
from functools import partial from functools import partial
from typing import TYPE_CHECKING, cast, Any
import threading import threading
import logging import logging
@@ -43,7 +44,7 @@ class CacheCommandProvider(Provider):
yield Hit( yield Hit(
score, score,
matcher.highlight(display_name), matcher.highlight(display_name),
partial(self.app.action_cache_command, command_name), partial(cast('MomaApp', self.app).action_cache_command, command_name),
help=help_text help=help_text
) )
@@ -208,11 +209,11 @@ class MomaApp(App):
# File type icons # File type icons
icons = { icons = {
'mkv': '󰈫', # Video camera for MKV 'mkv': '🎥', # Video camera for MKV
'mk3d': '󰟽', # Clapper board for 3D 'mk3d': '🕹️', # Clapper board for 3D
'mp4': '󰎁', # Video camera 'mp4': '🎥', # Video camera
'mov': '󰎁', # Video camera 'mov': '🎥', # Video camera
'webm': '', # Video camera 'webm': '🎥', # Video camera
'avi': '💿', # Film frames for AVI 'avi': '💿', # Film frames for AVI
'wmv': '📀', # Video camera 'wmv': '📀', # Video camera
'm4v': '📹', # Video camera 'm4v': '📹', # Video camera
@@ -308,14 +309,12 @@ class MomaApp(App):
extractor = MediaExtractor(file_path) extractor = MediaExtractor(file_path)
mode = self.settings.get("mode") mode = self.settings.get("mode")
poster_content = "" poster_content: Any = ""
if mode == "technical": if mode == "technical":
formatter = MediaPanelView(extractor) full_info = MediaPanelView(extractor).file_info_panel()
full_info = formatter.file_info_panel()
else: # catalog else: # catalog
formatter = CatalogFormatter(extractor, self.settings) full_info, poster_content = CatalogFormatter(extractor, self.settings).format_catalog_info()
full_info, poster_content = formatter.format_catalog_info()
# Update UI # Update UI
self.call_later( self.call_later(
+1
View File
@@ -16,6 +16,7 @@ class Cache:
_instance: Optional['Cache'] = None _instance: Optional['Cache'] = None
_lock_init = threading.Lock() _lock_init = threading.Lock()
_initialized: bool
def __new__(cls, cache_dir: Optional[Path] = None): def __new__(cls, cache_dir: Optional[Path] = None):
"""Create or return singleton instance.""" """Create or return singleton instance."""
+2 -1
View File
@@ -18,7 +18,7 @@ from .media_constants import (
get_extension_from_format get_extension_from_format
) )
from .source_constants import SOURCE_DICT from .source_constants import SOURCE_DICT
from .frame_constants import FRAME_CLASSES, NON_STANDARD_QUALITY_INDICATORS from .frame_constants import FRAME_CLASSES, NON_STANDARD_QUALITY_INDICATORS, FrameClassInfo
from .moviedb_constants import MOVIE_DB_DICT from .moviedb_constants import MOVIE_DB_DICT
from .edition_constants import SPECIAL_EDITIONS from .edition_constants import SPECIAL_EDITIONS
from .lang_constants import SKIP_WORDS from .lang_constants import SKIP_WORDS
@@ -35,6 +35,7 @@ __all__ = [
# Frame classes # Frame classes
'FRAME_CLASSES', 'FRAME_CLASSES',
'NON_STANDARD_QUALITY_INDICATORS', 'NON_STANDARD_QUALITY_INDICATORS',
'FrameClassInfo',
# Movie databases # Movie databases
'MOVIE_DB_DICT', 'MOVIE_DB_DICT',
# Special editions # Special editions
+10 -1
View File
@@ -7,6 +7,15 @@ Also includes non-standard quality indicators that appear in filenames but don't
represent specific resolutions. represent specific resolutions.
""" """
from typing import TypedDict
class FrameClassInfo(TypedDict):
"""Information about a video frame class."""
nominal_height: int
typical_widths: list[int]
description: str
# Non-standard quality indicators that don't have specific resolution values # Non-standard quality indicators that don't have specific resolution values
# These are used in filenames to indicate quality but aren't proper frame classes # These are used in filenames to indicate quality but aren't proper frame classes
# When found, we return None instead of trying to classify them # When found, we return None instead of trying to classify them
@@ -15,7 +24,7 @@ represent specific resolutions.
# the exact resolution, so we treat them as non-standard indicators # the exact resolution, so we treat them as non-standard indicators
NON_STANDARD_QUALITY_INDICATORS = ['SD', 'LQ', 'HD', 'QHD', 'FHD', 'FullHD', '4K', '8K'] NON_STANDARD_QUALITY_INDICATORS = ['SD', 'LQ', 'HD', 'QHD', 'FHD', 'FullHD', '4K', '8K']
FRAME_CLASSES = { FRAME_CLASSES: dict[str, FrameClassInfo] = {
"480p": { "480p": {
"nominal_height": 480, "nominal_height": 480,
"typical_widths": [640, 704, 720], "typical_widths": [640, 704, 720],
+1 -1
View File
@@ -87,7 +87,7 @@ MEDIA_TYPES = {
# Reverse mapping: meta_type -> list of extensions # Reverse mapping: meta_type -> list of extensions
# Built once at module load instead of rebuilding in every extractor instance # Built once at module load instead of rebuilding in every extractor instance
META_TYPE_TO_EXTENSIONS = {} META_TYPE_TO_EXTENSIONS: dict[str, list[str]] = {}
for ext, info in MEDIA_TYPES.items(): for ext, info in MEDIA_TYPES.items():
meta_type = info.get('meta_type') meta_type = info.get('meta_type')
if meta_type: if meta_type:
+2 -1
View File
@@ -1,6 +1,7 @@
from pathlib import Path from pathlib import Path
from pymediainfo import MediaInfo from pymediainfo import MediaInfo
from collections import Counter from collections import Counter
from typing import Any
from ..constants import FRAME_CLASSES, get_extension_from_format from ..constants import FRAME_CLASSES, get_extension_from_format
from ..cache import cached_method, Cache from ..cache import cached_method, Cache
import langcodes import langcodes
@@ -52,7 +53,7 @@ class MediaInfoExtractor:
Cache() if use_cache else None Cache() if use_cache else None
) # Singleton cache for @cached_method decorator ) # Singleton cache for @cached_method decorator
self.settings = None # Will be set by Settings singleton if needed self.settings = None # Will be set by Settings singleton if needed
self._cache = {} # Internal cache for method results self._cache: dict[str, Any] = {} # Internal cache for method results
@cached_method() @cached_method()
def _get_media_info(self) -> MediaInfo | None: def _get_media_info(self) -> MediaInfo | None:
+2 -6
View File
@@ -1,7 +1,3 @@
import json
import os
import time
import hashlib
import requests import requests
import logging import logging
from pathlib import Path from pathlib import Path
@@ -17,7 +13,7 @@ class TMDBExtractor:
self.cache = Cache() if use_cache else None # Singleton cache self.cache = Cache() if use_cache else None # Singleton cache
self.settings = Settings() # Singleton settings self.settings = Settings() # Singleton settings
self.ttl_seconds = self.settings.get("cache_ttl_extractors", 21600) self.ttl_seconds = self.settings.get("cache_ttl_extractors", 21600)
self._movie_db_info = None self._movie_db_info: Optional[Dict[str, Any]] = None
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"""
@@ -301,5 +297,5 @@ class TMDBExtractor:
local_path = self.cache.set_image(cache_key, image_data, self.ttl_seconds) local_path = self.cache.set_image(cache_key, image_data, self.ttl_seconds)
return str(local_path) if local_path else None return str(local_path) if local_path else None
except requests.RequestException as e: except requests.RequestException as e:
logging.warning(f"Failed to download poster from {poster_url}: {e}") logging.warning(f"Failed to download poster from {url}: {e}")
return None return None
+3 -3
View File
@@ -5,7 +5,7 @@ should inherit from. This ensures a consistent interface and enables type checki
""" """
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import Any from typing import Any, Callable
class Formatter(ABC): class Formatter(ABC):
@@ -106,7 +106,7 @@ class MarkupFormatter(Formatter):
pass pass
class CompositeFormatter(Formatter): class CompositeFormatter:
"""Formatter that applies multiple formatters in sequence. """Formatter that applies multiple formatters in sequence.
This class allows chaining multiple formatters together in a specific order. This class allows chaining multiple formatters together in a specific order.
@@ -122,7 +122,7 @@ class CompositeFormatter(Formatter):
formatters: List of formatter functions to apply in order formatters: List of formatter functions to apply in order
""" """
def __init__(self, formatters: list[callable]): def __init__(self, formatters: list[Callable[..., Any]]):
"""Initialize the composite formatter. """Initialize the composite formatter.
Args: Args:
+9 -7
View File
@@ -1,6 +1,7 @@
from .text_formatter import TextFormatter from .text_formatter import TextFormatter
from src.views.posters import AsciiPosterRenderer, ViuPosterRenderer, RichPixelsPosterRenderer from src.views.posters import AsciiPosterRenderer, ViuPosterRenderer, RichPixelsPosterRenderer
from typing import Union from typing import Union, Any
from io import StringIO
import os import os
@@ -11,7 +12,7 @@ class CatalogFormatter:
self.extractor = extractor self.extractor = extractor
self.settings = settings self.settings = settings
def format_catalog_info(self) -> tuple[str, Union[str, object]]: def format_catalog_info(self) -> tuple[str, Any]:
"""Format catalog information for display. """Format catalog information for display.
Returns: Returns:
@@ -66,18 +67,18 @@ class CatalogFormatter:
text_content = "\n\n".join(lines) if lines else "No catalog information available" text_content = "\n\n".join(lines) if lines else "No catalog information available"
from rich.console import Console from rich.console import Console
from io import StringIO
console = Console(file=StringIO(), width=120, legacy_windows=False) sio = StringIO()
console = Console(file=sio, width=120, legacy_windows=False)
console.print(text_content, markup=True) console.print(text_content, markup=True)
rendered_text = console.file.getvalue() rendered_text = sio.getvalue()
# Get poster separately # Get poster separately
poster_content = self.get_poster() poster_content = self.get_poster()
return rendered_text, poster_content return rendered_text, poster_content
def get_poster(self) -> Union[str, object]: def get_poster(self) -> Any:
"""Get poster content for separate display. """Get poster content for separate display.
Returns: Returns:
@@ -99,7 +100,7 @@ class CatalogFormatter:
return f"{TextFormatter.bold('Poster:')} {poster_path} (not cached yet)" return f"{TextFormatter.bold('Poster:')} {poster_path} (not cached yet)"
return "" return ""
def _display_poster(self, image_path: str, mode: str) -> Union[str, object]: def _display_poster(self, image_path: str, mode: str) -> Any:
"""Display poster image based on mode setting. """Display poster image based on mode setting.
Args: Args:
@@ -113,6 +114,7 @@ class CatalogFormatter:
return f"Image file not found: {image_path}" return f"Image file not found: {image_path}"
# Select renderer based on mode # Select renderer based on mode
renderer: Union[ViuPosterRenderer, AsciiPosterRenderer, RichPixelsPosterRenderer]
if mode == "viu": if mode == "viu":
renderer = ViuPosterRenderer() renderer = ViuPosterRenderer()
elif mode == "pseudo": elif mode == "pseudo":
+5 -4
View File
@@ -4,11 +4,12 @@ class SizeFormatter:
@staticmethod @staticmethod
def format_size(bytes_size: int) -> str: def format_size(bytes_size: int) -> str:
"""Format bytes to human readable with unit""" """Format bytes to human readable with unit"""
size: float = bytes_size
for unit in ['B', 'KB', 'MB', 'GB', 'TB']: for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
if bytes_size < 1024: if size < 1024:
return f"{bytes_size:.1f} {unit}" return f"{size:.1f} {unit}"
bytes_size /= 1024 size /= 1024
return f"{bytes_size:.1f} TB" return f"{size:.1f} TB"
@staticmethod @staticmethod
def format_size_full(bytes_size: int) -> str: def format_size_full(bytes_size: int) -> str:
+1 -1
View File
@@ -287,7 +287,7 @@ class ConversionService:
logger.debug(f"Expanded languages from '{audio_langs_str}' to: {langs}") logger.debug(f"Expanded languages from '{audio_langs_str}' to: {langs}")
# Map to tracks (pad with None if needed) # Map to tracks (pad with None if needed)
result = [] result: List[Optional[str]] = []
for i in range(audio_track_count): for i in range(audio_track_count):
if i < len(langs): if i < len(langs):
result.append(langs[i]) result.append(langs[i])
+4 -5
View File
@@ -9,7 +9,7 @@ This service manages the extraction of metadata from media files with:
import logging import logging
from pathlib import Path from pathlib import Path
from typing import Optional, Callable from typing import Optional, Callable, Any
from concurrent.futures import ThreadPoolExecutor, Future from concurrent.futures import ThreadPoolExecutor, Future
from threading import Lock from threading import Lock
@@ -169,12 +169,11 @@ class MetadataService:
mode = self.settings.get("mode") mode = self.settings.get("mode")
# Format based on mode # Format based on mode
formatted_info: Any
if mode == "technical": if mode == "technical":
formatter = MediaPanelView(extractor) formatted_info = MediaPanelView(extractor).file_info_panel()
formatted_info = formatter.file_info_panel()
else: # catalog else: # catalog
formatter = CatalogFormatter(extractor, self.settings) formatted_info = CatalogFormatter(extractor, self.settings).format_catalog_info()
formatted_info = formatter.format_catalog_info()
# Generate proposed name # Generate proposed name
proposed_formatter = ProposedFilenameView(extractor) proposed_formatter = ProposedFilenameView(extractor)
+2 -2
View File
@@ -247,13 +247,13 @@ class RenameService:
is_valid, error = self.validate_filename(sanitized_filename) is_valid, error = self.validate_filename(sanitized_filename)
if not is_valid: if not is_valid:
logger.error(f"Invalid filename: {error}") logger.error(f"Invalid filename: {error}")
return False, error return False, error or "Invalid filename"
# Check for conflicts # Check for conflicts
has_conflict, conflict_msg = self.check_name_conflict(source_path, sanitized_filename) has_conflict, conflict_msg = self.check_name_conflict(source_path, sanitized_filename)
if has_conflict: if has_conflict:
logger.warning(f"Name conflict: {conflict_msg}") logger.warning(f"Name conflict: {conflict_msg}")
return False, conflict_msg return False, conflict_msg or "Name conflict"
# Build the new path # Build the new path
new_path = source_path.parent / sanitized_filename new_path = source_path.parent / sanitized_filename
+1
View File
@@ -22,6 +22,7 @@ class Settings:
_instance: Optional['Settings'] = None _instance: Optional['Settings'] = None
_lock = threading.Lock() _lock = threading.Lock()
_initialized: bool
def __new__(cls, config_dir: Path | None = None): def __new__(cls, config_dir: Path | None = None):
"""Create or return singleton instance.""" """Create or return singleton instance."""
+1
View File
@@ -540,3 +540,4 @@ class MediaPanelProperties:
@track_decorators.subtitle_track() @track_decorators.subtitle_track()
def subtitle_track(self, track, index) -> str: def subtitle_track(self, track, index) -> str:
"""Get subtitle track info formatted with label.""" """Get subtitle track info formatted with label."""
return track
+4 -3
View File
@@ -1,12 +1,13 @@
"""ASCII art poster renderer.""" """ASCII art poster renderer."""
from .base import PosterRenderer from .base import PosterRenderer
from typing import Any
class AsciiPosterRenderer(PosterRenderer): class AsciiPosterRenderer(PosterRenderer):
"""Render posters as ASCII art using PIL.""" """Render posters as ASCII art using PIL."""
def render(self, image_path: str, width: int = 35) -> str: def render(self, image_path: str, width: int = 35) -> Any:
"""Render poster as ASCII art. """Render poster as ASCII art.
Args: Args:
@@ -28,7 +29,7 @@ class AsciiPosterRenderer(PosterRenderer):
from PIL import Image, ImageEnhance from PIL import Image, ImageEnhance
# Open image # Open image
img = Image.open(image_path) img: Any = Image.open(image_path)
# Enhance contrast for better detail # Enhance contrast for better detail
enhancer = ImageEnhance.Contrast(img) enhancer = ImageEnhance.Contrast(img)
@@ -57,7 +58,7 @@ class AsciiPosterRenderer(PosterRenderer):
# Map pixel brightness to character # Map pixel brightness to character
# Invert: 0 (black) -> dark char, 255 (white) -> light char # Invert: 0 (black) -> dark char, 255 (white) -> light char
char_index = (255 - avg) * (len(ascii_chars) - 1) // 255 char_index = int((255 - avg) * (len(ascii_chars) - 1) // 255)
char = ascii_chars[char_index] char = ascii_chars[char_index]
row.append(char) row.append(char)
ascii_art.append(''.join(row)) ascii_art.append(''.join(row))
+4 -3
View File
@@ -1,6 +1,7 @@
"""Base class for poster renderers.""" """Base class for poster renderers."""
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import Any
import os import os
@@ -8,15 +9,15 @@ class PosterRenderer(ABC):
"""Abstract base class for poster rendering implementations.""" """Abstract base class for poster rendering implementations."""
@abstractmethod @abstractmethod
def render(self, image_path: str, width: int = 40) -> str: def render(self, image_path: str, width: int = 40) -> Any:
"""Render a poster image to a string. """Render a poster image.
Args: Args:
image_path: Path to the poster image file image_path: Path to the poster image file
width: Desired width in characters width: Desired width in characters
Returns: Returns:
Rendered poster as a string Rendered poster as a string or Rich Renderable
""" """
pass pass
+2 -2
View File
@@ -1,13 +1,13 @@
"""Rich-pixels renderer for high-quality terminal image display.""" """Rich-pixels renderer for high-quality terminal image display."""
from .base import PosterRenderer from .base import PosterRenderer
from typing import Union from typing import Any
class RichPixelsPosterRenderer(PosterRenderer): class RichPixelsPosterRenderer(PosterRenderer):
"""Render posters using rich-pixels library for high-quality display.""" """Render posters using rich-pixels library for high-quality display."""
def render(self, image_path: str, width: int = 40) -> Union[str, object]: def render(self, image_path: str, width: int = 40) -> Any:
"""Render poster using rich-pixels. """Render poster using rich-pixels.
Args: Args:
Generated
+14
View File
@@ -224,6 +224,7 @@ dependencies = [
[package.optional-dependencies] [package.optional-dependencies]
dev = [ dev = [
{ name = "mypy" }, { name = "mypy" },
{ name = "types-requests" },
] ]
[package.metadata] [package.metadata]
@@ -237,6 +238,7 @@ requires-dist = [
{ name = "requests", specifier = ">=2.31.0" }, { name = "requests", specifier = ">=2.31.0" },
{ name = "rich-pixels", specifier = ">=1.0.0" }, { name = "rich-pixels", specifier = ">=1.0.0" },
{ name = "textual", specifier = ">=6.11.0" }, { name = "textual", specifier = ">=6.11.0" },
{ name = "types-requests", marker = "extra == 'dev'", specifier = ">=2.31.0" },
] ]
provides-extras = ["dev"] provides-extras = ["dev"]
@@ -501,6 +503,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b5/fc/5e2988590ff2e0128eea6446806c904445a44e17256c67141573ea16b5a5/textual-6.11.0-py3-none-any.whl", hash = "sha256:9e663b73ed37123a9b13c16a0c85e09ef917a4cfded97814361ed5cccfa40f89", size = 714886, upload-time = "2025-12-18T10:48:36.269Z" }, { url = "https://files.pythonhosted.org/packages/b5/fc/5e2988590ff2e0128eea6446806c904445a44e17256c67141573ea16b5a5/textual-6.11.0-py3-none-any.whl", hash = "sha256:9e663b73ed37123a9b13c16a0c85e09ef917a4cfded97814361ed5cccfa40f89", size = 714886, upload-time = "2025-12-18T10:48:36.269Z" },
] ]
[[package]]
name = "types-requests"
version = "2.33.0.20260408"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/69/6a/749dc53a54a3f35842c1f8197b3ca6b54af6d7458a1bfc75f6629b6da666/types_requests-2.33.0.20260408.tar.gz", hash = "sha256:95b9a86376807a216b2fb412b47617b202091c3ea7c078f47cc358d5528ccb7b", size = 23882, upload-time = "2026-04-08T04:34:49.33Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/90/b8/78fd6c037de4788c040fdd323b3369804400351b7827473920f6c1d03c10/types_requests-2.33.0.20260408-py3-none-any.whl", hash = "sha256:81f31d5ea4acb39f03be7bc8bed569ba6d5a9c5d97e89f45ac43d819b68ca50f", size = 20739, upload-time = "2026-04-08T04:34:48.325Z" },
]
[[package]] [[package]]
name = "typing-extensions" name = "typing-extensions"
version = "4.15.0" version = "4.15.0"