diff --git a/pyproject.toml b/pyproject.toml index bdb635f..d593f56 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,7 @@ dependencies = [ [project.optional-dependencies] dev = [ "mypy>=1.0.0", + "types-requests>=2.31.0", ] [project.scripts] diff --git a/src/app.py b/src/app.py index 0b6e0c2..43a29d1 100644 --- a/src/app.py +++ b/src/app.py @@ -6,6 +6,7 @@ from textual.command import Provider, Hit from rich.markup import escape from pathlib import Path from functools import partial +from typing import TYPE_CHECKING, cast, Any import threading import logging @@ -43,7 +44,7 @@ class CacheCommandProvider(Provider): yield Hit( score, 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 ) @@ -208,11 +209,11 @@ class MomaApp(App): # File type icons icons = { - 'mkv': '󰈫', # Video camera for MKV - 'mk3d': '󰟽', # Clapper board for 3D - 'mp4': '󰎁', # Video camera - 'mov': '󰎁', # Video camera - 'webm': '', # Video camera + 'mkv': '🎥', # Video camera for MKV + 'mk3d': '🕹️', # Clapper board for 3D + 'mp4': '🎥', # Video camera + 'mov': '🎥', # Video camera + 'webm': '🎥', # Video camera 'avi': '💿', # Film frames for AVI 'wmv': '📀', # Video camera 'm4v': '📹', # Video camera @@ -308,14 +309,12 @@ class MomaApp(App): extractor = MediaExtractor(file_path) mode = self.settings.get("mode") - poster_content = "" + poster_content: Any = "" if mode == "technical": - formatter = MediaPanelView(extractor) - full_info = formatter.file_info_panel() + full_info = MediaPanelView(extractor).file_info_panel() else: # catalog - formatter = CatalogFormatter(extractor, self.settings) - full_info, poster_content = formatter.format_catalog_info() + full_info, poster_content = CatalogFormatter(extractor, self.settings).format_catalog_info() # Update UI self.call_later( diff --git a/src/cache/core.py b/src/cache/core.py index 7290673..1538fc1 100644 --- a/src/cache/core.py +++ b/src/cache/core.py @@ -16,6 +16,7 @@ class Cache: _instance: Optional['Cache'] = None _lock_init = threading.Lock() + _initialized: bool def __new__(cls, cache_dir: Optional[Path] = None): """Create or return singleton instance.""" diff --git a/src/constants/__init__.py b/src/constants/__init__.py index dc92ccb..c6d1b56 100644 --- a/src/constants/__init__.py +++ b/src/constants/__init__.py @@ -18,7 +18,7 @@ from .media_constants import ( get_extension_from_format ) 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 .edition_constants import SPECIAL_EDITIONS from .lang_constants import SKIP_WORDS @@ -35,6 +35,7 @@ __all__ = [ # Frame classes 'FRAME_CLASSES', 'NON_STANDARD_QUALITY_INDICATORS', + 'FrameClassInfo', # Movie databases 'MOVIE_DB_DICT', # Special editions diff --git a/src/constants/frame_constants.py b/src/constants/frame_constants.py index aeb899a..89e085e 100644 --- a/src/constants/frame_constants.py +++ b/src/constants/frame_constants.py @@ -7,6 +7,15 @@ Also includes non-standard quality indicators that appear in filenames but don't 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 # 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 @@ -15,7 +24,7 @@ represent specific resolutions. # the exact resolution, so we treat them as non-standard indicators NON_STANDARD_QUALITY_INDICATORS = ['SD', 'LQ', 'HD', 'QHD', 'FHD', 'FullHD', '4K', '8K'] -FRAME_CLASSES = { +FRAME_CLASSES: dict[str, FrameClassInfo] = { "480p": { "nominal_height": 480, "typical_widths": [640, 704, 720], diff --git a/src/constants/media_constants.py b/src/constants/media_constants.py index 99d9908..493138e 100644 --- a/src/constants/media_constants.py +++ b/src/constants/media_constants.py @@ -87,7 +87,7 @@ MEDIA_TYPES = { # Reverse mapping: meta_type -> list of extensions # 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(): meta_type = info.get('meta_type') if meta_type: diff --git a/src/extractors/mediainfo_extractor.py b/src/extractors/mediainfo_extractor.py index 71d50a4..52f3e18 100644 --- a/src/extractors/mediainfo_extractor.py +++ b/src/extractors/mediainfo_extractor.py @@ -1,6 +1,7 @@ from pathlib import Path from pymediainfo import MediaInfo from collections import Counter +from typing import Any from ..constants import FRAME_CLASSES, get_extension_from_format from ..cache import cached_method, Cache import langcodes @@ -52,7 +53,7 @@ class MediaInfoExtractor: Cache() if use_cache else None ) # Singleton cache for @cached_method decorator 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() def _get_media_info(self) -> MediaInfo | None: diff --git a/src/extractors/tmdb_extractor.py b/src/extractors/tmdb_extractor.py index 3fd72bf..4c753db 100644 --- a/src/extractors/tmdb_extractor.py +++ b/src/extractors/tmdb_extractor.py @@ -1,7 +1,3 @@ -import json -import os -import time -import hashlib import requests import logging from pathlib import Path @@ -17,7 +13,7 @@ class TMDBExtractor: self.cache = Cache() if use_cache else None # Singleton cache self.settings = Settings() # Singleton settings 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]]: """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) return str(local_path) if local_path else None 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 diff --git a/src/formatters/base.py b/src/formatters/base.py index c24dd1d..f202907 100644 --- a/src/formatters/base.py +++ b/src/formatters/base.py @@ -5,7 +5,7 @@ should inherit from. This ensures a consistent interface and enables type checki """ from abc import ABC, abstractmethod -from typing import Any +from typing import Any, Callable class Formatter(ABC): @@ -106,7 +106,7 @@ class MarkupFormatter(Formatter): pass -class CompositeFormatter(Formatter): +class CompositeFormatter: """Formatter that applies multiple formatters in sequence. 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 """ - def __init__(self, formatters: list[callable]): + def __init__(self, formatters: list[Callable[..., Any]]): """Initialize the composite formatter. Args: diff --git a/src/formatters/catalog_formatter.py b/src/formatters/catalog_formatter.py index 498e969..5c8380c 100644 --- a/src/formatters/catalog_formatter.py +++ b/src/formatters/catalog_formatter.py @@ -1,6 +1,7 @@ from .text_formatter import TextFormatter from src.views.posters import AsciiPosterRenderer, ViuPosterRenderer, RichPixelsPosterRenderer -from typing import Union +from typing import Union, Any +from io import StringIO import os @@ -11,7 +12,7 @@ class CatalogFormatter: self.extractor = extractor 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. Returns: @@ -66,18 +67,18 @@ class CatalogFormatter: text_content = "\n\n".join(lines) if lines else "No catalog information available" 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) - rendered_text = console.file.getvalue() + rendered_text = sio.getvalue() # Get poster separately poster_content = self.get_poster() return rendered_text, poster_content - def get_poster(self) -> Union[str, object]: + def get_poster(self) -> Any: """Get poster content for separate display. Returns: @@ -99,7 +100,7 @@ class CatalogFormatter: return f"{TextFormatter.bold('Poster:')} {poster_path} (not cached yet)" 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. Args: @@ -113,6 +114,7 @@ class CatalogFormatter: return f"Image file not found: {image_path}" # Select renderer based on mode + renderer: Union[ViuPosterRenderer, AsciiPosterRenderer, RichPixelsPosterRenderer] if mode == "viu": renderer = ViuPosterRenderer() elif mode == "pseudo": diff --git a/src/formatters/size_formatter.py b/src/formatters/size_formatter.py index e364821..3bcdf3c 100644 --- a/src/formatters/size_formatter.py +++ b/src/formatters/size_formatter.py @@ -4,11 +4,12 @@ class SizeFormatter: @staticmethod def format_size(bytes_size: int) -> str: """Format bytes to human readable with unit""" + size: float = bytes_size for unit in ['B', 'KB', 'MB', 'GB', 'TB']: - if bytes_size < 1024: - return f"{bytes_size:.1f} {unit}" - bytes_size /= 1024 - return f"{bytes_size:.1f} TB" + if size < 1024: + return f"{size:.1f} {unit}" + size /= 1024 + return f"{size:.1f} TB" @staticmethod def format_size_full(bytes_size: int) -> str: diff --git a/src/services/conversion_service.py b/src/services/conversion_service.py index e284761..b78f996 100644 --- a/src/services/conversion_service.py +++ b/src/services/conversion_service.py @@ -287,7 +287,7 @@ class ConversionService: logger.debug(f"Expanded languages from '{audio_langs_str}' to: {langs}") # Map to tracks (pad with None if needed) - result = [] + result: List[Optional[str]] = [] for i in range(audio_track_count): if i < len(langs): result.append(langs[i]) diff --git a/src/services/metadata_service.py b/src/services/metadata_service.py index 6096a1f..c330a1c 100644 --- a/src/services/metadata_service.py +++ b/src/services/metadata_service.py @@ -9,7 +9,7 @@ This service manages the extraction of metadata from media files with: import logging from pathlib import Path -from typing import Optional, Callable +from typing import Optional, Callable, Any from concurrent.futures import ThreadPoolExecutor, Future from threading import Lock @@ -169,12 +169,11 @@ class MetadataService: mode = self.settings.get("mode") # Format based on mode + formatted_info: Any if mode == "technical": - formatter = MediaPanelView(extractor) - formatted_info = formatter.file_info_panel() + formatted_info = MediaPanelView(extractor).file_info_panel() else: # catalog - formatter = CatalogFormatter(extractor, self.settings) - formatted_info = formatter.format_catalog_info() + formatted_info = CatalogFormatter(extractor, self.settings).format_catalog_info() # Generate proposed name proposed_formatter = ProposedFilenameView(extractor) diff --git a/src/services/rename_service.py b/src/services/rename_service.py index 1449929..308b128 100644 --- a/src/services/rename_service.py +++ b/src/services/rename_service.py @@ -247,13 +247,13 @@ class RenameService: is_valid, error = self.validate_filename(sanitized_filename) if not is_valid: logger.error(f"Invalid filename: {error}") - return False, error + return False, error or "Invalid filename" # Check for conflicts has_conflict, conflict_msg = self.check_name_conflict(source_path, sanitized_filename) if has_conflict: logger.warning(f"Name conflict: {conflict_msg}") - return False, conflict_msg + return False, conflict_msg or "Name conflict" # Build the new path new_path = source_path.parent / sanitized_filename diff --git a/src/settings.py b/src/settings.py index 3415d6c..f05eacb 100644 --- a/src/settings.py +++ b/src/settings.py @@ -22,6 +22,7 @@ class Settings: _instance: Optional['Settings'] = None _lock = threading.Lock() + _initialized: bool def __new__(cls, config_dir: Path | None = None): """Create or return singleton instance.""" diff --git a/src/views/media_panel_properties.py b/src/views/media_panel_properties.py index 148ff77..c1e4848 100644 --- a/src/views/media_panel_properties.py +++ b/src/views/media_panel_properties.py @@ -540,3 +540,4 @@ class MediaPanelProperties: @track_decorators.subtitle_track() def subtitle_track(self, track, index) -> str: """Get subtitle track info formatted with label.""" + return track diff --git a/src/views/posters/ascii_renderer.py b/src/views/posters/ascii_renderer.py index 9060029..8c5b8e4 100644 --- a/src/views/posters/ascii_renderer.py +++ b/src/views/posters/ascii_renderer.py @@ -1,12 +1,13 @@ """ASCII art poster renderer.""" from .base import PosterRenderer +from typing import Any class AsciiPosterRenderer(PosterRenderer): """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. Args: @@ -28,7 +29,7 @@ class AsciiPosterRenderer(PosterRenderer): from PIL import Image, ImageEnhance # Open image - img = Image.open(image_path) + img: Any = Image.open(image_path) # Enhance contrast for better detail enhancer = ImageEnhance.Contrast(img) @@ -57,7 +58,7 @@ class AsciiPosterRenderer(PosterRenderer): # Map pixel brightness to character # 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] row.append(char) ascii_art.append(''.join(row)) diff --git a/src/views/posters/base.py b/src/views/posters/base.py index d5d7f87..cd33016 100644 --- a/src/views/posters/base.py +++ b/src/views/posters/base.py @@ -1,6 +1,7 @@ """Base class for poster renderers.""" from abc import ABC, abstractmethod +from typing import Any import os @@ -8,15 +9,15 @@ class PosterRenderer(ABC): """Abstract base class for poster rendering implementations.""" @abstractmethod - def render(self, image_path: str, width: int = 40) -> str: - """Render a poster image to a string. + def render(self, image_path: str, width: int = 40) -> Any: + """Render a poster image. Args: image_path: Path to the poster image file width: Desired width in characters Returns: - Rendered poster as a string + Rendered poster as a string or Rich Renderable """ pass diff --git a/src/views/posters/richpixels_renderer.py b/src/views/posters/richpixels_renderer.py index 4b2450b..b1bae20 100644 --- a/src/views/posters/richpixels_renderer.py +++ b/src/views/posters/richpixels_renderer.py @@ -1,13 +1,13 @@ """Rich-pixels renderer for high-quality terminal image display.""" from .base import PosterRenderer -from typing import Union +from typing import Any class RichPixelsPosterRenderer(PosterRenderer): """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. Args: diff --git a/uv.lock b/uv.lock index ed8265f..50de46a 100644 --- a/uv.lock +++ b/uv.lock @@ -224,6 +224,7 @@ dependencies = [ [package.optional-dependencies] dev = [ { name = "mypy" }, + { name = "types-requests" }, ] [package.metadata] @@ -237,6 +238,7 @@ requires-dist = [ { name = "requests", specifier = ">=2.31.0" }, { name = "rich-pixels", specifier = ">=1.0.0" }, { name = "textual", specifier = ">=6.11.0" }, + { name = "types-requests", marker = "extra == 'dev'", specifier = ">=2.31.0" }, ] 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" }, ] +[[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]] name = "typing-extensions" version = "4.15.0"