From 8274cb4e9f3998cf55577a3bcb340900b1e97d8c Mon Sep 17 00:00:00 2001 From: sHa Date: Sat, 11 Apr 2026 20:49:28 +0300 Subject: [PATCH] feat: Add conversion and deletion confirmation screens, enhance media panel properties - Implemented ConvertConfirmScreen for confirming AVI to MKV conversions with audio and subtitle options. - Added DeleteConfirmScreen for confirming file deletions with detailed file information. - Enhanced MediaPanelView to include additional MediaInfo properties such as video, audio, and subtitle tracks. - Updated MediaPanelProperties to extract and display raw MediaInfo track data. - Introduced HelpScreen for user guidance on application features and navigation. - Created OpenScreen for directory path input with validation. - Developed RenameConfirmScreen for renaming files with user confirmation and editing capabilities. - Added SettingsScreen for configuring application settings, including cache TTL and HEVC encoding options. - Updated imports and module exports in views to accommodate new screens. --- README.md | 2 +- renamer/app.py | 4 +- renamer/extractors/fileinfo_extractor.py | 4 +- renamer/extractors/mediainfo_extractor.py | 505 ++++++++----- renamer/extractors/metadata_extractor.py | 4 +- renamer/formatters/special_info_decorators.py | 2 +- renamer/formatters/special_info_formatter.py | 2 +- renamer/logging_config.py | 3 +- renamer/screens.py | 698 ------------------ renamer/test/test_mediainfo_frame_class.py | 17 +- renamer/views/__init__.py | 12 + renamer/views/convert_confirm_screen.py | 185 +++++ renamer/views/delete_confirm_screen.py | 111 +++ renamer/views/help_screen.py | 68 ++ renamer/views/media_panel.py | 7 +- renamer/views/media_panel_properties.py | 47 +- renamer/views/open_screen.py | 30 + renamer/views/rename_confirm_screen.py | 173 +++++ renamer/views/settings_screen.py | 151 ++++ 19 files changed, 1112 insertions(+), 913 deletions(-) delete mode 100644 renamer/screens.py create mode 100644 renamer/views/convert_confirm_screen.py create mode 100644 renamer/views/delete_confirm_screen.py create mode 100644 renamer/views/help_screen.py create mode 100644 renamer/views/open_screen.py create mode 100644 renamer/views/rename_confirm_screen.py create mode 100644 renamer/views/settings_screen.py diff --git a/README.md b/README.md index ee49fe8..60d02ed 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Renamer - Media File Renamer and Metadata Viewer +# moma - Media Manager, File Renamer and Metadata Viewer **Version**: 0.7.0-dev diff --git a/renamer/app.py b/renamer/app.py index 34564ff..cf85236 100644 --- a/renamer/app.py +++ b/renamer/app.py @@ -7,12 +7,11 @@ from rich.markup import escape from pathlib import Path from functools import partial import threading -import time import logging from .logging_config import LoggerConfig # Initialize logging singleton from .constants import MEDIA_TYPES -from .screens import OpenScreen, HelpScreen, RenameConfirmScreen, SettingsScreen, ConvertConfirmScreen +from .views import OpenScreen, HelpScreen, RenameConfirmScreen, SettingsScreen, ConvertConfirmScreen, DeleteConfirmScreen from .extractors.extractor import MediaExtractor from .views import MediaPanelView, ProposedFilenameView from .formatters.text_formatter import TextFormatter @@ -579,7 +578,6 @@ By Category:""" async def action_delete(self): """Delete a file with confirmation.""" - from .screens import DeleteConfirmScreen tree = self.query_one("#file_tree", Tree) node = tree.cursor_node diff --git a/renamer/extractors/fileinfo_extractor.py b/renamer/extractors/fileinfo_extractor.py index fcdf7f3..d440143 100644 --- a/renamer/extractors/fileinfo_extractor.py +++ b/renamer/extractors/fileinfo_extractor.py @@ -44,7 +44,7 @@ class FileInfoExtractor: self.cache = Cache() if use_cache else None # Singleton cache for @cached_method decorator self.settings = None # Will be set by Settings singleton if needed self._stat = file_path.stat() - self._cache: dict[str, any] = {} # Internal cache for method results + self._cache: dict = {} # Internal cache for method results @cached_method() def extract_size(self) -> int: @@ -91,4 +91,4 @@ class FileInfoExtractor: or None if no extension exists """ ext = self._file_path.suffix.lower().lstrip('.') - return ext if ext else None \ No newline at end of file + return ext if ext else None diff --git a/renamer/extractors/mediainfo_extractor.py b/renamer/extractors/mediainfo_extractor.py index 32bef90..c9a54af 100644 --- a/renamer/extractors/mediainfo_extractor.py +++ b/renamer/extractors/mediainfo_extractor.py @@ -5,96 +5,240 @@ from ..constants import FRAME_CLASSES, get_extension_from_format from ..cache import cached_method, Cache import langcodes import logging +import functools logger = logging.getLogger(__name__) +def requires_tracks(func): + """Decorator that returns None if media_info has no tracks.""" + + @functools.wraps(func) + def wrapper(self, *args, **kwargs): + media_info = self._get_media_info() + if not media_info or not media_info.tracks: + return None + return func(self, *args, **kwargs) + + return wrapper + + +def requires_tracks_type(track_type: str): + """Decorator that returns None if no tracks of the specified type are available.""" + + def decorator(func): + @functools.wraps(func) + def wrapper(self, *args, **kwargs): + tracks = self._get_tracks(track_type=track_type) + if tracks is None: + return None + if type(tracks) is not list: + return None + if len(tracks) == 0: + return None + return func(self, *args, **kwargs) + + return wrapper + + return decorator + + class MediaInfoExtractor: """Class to extract information from MediaInfo""" def __init__(self, file_path: Path, use_cache: bool = True): self.file_path = file_path - self.cache = Cache() if use_cache else None # Singleton cache for @cached_method decorator + self.cache = ( + 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 - # Parse media info - set to None on failure - self.media_info = MediaInfo.parse(file_path) if file_path.exists() else None - - # Extract tracks - if self.media_info: - self.video_tracks = [t for t in self.media_info.tracks if t.track_type == 'Video'] - self.audio_tracks = [t for t in self.media_info.tracks if t.track_type == 'Audio'] - self.sub_tracks = [t for t in self.media_info.tracks if t.track_type == 'Text'] - else: - self.video_tracks = [] - self.audio_tracks = [] - self.sub_tracks = [] - - def _get_frame_class_from_height(self, height: int) -> str | None: - """Get frame class from video height, finding closest match if exact not found""" - if not height: - return None - - # First try exact match - for frame_class, info in FRAME_CLASSES.items(): - if height == info['nominal_height']: - return frame_class - - # If no exact match, find closest - closest = None - min_diff = float('inf') - for frame_class, info in FRAME_CLASSES.items(): - diff = abs(height - info['nominal_height']) - if diff < min_diff: - min_diff = diff - closest = frame_class - - # Only return if difference is reasonable (within 50 pixels) - if min_diff <= 50: - return closest - return None - @cached_method() + def _get_media_info(self) -> MediaInfo | None: + """Get parsed MediaInfo object, cached. Returns None if no media.""" + if not self.file_path.exists(): + return None + parsed = MediaInfo.parse(self.file_path) + return parsed if parsed else None + + @requires_tracks + def _get_tracks(self, track_type="General") -> list | None: + """Return tracks of given type or specific track by ID.""" + media_info = self._get_media_info() + tracks = [t for t in media_info.tracks if t.track_type == track_type] + return tracks + + def _get_track(self, track_type="General", track_id: int = 0) -> object | None: + """Return tracks of given type or specific track by ID.""" + tracks = self._get_tracks(track_type=track_type) + if tracks is None: + return None + return tracks[track_id] if track_id < len(tracks) else None + + @requires_tracks_type("General") + def extract_general_track(self) -> dict | None: + """Extract general track data""" + general = self._get_track(track_type="General", track_id=0) + result = { + "format": getattr(general, "format", None) or "unknown", + "file_size": getattr(general, "file_size", None), + "duration": getattr(general, "duration", 0) / 1000 + if getattr(general, "duration", None) + else None, + "overall_bit_rate": getattr(general, "overall_bit_rate", None), + "movie_name": getattr(general, "movie_name", None), + "encoded_date": getattr(general, "encoded_date", None), + } + return result + + @requires_tracks_type("Video") + def extract_video_tracks(self) -> list[dict] | None: + """Extract video track data""" + tracks = self._get_tracks(track_type="Video") + # Type assertion: decorator guarantees tracks is a list + assert isinstance(tracks, list) + result = [] + for v in tracks[:2]: # Up to 2 videos + track_data = { + "codec": getattr(v, "format", None) + or getattr(v, "codec", None) + or "unknown", + "width": getattr(v, "width", None), + "height": getattr(v, "height", None), + "bitrate": getattr(v, "bit_rate", None), + "fps": getattr(v, "frame_rate", None), + "profile": getattr(v, "format_profile", None) or "", + "interlaced": getattr(v, "interlaced", None) == "Yes", + "anamorphic": getattr(v, "anamorphic", None) == "Yes", + } + result.append(track_data) + return result if result else None + + @requires_tracks_type("Audio") + def extract_audio_tracks(self) -> list[dict] | None: + """Extract audio track data""" + tracks = self._get_tracks(track_type="Audio") + # Type assertion: decorator guarantees tracks is a list + assert isinstance(tracks, list) + result = [] + for a in tracks[:10]: # Up to 10 audios + track_data = { + "codec": getattr(a, "format", None) + or getattr(a, "codec", None) + or "unknown", + "channels": getattr(a, "channel_s", None), + "language": getattr(a, "language", "und"), + "bitrate": getattr(a, "bit_rate", None), + } + result.append(track_data) + return result if result else None + + @requires_tracks_type("Text") + def extract_subtitle_tracks(self) -> list[dict] | None: + """Extract subtitle track data""" + tracks = self._get_tracks(track_type="Text") + # Type assertion: decorator guarantees tracks is a list + assert isinstance(tracks, list) + result = [] + for s in tracks[:10]: # Up to 10 subs + track_data = { + "language": getattr(s, "language", "und"), + "format": getattr(s, "format", None) + or getattr(s, "codec", None) + or "unknown", + "forced": getattr(s, "forced", None) == "Yes", + "default": getattr(s, "default", None) == "Yes", + } + result.append(track_data) + return result if result else None + + @requires_tracks + @requires_tracks_type("General") def extract_duration(self) -> float | None: """Extract duration from media info in seconds""" - if self.media_info: - for track in self.media_info.tracks: - if track.track_type == 'General': - return getattr(track, 'duration', 0) / 1000 if getattr(track, 'duration', None) else None + tracks = self._get_tracks(track_type="General") + # Type assertion: decorators guarantee tracks is a list + assert isinstance(tracks, list) + for track in tracks: + return ( + getattr(track, "duration", 0) / 1000 + if getattr(track, "duration", None) + else None + ) return None + @requires_tracks_type("Video") + def extract_resolution(self) -> tuple[int, int] | None: + """Extract actual video resolution as (width, height) tuple from media info""" + track = self._get_track(track_type="Video", track_id=0) + width = getattr(track, "width", None) + height = getattr(track, "height", None) + if width is not None and height is not None: + try: + return int(width), int(height) + except (ValueError, TypeError): + return None + return None + + @requires_tracks_type("Video") def extract_frame_class(self) -> str | None: """Extract frame class from media info (480p, 720p, 1080p, etc.)""" - if not self.video_tracks: - return None - height = getattr(self.video_tracks[0], 'height', None) - width = getattr(self.video_tracks[0], 'width', None) - if not height or not width: - return None + track = self._get_track(track_type="Video", track_id=0) - # Check if interlaced - try multiple attributes - # PyMediaInfo may use different attribute names depending on version - scan_type_attr = getattr(self.video_tracks[0], 'scan_type', None) - interlaced = getattr(self.video_tracks[0], 'interlaced', None) + scan_type_attr = getattr(track, "scan_type", None) - logger.debug(f"[{self.file_path.name}] Frame class detection - Resolution: {width}x{height}") - logger.debug(f"[{self.file_path.name}] scan_type attribute: {scan_type_attr!r} (type: {type(scan_type_attr).__name__})") - logger.debug(f"[{self.file_path.name}] interlaced attribute: {interlaced!r} (type: {type(interlaced).__name__})") + interlaced = self.extract_interlaced() + scan_order = getattr(track, "scan_order", None) + + resolution = self.extract_resolution() + if not resolution: + return None + height, width = resolution + + logger.debug( + f"[{self.file_path.name}] Frame class detection - Resolution: {width}x{height}" + ) + logger.debug( + f"[{self.file_path.name}] scan_type attribute: {scan_type_attr!r} (type: {type(scan_type_attr).__name__})" + ) + logger.debug( + f"[{self.file_path.name}] interlaced attribute: {interlaced!r} (type: {type(interlaced).__name__})" + ) + logger.debug( + f"[{self.file_path.name}] scan_order attribute: {scan_order!r} (type: {type(scan_order).__name__})" + ) # Determine scan type from available attributes # Check scan_type first (e.g., "Interlaced", "Progressive", "MBAFF") if scan_type_attr and isinstance(scan_type_attr, str): - scan_type = 'i' if 'interlaced' in scan_type_attr.lower() else 'p' - logger.debug(f"[{self.file_path.name}] Using scan_type: {scan_type_attr!r} -> scan_type={scan_type!r}") - # Then check interlaced flag (e.g., "Yes", "No") - elif interlaced and isinstance(interlaced, str): - scan_type = 'i' if interlaced.lower() in ['yes', 'true', '1'] else 'p' - logger.debug(f"[{self.file_path.name}] Using interlaced: {interlaced!r} -> scan_type={scan_type!r}") + scan_type = "i" if "interlaced" in scan_type_attr.lower() else "p" + logger.debug( + f"[{self.file_path.name}] Using scan_type: {scan_type_attr!r} -> scan_type={scan_type!r}" + ) + # Check scan_order (e.g., "TFF", "BFF" for interlaced, "Progressive" for progressive) + elif scan_order and isinstance(scan_order, str): + scan_type = "i" if scan_order.upper() in ["TFF", "BFF"] else "p" + logger.debug( + f"[{self.file_path.name}] Using scan_order: {scan_order!r} -> scan_type={scan_type!r}" + ) + # Then check interlaced flag from extract_interlaced() method + elif interlaced is True: + scan_type = "i" + logger.debug( + f"[{self.file_path.name}] Using interlaced: True -> scan_type=i" + ) + elif interlaced is False: + scan_type = "p" + logger.debug( + f"[{self.file_path.name}] Using interlaced: False -> scan_type=p" + ) else: # Default to progressive if no information available - scan_type = 'p' - logger.debug(f"[{self.file_path.name}] No scan type info, defaulting to progressive") + scan_type = "p" + logger.debug( + f"[{self.file_path.name}] No scan type info, defaulting to progressive" + ) # Calculate effective height for frame class determination aspect_ratio = 16 / 9 @@ -107,9 +251,9 @@ class MediaInfoExtractor: # Use a larger tolerance (10 pixels) to handle cinema/ultrawide aspect ratios width_matches = [] for frame_class, info in FRAME_CLASSES.items(): - for tw in info['typical_widths']: + for tw in info["typical_widths"]: if abs(width - tw) <= 10 and frame_class.endswith(scan_type): - diff = abs(height - info['nominal_height']) + diff = abs(height - info["nominal_height"]) width_matches.append((frame_class, diff)) if width_matches: @@ -123,67 +267,68 @@ class MediaInfoExtractor: # First try exact match with standard frame classes frame_class = f"{int(round(effective_height))}{scan_type}" if frame_class in FRAME_CLASSES: - logger.debug(f"[{self.file_path.name}] Result (exact height match): {frame_class!r}") + logger.debug( + f"[{self.file_path.name}] Result (exact height match): {frame_class!r}" + ) return frame_class # Find closest standard height match closest_class = None - min_diff = float('inf') + min_diff = float("inf") for fc, info in FRAME_CLASSES.items(): if fc.endswith(scan_type): - diff = abs(effective_height - info['nominal_height']) + diff = abs(effective_height - info["nominal_height"]) if diff < min_diff: min_diff = diff closest_class = fc # Return closest standard match if within reasonable distance (20 pixels) if closest_class and min_diff <= 20: - logger.debug(f"[{self.file_path.name}] Result (closest match, diff={min_diff}): {closest_class!r}") + logger.debug( + f"[{self.file_path.name}] Result (closest match, diff={min_diff}): {closest_class!r}" + ) return closest_class # For non-standard resolutions, create a custom frame class - logger.debug(f"[{self.file_path.name}] Result (custom/non-standard): {frame_class!r}") + logger.debug( + f"[{self.file_path.name}] Result (custom/non-standard): {frame_class!r}" + ) return frame_class - @cached_method() - def extract_resolution(self) -> tuple[int, int] | None: - """Extract actual video resolution as (width, height) tuple from media info""" - if not self.video_tracks: - return None - width = getattr(self.video_tracks[0], 'width', None) - height = getattr(self.video_tracks[0], 'height', None) - if width and height: - return width, height - return None - - @cached_method() + @requires_tracks_type("Video") def extract_aspect_ratio(self) -> str | None: """Extract video aspect ratio from media info""" - if not self.video_tracks: - return None - aspect_ratio = getattr(self.video_tracks[0], 'display_aspect_ratio', None) + tracks = self._get_tracks(track_type="Video") + # Type assertion: decorator guarantees tracks is a list + assert isinstance(tracks, list) + track = tracks[0] + aspect_ratio = getattr(track, "display_aspect_ratio", None) if aspect_ratio: return str(aspect_ratio) return None - @cached_method() + @requires_tracks_type("Video") def extract_hdr(self) -> str | None: """Extract HDR info from media info""" - if not self.video_tracks: - return None - profile = getattr(self.video_tracks[0], 'format_profile', '') or '' - if 'HDR' in profile.upper(): - return 'HDR' + tracks = self._get_tracks(track_type="Video") + # Type assertion: decorator guarantees tracks is a list + assert isinstance(tracks, list) + track = tracks[0] + profile = getattr(track, "format_profile", "") or "" + if "HDR" in profile.upper(): + return "HDR" return None - @cached_method() + @requires_tracks + @requires_tracks_type("Audio") def extract_audio_langs(self) -> str | None: """Extract audio languages from media info""" - if not self.audio_tracks: + tracks = self._get_tracks(track_type="Audio") + if not isinstance(tracks, list): return None langs = [] - for a in self.audio_tracks: - lang_code = getattr(a, 'language', 'und') or 'und' + for a in tracks: + lang_code = getattr(a, "language", "und") or "und" try: # Try to get the 3-letter code lang_obj = langcodes.Language.get(lang_code.lower()) @@ -195,75 +340,26 @@ class MediaInfoExtractor: langs.append(lang_code.lower()[:3]) lang_counts = Counter(langs) - audio_langs = [f"{count}{lang}" if count > 1 else lang for lang, count in lang_counts.items()] - return ','.join(audio_langs) + audio_langs = [ + f"{count}{lang}" if count > 1 else lang + for lang, count in lang_counts.items() + ] + return ",".join(audio_langs) - @cached_method() - def extract_video_tracks(self) -> list[dict]: - """Extract video track data""" - tracks = [] - for v in self.video_tracks[:2]: # Up to 2 videos - track_data = { - 'codec': getattr(v, 'format', None) or getattr(v, 'codec', None) or 'unknown', - 'width': getattr(v, 'width', None), - 'height': getattr(v, 'height', None), - 'bitrate': getattr(v, 'bit_rate', None), - 'fps': getattr(v, 'frame_rate', None), - 'profile': getattr(v, 'format_profile', None) or '', - } - tracks.append(track_data) - return tracks - - @cached_method() - def extract_audio_tracks(self) -> list[dict]: - """Extract audio track data""" - tracks = [] - for a in self.audio_tracks[:10]: # Up to 10 audios - track_data = { - 'codec': getattr(a, 'format', None) or getattr(a, 'codec', None) or 'unknown', - 'channels': getattr(a, 'channel_s', None), - 'language': getattr(a, 'language', None) or 'und', - 'bitrate': getattr(a, 'bit_rate', None), - } - tracks.append(track_data) - return tracks - - @cached_method() - def extract_subtitle_tracks(self) -> list[dict]: - """Extract subtitle track data""" - tracks = [] - for s in self.sub_tracks[:10]: # Up to 10 subs - track_data = { - 'language': getattr(s, 'language', None) or 'und', - 'format': getattr(s, 'format', None) or getattr(s, 'codec', None) or 'unknown', - } - tracks.append(track_data) - return tracks - - @cached_method() def is_3d(self) -> bool: """Check if the video is 3D""" - if not self.video_tracks: + track = self._get_track("Video", 0) + if not track: return False - multi_view = getattr(self.video_tracks[0], 'multi_view_count', None) + multi_view = getattr(track, "multi_view_count", None) if multi_view and int(multi_view) > 1: return True - stereoscopic = getattr(self.video_tracks[0], 'stereoscopic', None) - if stereoscopic == 'Yes': + stereoscopic = getattr(track, "stereoscopic", None) + if stereoscopic == "Yes": return True return False - @cached_method() - def extract_anamorphic(self) -> str | None: - """Extract anamorphic info for 3D videos""" - if not self.video_tracks: - return None - anamorphic = getattr(self.video_tracks[0], 'anamorphic', None) - if anamorphic == 'Yes' and self.is_3d(): - return 'Anamorphic:Yes' - return None - - @cached_method() + @requires_tracks_type("General") def extract_extension(self) -> str | None: """Extract file extension based on container format. @@ -273,12 +369,9 @@ class MediaInfoExtractor: Returns: File extension (e.g., "mp4", "mkv") or None if format is unknown """ - if not self.media_info: - return None - general_track = next((t for t in self.media_info.tracks if t.track_type == 'General'), None) - if not general_track: - return None - format_ = getattr(general_track, 'format', None) + + general_track = self._get_track(track_type="General", track_id=0) + format_ = getattr(general_track, "format", None) if not format_: return None @@ -286,20 +379,24 @@ class MediaInfoExtractor: ext = get_extension_from_format(format_) # Special case: Matroska 3D uses mk3d extension - if ext == 'mkv' and self.is_3d(): - return 'mk3d' + if ext == "mkv" and self.is_3d(): + return "mk3d" return ext - @cached_method() + @requires_tracks_type("Video") def extract_3d_layout(self) -> str | None: """Extract 3D stereoscopic layout from MediaInfo""" if not self.is_3d(): return None - stereoscopic = getattr(self.video_tracks[0], 'stereoscopic', None) + tracks = self._get_tracks(track_type="Video") + # Type assertion: decorator guarantees tracks is a list + assert isinstance(tracks, list) + track = tracks[0] + stereoscopic = getattr(track, "stereoscopic", None) return stereoscopic if stereoscopic else None - @cached_method() + @requires_tracks_type("Video") def extract_interlaced(self) -> bool | None: """Determine if the video is interlaced. @@ -308,39 +405,77 @@ class MediaInfoExtractor: False: Video is progressive (explicitly set) None: Information not available in MediaInfo """ - if not self.video_tracks: - logger.debug(f"[{self.file_path.name}] Interlaced detection: No video tracks") - return None - - scan_type_attr = getattr(self.video_tracks[0], 'scan_type', None) - interlaced = getattr(self.video_tracks[0], 'interlaced', None) + tracks = self._get_tracks(track_type="Video") + # Type assertion: decorator guarantees tracks is a list + assert isinstance(tracks, list) + track = tracks[0] + scan_type_attr = getattr(track, "scan_type", None) + interlaced = getattr(track, "interlaced", None) + scan_order = getattr(track, "scan_order", None) logger.debug(f"[{self.file_path.name}] Interlaced detection:") - logger.debug(f"[{self.file_path.name}] scan_type: {scan_type_attr!r} (type: {type(scan_type_attr).__name__})") - logger.debug(f"[{self.file_path.name}] interlaced: {interlaced!r} (type: {type(interlaced).__name__})") + logger.debug( + f"[{self.file_path.name}] scan_type: {scan_type_attr!r} (type: {type(scan_type_attr).__name__})" + ) + logger.debug( + f"[{self.file_path.name}] interlaced: {interlaced!r} (type: {type(interlaced).__name__})" + ) + logger.debug( + f"[{self.file_path.name}] scan_order: {scan_order!r} (type: {type(scan_order).__name__})" + ) # Check scan_type attribute first (e.g., "Interlaced", "Progressive", "MBAFF") if scan_type_attr and isinstance(scan_type_attr, str): scan_lower = scan_type_attr.lower() - if 'interlaced' in scan_lower or 'mbaff' in scan_lower: - logger.debug(f"[{self.file_path.name}] Result: True (from scan_type={scan_type_attr!r})") + if "interlaced" in scan_lower or "mbaff" in scan_lower: + logger.debug( + f"[{self.file_path.name}] Result: True (from scan_type={scan_type_attr!r})" + ) return True - elif 'progressive' in scan_lower: - logger.debug(f"[{self.file_path.name}] Result: False (from scan_type={scan_type_attr!r})") + elif "progressive" in scan_lower: + logger.debug( + f"[{self.file_path.name}] Result: False (from scan_type={scan_type_attr!r})" + ) return False - # If scan_type has some other value, fall through to check interlaced - logger.debug(f"[{self.file_path.name}] scan_type unrecognized, checking interlaced attribute") + # If scan_type has some other value, fall through to check other attributes + logger.debug( + f"[{self.file_path.name}] scan_type unrecognized, checking other attributes" + ) + + # Check scan_order attribute (e.g., "TFF", "BFF" for interlaced, "Progressive" for progressive) + if scan_order and isinstance(scan_order, str): + scan_order_upper = scan_order.upper() + if scan_order_upper in ["TFF", "BFF"]: + logger.debug( + f"[{self.file_path.name}] Result: True (from scan_order={scan_order!r})" + ) + return True + elif scan_order_upper == "PROGRESSIVE": + logger.debug( + f"[{self.file_path.name}] Result: False (from scan_order={scan_order!r})" + ) + return False + # If scan_order has some other value, fall through + logger.debug( + f"[{self.file_path.name}] scan_order unrecognized, checking interlaced attribute" + ) # Check interlaced attribute (e.g., "Yes", "No") if interlaced and isinstance(interlaced, str): interlaced_lower = interlaced.lower() - if interlaced_lower in ['yes', 'true', '1']: - logger.debug(f"[{self.file_path.name}] Result: True (from interlaced={interlaced!r})") + if interlaced_lower in ["yes", "true", "1"]: + logger.debug( + f"[{self.file_path.name}] Result: True (from interlaced={interlaced!r})" + ) return True - elif interlaced_lower in ['no', 'false', '0']: - logger.debug(f"[{self.file_path.name}] Result: False (from interlaced={interlaced!r})") + elif interlaced_lower in ["no", "false", "0"]: + logger.debug( + f"[{self.file_path.name}] Result: False (from interlaced={interlaced!r})" + ) return False # No information available - logger.debug(f"[{self.file_path.name}] Result: None (no information available)") + logger.debug( + f"[{self.file_path.name}] Result: None (no information available)" + ) return None diff --git a/renamer/extractors/metadata_extractor.py b/renamer/extractors/metadata_extractor.py index d604131..3f45843 100644 --- a/renamer/extractors/metadata_extractor.py +++ b/renamer/extractors/metadata_extractor.py @@ -42,7 +42,7 @@ class MetadataExtractor: self.file_path = file_path self.cache = 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: dict[str, any] = {} # Internal cache for method results + self._cache: dict = {} # Internal cache for method results try: self.info = mutagen.File(file_path) # type: ignore except Exception as e: @@ -114,4 +114,4 @@ class MetadataExtractor: return 'Unknown' except Exception as e: logger.debug(f"Failed to detect MIME type for {self.file_path}: {e}") - return 'Unknown' \ No newline at end of file + return 'Unknown' diff --git a/renamer/formatters/special_info_decorators.py b/renamer/formatters/special_info_decorators.py index 01b0c42..1abf32a 100644 --- a/renamer/formatters/special_info_decorators.py +++ b/renamer/formatters/special_info_decorators.py @@ -43,7 +43,7 @@ class SpecialInfoDecorators: """ def decorator(func: Callable) -> Callable: @wraps(func) - def wrapper(*args, **kwargs) -> str: + def wrapper(*args, **kwargs) -> str | None: result = func(*args, **kwargs) return SpecialInfoFormatter.format_database_info(result) return wrapper diff --git a/renamer/formatters/special_info_formatter.py b/renamer/formatters/special_info_formatter.py index e726259..193976c 100644 --- a/renamer/formatters/special_info_formatter.py +++ b/renamer/formatters/special_info_formatter.py @@ -11,7 +11,7 @@ class SpecialInfoFormatter: return special_info or "" @staticmethod - def format_database_info(database_info): + def format_database_info(database_info) -> str | None: """Format database info dictionary or tuple/list into a string""" import logging import os diff --git a/renamer/logging_config.py b/renamer/logging_config.py index b971cbe..5251ea2 100644 --- a/renamer/logging_config.py +++ b/renamer/logging_config.py @@ -36,8 +36,7 @@ class LoggerConfig: level=logging.DEBUG, format='%(asctime)s - %(levelname)s - %(message)s' ) - else: - logging.basicConfig(level=logging.INFO) + # When FORMATTER_LOG is not '1', do not configure logging at all LoggerConfig._initialized = True diff --git a/renamer/screens.py b/renamer/screens.py deleted file mode 100644 index 722e2b3..0000000 --- a/renamer/screens.py +++ /dev/null @@ -1,698 +0,0 @@ -from textual.screen import Screen -from textual.widgets import Input, Button, Static -from textual.containers import Vertical, Horizontal, Center, Container -from textual.markup import escape -from pathlib import Path -import logging - - -class OpenScreen(Screen): - def compose(self): - yield Input(placeholder="Enter directory path", value=".", id="dir_input") - yield Button("OK", id="ok") - - def on_button_pressed(self, event): - if event.button.id == "ok": - self.submit_path() - - def on_input_submitted(self, event): - self.submit_path() - - def submit_path(self): - path_str = self.query_one("#dir_input", Input).value - path = Path(path_str) - if not path.exists(): - # Show error - self.query_one("#dir_input", Input).value = f"Path does not exist: {path_str}" - return - if not path.is_dir(): - self.query_one("#dir_input", Input).value = f"Not a directory: {path_str}" - return - self.app.scan_dir = path # type: ignore - self.app.scan_files() # type: ignore - self.app.pop_screen() - - -class HelpScreen(Screen): - def compose(self): - try: - from importlib.metadata import version - app_version = version("renamer") - except Exception: - app_version = "unknown" - - help_text = f""" -Media File Renamer v{app_version} - -A powerful tool for analyzing and renaming media files with intelligent metadata extraction. - -NAVIGATION: -• Use arrow keys to navigate the file tree -• Right arrow: Expand directory -• Left arrow: Collapse directory -• Enter/Space: Select file - -ACTIONS: -• o: Open directory - Change the scan directory -• s: Scan - Refresh the current directory -• f: Refresh - Reload metadata for selected file -• r: Rename - Rename selected file with proposed name -• c: Convert - Convert AVI file to MKV container with metadata -• d: Delete - Delete selected file (with confirmation) -• p: Expand/Collapse - Toggle expansion of selected directory -• m: Toggle Mode - Switch between technical and catalog display modes -• ctrl+s: Settings - Open settings window -• ctrl+p: Command Palette - Access cache commands and more -• h: Help - Show this help screen -• q: Quit - Exit the application - -FEATURES: -• Automatic metadata extraction from filenames -• MediaInfo integration for technical details -• Intelligent title, year, and format detection -• Support for special editions and collections -• Real-time file analysis and renaming suggestions - -FILE ANALYSIS: -The app extracts various metadata including: -• Movie/series titles and years -• Video resolution and frame rates -• Audio languages and formats -• Special edition information -• Collection order numbers -• HDR and source information - -Press any key to close this help screen. - """.strip() - - with Vertical(): - yield Static(help_text, id="help_content") - yield Button("Close", id="close") - - def on_button_pressed(self, event): - if event.button.id == "close": - self.app.pop_screen() - - def on_key(self, event): - # Close on any key press - self.app.pop_screen() - - -class RenameConfirmScreen(Screen): - CSS = """ - #confirm_content { - text-align: center; - } - # Button { - # background: $surface; - # border: solid $surface; - # } - Button:focus { - background: $primary; - # color: $text-primary; - # border: solid $primary; - } - #buttons { - align: center middle; - } - #new_name_input { - width: 100%; - margin: 1 0; - } - #new_name_display { - text-align: center; - margin-bottom: 1; - } - #warning_content { - text-align: center; - margin-bottom: 0; - } - """ - - def __init__(self, old_path: Path, new_name: str): - super().__init__() - self.old_path = old_path - self.new_name = new_name.replace("/", "-").replace("\\", "-") - self.new_path = old_path.parent / self.new_name - self.was_edited = False - - def compose(self): - from .formatters.text_formatter import TextFormatter - - confirm_text = f""" -{TextFormatter.bold(TextFormatter.red("RENAME CONFIRMATION"))} - -RAW name: {escape(self.old_path.name)} - -Current name: {TextFormatter.cyan(escape(self.old_path.name))} -Proposed name: {TextFormatter.green(escape(self.new_name))} - -{TextFormatter.yellow("Edit the new name below:")} - """.strip() - - warning_text = f""" -{TextFormatter.bold(TextFormatter.red("This action cannot be undone!"))} -Do you want to proceed with renaming? - """.strip() - - with Center(): - with Vertical(): - yield Static(confirm_text, id="confirm_content", markup=True) - yield Input(value=self.new_name, id="new_name_input", placeholder="New file name") - yield Static(f"{TextFormatter.green(escape(self.new_name))}", id="new_name_display", markup=True) - yield Static(warning_text, id="warning_content", markup=True) - with Horizontal(id="buttons"): - yield Button("Rename (y)", id="rename") - yield Button("Cancel (n)", id="cancel") - - def on_mount(self): - self.set_focus(self.query_one("#rename")) - - def on_input_changed(self, event): - if event.input.id == "new_name_input": - self.new_name = event.input.value.replace("/", "-").replace("\\", "-") - self.new_path = self.old_path.parent / self.new_name - self.was_edited = True - # Update the display - from .formatters.text_formatter import TextFormatter - display = self.query_one("#new_name_display", Static) - display.update(f"{TextFormatter.green(escape(self.new_name))}") - - def on_button_pressed(self, event): - if event.button.id == "rename": - # Check if new name is the same as old name - if self.new_name == self.old_path.name: - self.app.notify("Proposed name is the same as current name; no rename needed.", severity="information", timeout=3) - self.app.pop_screen() - return - - try: - logging.info(f"Starting rename: old_path={self.old_path}, new_path={self.new_path}") - logging.info(f"Old file name: {self.old_path.name}") - logging.info(f"New file name: {self.new_name}") - logging.info(f"New path parent: {self.new_path.parent}, Old path parent: {self.old_path.parent}") - if "/" in self.new_name or "\\" in self.new_name: - logging.warning(f"New name contains path separators: {self.new_name}") - self.old_path.rename(self.new_path) - logging.info(f"Rename successful: {self.old_path} -> {self.new_path}") - # Update the tree node - self.app.update_renamed_file(self.old_path, self.new_path) # type: ignore - self.app.pop_screen() - except Exception as e: - logging.error(f"Rename failed: {self.old_path} -> {self.new_path}, error: {str(e)}") - # Show error - content = self.query_one("#confirm_content", Static) - content.update(f"Error renaming file: {str(e)}") - elif event.button.id == "cancel": - self.app.pop_screen() - - def on_key(self, event): - current = self.focused - if current and hasattr(current, 'id'): - if current.id == "new_name_input": - # When input is focused, let left/right move cursor, use up/down to change focus - if event.key == "up": - self.set_focus(self.query_one("#cancel")) - elif event.key == "down": - self.set_focus(self.query_one("#rename")) - elif current.id in ("rename", "cancel"): - if event.key == "left": - if current.id == "rename": - self.set_focus(self.query_one("#new_name_input")) - elif current.id == "cancel": - self.set_focus(self.query_one("#rename")) - elif event.key == "right": - if current.id == "new_name_input": - self.set_focus(self.query_one("#rename")) - elif current.id == "rename": - self.set_focus(self.query_one("#cancel")) - elif event.key == "up": - if current.id == "rename": - self.set_focus(self.query_one("#new_name_input")) - elif current.id == "cancel": - self.set_focus(self.query_one("#rename")) - elif event.key == "down": - if current.id == "new_name_input": - self.set_focus(self.query_one("#rename")) - elif current.id == "rename": - self.set_focus(self.query_one("#cancel")) - elif current.id == "cancel": - self.set_focus(self.query_one("#new_name_input")) - - # Hotkeys only work when not focused on input - if not current or not hasattr(current, 'id') or current.id != "new_name_input": - if event.key == "y": - # Trigger rename - try: - logging.info(f"Hotkey rename: old_path={self.old_path}, new_path={self.new_path}") - logging.info(f"Old file name: {self.old_path.name}") - logging.info(f"New file name: {self.new_name}") - logging.info(f"New path parent: {self.new_path.parent}, Old path parent: {self.old_path.parent}") - if "/" in self.new_name or "\\" in self.new_name: - logging.warning(f"New name contains path separators: {self.new_name}") - self.old_path.rename(self.new_path) - logging.info(f"Hotkey rename successful: {self.old_path} -> {self.new_path}") - # Update the tree node - self.app.update_renamed_file(self.old_path, self.new_path) # type: ignore - self.app.pop_screen() - except Exception as e: - logging.error(f"Hotkey rename failed: {self.old_path} -> {self.new_path}, error: {str(e)}") - # Show error - content = self.query_one("#confirm_content", Static) - content.update(f"Error renaming file: {str(e)}") - elif event.key == "n": - # Cancel - 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") - - # Poster selection - yield Static("Poster Display (Catalog Mode):", classes="label") - with Horizontal(): - yield Button("No", id="poster_no", variant="primary" if settings.get("poster") == "no" else "default") - yield Button("ASCII", id="poster_pseudo", variant="primary" if settings.get("poster") == "pseudo" else "default") - yield Button("Viu", id="poster_viu", variant="primary" if settings.get("poster") == "viu" else "default") - yield Button("RichPixels", id="poster_richpixels", variant="primary" if settings.get("poster") == "richpixels" else "default") - - # HEVC quality selection - yield Static("HEVC Encoding Quality (for conversions):", classes="label") - with Horizontal(): - yield Button("CRF 18 (Visually Lossless)", id="hevc_crf_18", variant="primary" if settings.get("hevc_crf") == 18 else "default") - yield Button("CRF 23 (High Quality)", id="hevc_crf_23", variant="primary" if settings.get("hevc_crf") == 23 else "default") - yield Button("CRF 28 (Balanced)", id="hevc_crf_28", variant="primary" if settings.get("hevc_crf") == 28 else "default") - - # HEVC preset selection - yield Static("HEVC Encoding Speed (faster = lower quality/smaller file):", classes="label") - with Horizontal(): - yield Button("Ultrafast", id="hevc_preset_ultrafast", variant="primary" if settings.get("hevc_preset") == "ultrafast" else "default") - yield Button("Veryfast", id="hevc_preset_veryfast", variant="primary" if settings.get("hevc_preset") == "veryfast" else "default") - yield Button("Fast", id="hevc_preset_fast", variant="primary" if settings.get("hevc_preset") == "fast" else "default") - yield Button("Medium", id="hevc_preset_medium", variant="primary" if settings.get("hevc_preset") == "medium" 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" - elif event.button.id.startswith("poster_"): - # Toggle poster buttons - poster_mode = event.button.id.split("_", 1)[1] # Use split with maxsplit=1 to handle "richpixels" - self.app.settings.set("poster", poster_mode) # type: ignore - # Update button variants - no_btn = self.query_one("#poster_no", Button) - pseudo_btn = self.query_one("#poster_pseudo", Button) - viu_btn = self.query_one("#poster_viu", Button) - richpixels_btn = self.query_one("#poster_richpixels", Button) - no_btn.variant = "primary" if poster_mode == "no" else "default" - pseudo_btn.variant = "primary" if poster_mode == "pseudo" else "default" - viu_btn.variant = "primary" if poster_mode == "viu" else "default" - richpixels_btn.variant = "primary" if poster_mode == "richpixels" else "default" - elif event.button.id.startswith("hevc_crf_"): - # Toggle HEVC CRF buttons - crf_value = int(event.button.id.split("_")[-1]) - self.app.settings.set("hevc_crf", crf_value) # type: ignore - # Update button variants - crf18_btn = self.query_one("#hevc_crf_18", Button) - crf23_btn = self.query_one("#hevc_crf_23", Button) - crf28_btn = self.query_one("#hevc_crf_28", Button) - crf18_btn.variant = "primary" if crf_value == 18 else "default" - crf23_btn.variant = "primary" if crf_value == 23 else "default" - crf28_btn.variant = "primary" if crf_value == 28 else "default" - elif event.button.id.startswith("hevc_preset_"): - # Toggle HEVC preset buttons - preset_value = event.button.id.split("_")[-1] - self.app.settings.set("hevc_preset", preset_value) # type: ignore - # Update button variants - ultrafast_btn = self.query_one("#hevc_preset_ultrafast", Button) - veryfast_btn = self.query_one("#hevc_preset_veryfast", Button) - fast_btn = self.query_one("#hevc_preset_fast", Button) - medium_btn = self.query_one("#hevc_preset_medium", Button) - ultrafast_btn.variant = "primary" if preset_value == "ultrafast" else "default" - veryfast_btn.variant = "primary" if preset_value == "veryfast" else "default" - fast_btn.variant = "primary" if preset_value == "fast" else "default" - medium_btn.variant = "primary" if preset_value == "medium" 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 - - -class ConvertConfirmScreen(Screen): - """Confirmation screen for AVI to MKV conversion.""" - - CSS = """ - #convert_content { - text-align: center; - } - Button:focus { - background: $primary; - } - #buttons { - align: center middle; - } - #conversion_details { - text-align: left; - margin: 1 2; - padding: 1 2; - border: solid $primary; - } - #warning_content { - text-align: center; - margin-bottom: 1; - margin-top: 1; - } - """ - - def __init__( - self, - avi_path: Path, - mkv_path: Path, - audio_languages: list, - subtitle_files: list, - extractor - ): - super().__init__() - self.avi_path = avi_path - self.mkv_path = mkv_path - self.audio_languages = audio_languages - self.subtitle_files = subtitle_files - self.extractor = extractor - - def compose(self): - from .formatters.text_formatter import TextFormatter - - title_text = f"{TextFormatter.bold(TextFormatter.yellow('MKV CONVERSION'))}" - - # Build details - details_lines = [ - f"{TextFormatter.bold('Source:')} {TextFormatter.cyan(escape(self.avi_path.name))}", - f"{TextFormatter.bold('Output:')} {TextFormatter.green(escape(self.mkv_path.name))}", - "", - f"{TextFormatter.bold('Audio Languages:')}", - ] - - # Add audio language mapping - for i, lang in enumerate(self.audio_languages): - if lang: - details_lines.append(f" Track {i+1}: {TextFormatter.green(lang)}") - else: - details_lines.append(f" Track {i+1}: {TextFormatter.grey('(no language)')}") - - # Add subtitle info - if self.subtitle_files: - details_lines.append("") - details_lines.append(f"{TextFormatter.bold('Subtitles to include:')}") - for sub_file in self.subtitle_files: - details_lines.append(f" • {TextFormatter.blue(escape(sub_file.name))}") - else: - details_lines.append("") - details_lines.append(f"{TextFormatter.grey('No subtitle files found')}") - - details_text = "\n".join(details_lines) - - # Get HEVC CRF from settings - settings = self.app.settings # type: ignore - hevc_crf = settings.get("hevc_crf", 23) - - info_text = f""" -{TextFormatter.bold('Choose conversion mode:')} - -{TextFormatter.green('Copy Mode')} - Fast remux, no re-encoding (seconds to minutes) -{TextFormatter.yellow(f'HEVC Mode')} - Re-encode to H.265, CRF {hevc_crf} quality (minutes to hours) - {TextFormatter.grey('(Change quality in Settings with Ctrl+S)')} - """.strip() - - with Center(): - with Vertical(): - yield Static(title_text, id="convert_content", markup=True) - yield Static(details_text, id="conversion_details", markup=True) - yield Static(info_text, id="info_text", markup=True) - with Horizontal(id="buttons"): - yield Button("Convert Copy (y)", id="convert_copy", variant="success") - yield Button("Convert HEVC (e)", id="convert_hevc", variant="primary") - yield Button("Cancel (n)", id="cancel", variant="error") - - def on_mount(self): - self.set_focus(self.query_one("#convert_copy")) - - def on_button_pressed(self, event): - if event.button.id == "convert_copy": - self._do_conversion(encode_hevc=False) - event.stop() # Prevent key event from also triggering - elif event.button.id == "convert_hevc": - self._do_conversion(encode_hevc=True) - event.stop() # Prevent key event from also triggering - elif event.button.id == "cancel": - self.app.pop_screen() # type: ignore - event.stop() # Prevent key event from also triggering - - def _do_conversion(self, encode_hevc: bool): - """Start conversion with the specified encoding mode.""" - app = self.app # type: ignore - settings = app.settings - - # Get CRF and preset from settings if using HEVC - crf = settings.get("hevc_crf", 23) if encode_hevc else 18 - preset = settings.get("hevc_preset", "fast") if encode_hevc else "medium" - - mode_str = f"HEVC CRF {crf} ({preset})" if encode_hevc else "Copy" - app.notify(f"Starting conversion ({mode_str})...", severity="information", timeout=2) - - def do_conversion(): - from .services.conversion_service import ConversionService - import threading - import logging - - conversion_service = ConversionService() - logging.info(f"Starting conversion of {self.avi_path} with encode_hevc={encode_hevc}, crf={crf}, preset={preset}") - logging.info(f"CPU architecture: {conversion_service.cpu_arch}") - - success, message = conversion_service.convert_avi_to_mkv( - self.avi_path, - extractor=self.extractor, - encode_hevc=encode_hevc, - crf=crf, - preset=preset - ) - - logging.info(f"Conversion result: success={success}, message={message}") - - # Schedule UI updates on the main thread - mkv_path = self.avi_path.with_suffix('.mkv') - - def handle_success(): - logging.info(f"handle_success called: {mkv_path}") - app.notify(f"✓ {message}", severity="information", timeout=5) - logging.info(f"Adding file to tree: {mkv_path}") - app.add_file_to_tree(mkv_path) - logging.info("Conversion success handler completed") - - def handle_error(): - logging.info(f"handle_error called: {message}") - app.notify(f"✗ {message}", severity="error", timeout=10) - logging.info("Conversion error handler completed") - - if success: - logging.info(f"Conversion successful, scheduling UI update for {mkv_path}") - app.call_later(handle_success) - else: - logging.error(f"Conversion failed: {message}") - app.call_later(handle_error) - - # Run conversion in background thread - import threading - threading.Thread(target=do_conversion, daemon=True).start() - - # Close the screen - self.app.pop_screen() # type: ignore - - def on_key(self, event): - if event.key == "y": - # Copy mode - self._do_conversion(encode_hevc=False) - elif event.key == "e": - # HEVC mode - self._do_conversion(encode_hevc=True) - elif event.key == "n" or event.key == "escape": - self.app.pop_screen() # type: ignore - - -class DeleteConfirmScreen(Screen): - """Confirmation screen for file deletion.""" - - CSS = """ - #delete_content { - text-align: center; - } - Button:focus { - background: $primary; - } - #buttons { - align: center middle; - } - #file_details { - text-align: left; - margin: 1 2; - padding: 1 2; - border: solid $error; - } - #warning_content { - text-align: center; - margin-bottom: 1; - margin-top: 1; - } - """ - - def __init__(self, file_path: Path): - super().__init__() - self.file_path = file_path - - def compose(self): - from .formatters.text_formatter import TextFormatter - - title_text = f"{TextFormatter.bold(TextFormatter.red('DELETE FILE'))}" - - # Build file details - file_size = self.file_path.stat().st_size if self.file_path.exists() else 0 - from .formatters.size_formatter import SizeFormatter - size_str = SizeFormatter.format_size_full(file_size) - - details_lines = [ - f"{TextFormatter.bold('File:')} {TextFormatter.cyan(escape(self.file_path.name))}", - f"{TextFormatter.bold('Path:')} {TextFormatter.grey(escape(str(self.file_path.parent)))}", - f"{TextFormatter.bold('Size:')} {TextFormatter.yellow(size_str)}", - ] - - details_text = "\n".join(details_lines) - - warning_text = f""" -{TextFormatter.bold(TextFormatter.red("WARNING: This action cannot be undone!"))} -{TextFormatter.yellow("The file will be permanently deleted from your system.")} - -Are you sure you want to delete this file? - """.strip() - - with Center(): - with Vertical(): - yield Static(title_text, id="delete_content", markup=True) - yield Static(details_text, id="file_details", markup=True) - yield Static(warning_text, id="warning_content", markup=True) - with Horizontal(id="buttons"): - yield Button("No (n)", id="cancel", variant="primary") - yield Button("Yes (y)", id="delete", variant="error") - - def on_mount(self): - # Set focus to "No" button by default (safer option) - self.set_focus(self.query_one("#cancel")) - - def on_button_pressed(self, event): - if event.button.id == "delete": - # Delete the file - app = self.app # type: ignore - - try: - if self.file_path.exists(): - self.file_path.unlink() - app.notify(f"✓ Deleted: {self.file_path.name}", severity="information", timeout=3) - logging.info(f"File deleted: {self.file_path}") - - # Remove from tree - app.remove_file_from_tree(self.file_path) - else: - app.notify(f"✗ File not found: {self.file_path.name}", severity="error", timeout=3) - - except PermissionError: - app.notify(f"✗ Permission denied: Cannot delete {self.file_path.name}", severity="error", timeout=5) - logging.error(f"Permission denied deleting file: {self.file_path}") - except Exception as e: - app.notify(f"✗ Error deleting file: {e}", severity="error", timeout=5) - logging.error(f"Error deleting file {self.file_path}: {e}", exc_info=True) - - self.app.pop_screen() # type: ignore - else: - # Cancel - self.app.pop_screen() # type: ignore - - def on_key(self, event): - if event.key == "y": - # Simulate delete button press - delete_button = self.query_one("#delete") - self.on_button_pressed(type('Event', (), {'button': delete_button})()) - elif event.key == "n" or event.key == "escape": - self.app.pop_screen() # type: ignore \ No newline at end of file diff --git a/renamer/test/test_mediainfo_frame_class.py b/renamer/test/test_mediainfo_frame_class.py index 769ff99..9d9f7e2 100644 --- a/renamer/test/test_mediainfo_frame_class.py +++ b/renamer/test/test_mediainfo_frame_class.py @@ -19,26 +19,29 @@ with open(test_cases_file, 'r', encoding='utf-8') as f: @pytest.mark.parametrize("test_case", test_cases, ids=[tc['testname'] for tc in test_cases]) def test_frame_class_detection(test_case): """Test frame class detection for various resolutions""" - + testname = test_case['testname'] width, height = test_case['resolution'] interlaced = test_case['interlaced'] expected = test_case['expected_frame_class'] - + # Create a mock MediaInfoExtractor extractor = MagicMock(spec=MediaInfoExtractor) from pathlib import Path extractor.file_path = Path(f"test_{testname}") # Set a unique file_path for caching - + # Mock the video_tracks mock_track = MagicMock() mock_track.height = height mock_track.width = width mock_track.interlaced = 'Yes' if interlaced else 'No' - + extractor.video_tracks = [mock_track] - + extractor.extract_resolution.return_value = (height, width) + extractor._video_tracks.return_value = [mock_track] + extractor.extract_interlaced.return_value = interlaced + # Test the method actual = MediaInfoExtractor.extract_frame_class(extractor) - - assert actual == expected, f"{testname}: expected {expected}, got {actual}" \ No newline at end of file + + assert actual == expected, f"{testname}: expected {expected}, got {actual}" diff --git a/renamer/views/__init__.py b/renamer/views/__init__.py index 01e8157..a4ce549 100644 --- a/renamer/views/__init__.py +++ b/renamer/views/__init__.py @@ -7,8 +7,20 @@ orchestrate multiple formatters to build complete UI panels. from .proposed_filename import ProposedFilenameView from .media_panel import MediaPanelView +from .open_screen import OpenScreen +from .help_screen import HelpScreen +from .rename_confirm_screen import RenameConfirmScreen +from .settings_screen import SettingsScreen +from .convert_confirm_screen import ConvertConfirmScreen +from .delete_confirm_screen import DeleteConfirmScreen __all__ = [ 'ProposedFilenameView', 'MediaPanelView', + 'OpenScreen', + 'HelpScreen', + 'RenameConfirmScreen', + 'SettingsScreen', + 'ConvertConfirmScreen', + 'DeleteConfirmScreen', ] diff --git a/renamer/views/convert_confirm_screen.py b/renamer/views/convert_confirm_screen.py new file mode 100644 index 0000000..81d4428 --- /dev/null +++ b/renamer/views/convert_confirm_screen.py @@ -0,0 +1,185 @@ +from textual.screen import Screen +from textual.widgets import Button, Static +from textual.containers import Vertical, Horizontal, Center +from textual.markup import escape +from pathlib import Path + + +class ConvertConfirmScreen(Screen): + """Confirmation screen for AVI to MKV conversion.""" + + CSS = """ + #convert_content { + text-align: center; + } + Button:focus { + background: $primary; + } + #buttons { + align: center middle; + } + #conversion_details { + text-align: left; + margin: 1 2; + padding: 1 2; + border: solid $primary; + } + #warning_content { + text-align: center; + margin-bottom: 1; + margin-top: 1; + } + """ + + def __init__( + self, + avi_path: Path, + mkv_path: Path, + audio_languages: list, + subtitle_files: list, + extractor + ): + super().__init__() + self.avi_path = avi_path + self.mkv_path = mkv_path + self.audio_languages = audio_languages + self.subtitle_files = subtitle_files + self.extractor = extractor + + def compose(self): + from ..formatters.text_formatter import TextFormatter + + title_text = f"{TextFormatter.bold(TextFormatter.yellow('MKV CONVERSION'))}" + + # Build details + details_lines = [ + f"{TextFormatter.bold('Source:')} {TextFormatter.cyan(escape(self.avi_path.name))}", + f"{TextFormatter.bold('Output:')} {TextFormatter.green(escape(self.mkv_path.name))}", + "", + f"{TextFormatter.bold('Audio Languages:')}", + ] + + # Add audio language mapping + for i, lang in enumerate(self.audio_languages): + if lang: + details_lines.append(f" Track {i+1}: {TextFormatter.green(lang)}") + else: + details_lines.append(f" Track {i+1}: {TextFormatter.grey('(no language)')}") + + # Add subtitle info + if self.subtitle_files: + details_lines.append("") + details_lines.append(f"{TextFormatter.bold('Subtitles to include:')}") + for sub_file in self.subtitle_files: + details_lines.append(f" • {TextFormatter.blue(escape(sub_file.name))}") + else: + details_lines.append("") + details_lines.append(f"{TextFormatter.grey('No subtitle files found')}") + + details_text = "\n".join(details_lines) + + # Get HEVC CRF from settings + settings = self.app.settings # type: ignore + hevc_crf = settings.get("hevc_crf", 23) + + info_text = f""" +{TextFormatter.bold('Choose conversion mode:')} + +{TextFormatter.green('Copy Mode')} - Fast remux, no re-encoding (seconds to minutes) +{TextFormatter.yellow(f'HEVC Mode')} - Re-encode to H.265, CRF {hevc_crf} quality (minutes to hours) + {TextFormatter.grey('(Change quality in Settings with Ctrl+S)')} + """.strip() + + with Center(): + with Vertical(): + yield Static(title_text, id="convert_content", markup=True) + yield Static(details_text, id="conversion_details", markup=True) + yield Static(info_text, id="info_text", markup=True) + with Horizontal(id="buttons"): + yield Button("Convert Copy (y)", id="convert_copy", variant="success") + yield Button("Convert HEVC (e)", id="convert_hevc", variant="primary") + yield Button("Cancel (n)", id="cancel", variant="error") + + def on_mount(self): + self.set_focus(self.query_one("#convert_copy")) + + def on_button_pressed(self, event): + if event.button.id == "convert_copy": + self._do_conversion(encode_hevc=False) + event.stop() # Prevent key event from also triggering + elif event.button.id == "convert_hevc": + self._do_conversion(encode_hevc=True) + event.stop() # Prevent key event from also triggering + elif event.button.id == "cancel": + self.app.pop_screen() # type: ignore + event.stop() # Prevent key event from also triggering + + def _do_conversion(self, encode_hevc: bool): + """Start conversion with the specified encoding mode.""" + app = self.app + settings = app.settings # type: ignore + + # Get CRF and preset from settings if using HEVC + crf = settings.get("hevc_crf", 23) if encode_hevc else 18 + preset = settings.get("hevc_preset", "fast") if encode_hevc else "medium" + + mode_str = f"HEVC CRF {crf} ({preset})" if encode_hevc else "Copy" + app.notify(f"Starting conversion ({mode_str})...", severity="information", timeout=2) + + def do_conversion(): + from ..services.conversion_service import ConversionService + import threading + import logging + + conversion_service = ConversionService() + logging.info(f"Starting conversion of {self.avi_path} with encode_hevc={encode_hevc}, crf={crf}, preset={preset}") + logging.info(f"CPU architecture: {conversion_service.cpu_arch}") + + success, message = conversion_service.convert_avi_to_mkv( + self.avi_path, + extractor=self.extractor, + encode_hevc=encode_hevc, + crf=crf, + preset=preset + ) + + logging.info(f"Conversion result: success={success}, message={message}") + + # Schedule UI updates on the main thread + mkv_path = self.avi_path.with_suffix('.mkv') + + def handle_success(): + logging.info(f"handle_success called: {mkv_path}") + app.notify(f"✓ {message}", severity="information", timeout=5) + logging.info(f"Adding file to tree: {mkv_path}") + app.add_file_to_tree(mkv_path) + logging.info("Conversion success handler completed") + + def handle_error(): + logging.info(f"handle_error called: {message}") + app.notify(f"✗ {message}", severity="error", timeout=10) + logging.info("Conversion error handler completed") + + if success: + logging.info(f"Conversion successful, scheduling UI update for {mkv_path}") + app.call_later(handle_success) + else: + logging.error(f"Conversion failed: {message}") + app.call_later(handle_error) + + # Run conversion in background thread + import threading + threading.Thread(target=do_conversion, daemon=True).start() + + # Close the screen + self.app.pop_screen() # type: ignore + + def on_key(self, event): + if event.key == "y": + # Copy mode + self._do_conversion(encode_hevc=False) + elif event.key == "e": + # HEVC mode + self._do_conversion(encode_hevc=True) + elif event.key == "n" or event.key == "escape": + self.app.pop_screen() # type: ignore diff --git a/renamer/views/delete_confirm_screen.py b/renamer/views/delete_confirm_screen.py new file mode 100644 index 0000000..93539f5 --- /dev/null +++ b/renamer/views/delete_confirm_screen.py @@ -0,0 +1,111 @@ +from textual.screen import Screen +from textual.widgets import Button, Static +from textual.containers import Vertical, Horizontal, Center +from textual.markup import escape +from pathlib import Path +import logging + + +class DeleteConfirmScreen(Screen): + """Confirmation screen for file deletion.""" + + CSS = """ + #delete_content { + text-align: center; + } + Button:focus { + background: $primary; + } + #buttons { + align: center middle; + } + #file_details { + text-align: left; + margin: 1 2; + padding: 1 2; + border: solid $error; + } + #warning_content { + text-align: center; + margin-bottom: 1; + margin-top: 1; + } + """ + + def __init__(self, file_path: Path): + super().__init__() + self.file_path = file_path + + def compose(self): + from ..formatters.text_formatter import TextFormatter + + title_text = f"{TextFormatter.bold(TextFormatter.red('DELETE FILE'))}" + + # Build file details + file_size = self.file_path.stat().st_size if self.file_path.exists() else 0 + from ..formatters.size_formatter import SizeFormatter + size_str = SizeFormatter.format_size_full(file_size) + + details_lines = [ + f"{TextFormatter.bold('File:')} {TextFormatter.cyan(escape(self.file_path.name))}", + f"{TextFormatter.bold('Path:')} {TextFormatter.grey(escape(str(self.file_path.parent)))}", + f"{TextFormatter.bold('Size:')} {TextFormatter.yellow(size_str)}", + ] + + details_text = "\n".join(details_lines) + + warning_text = f""" +{TextFormatter.bold(TextFormatter.red("WARNING: This action cannot be undone!"))} +{TextFormatter.yellow("The file will be permanently deleted from your system.")} + +Are you sure you want to delete this file? + """.strip() + + with Center(): + with Vertical(): + yield Static(title_text, id="delete_content", markup=True) + yield Static(details_text, id="file_details", markup=True) + yield Static(warning_text, id="warning_content", markup=True) + with Horizontal(id="buttons"): + yield Button("No (n)", id="cancel", variant="primary") + yield Button("Yes (y)", id="delete", variant="error") + + def on_mount(self): + # Set focus to "No" button by default (safer option) + self.set_focus(self.query_one("#cancel")) + + def on_button_pressed(self, event): + if event.button.id == "delete": + # Delete the file + app = self.app # type: ignore + + try: + if self.file_path.exists(): + self.file_path.unlink() + app.notify(f"✓ Deleted: {self.file_path.name}", severity="information", timeout=3) + logging.info(f"File deleted: {self.file_path}") + + # Remove from tree + app.remove_file_from_tree(self.file_path) + else: + app.notify(f"✗ File not found: {self.file_path.name}", severity="error", timeout=3) + + except PermissionError: + app.notify(f"✗ Permission denied: Cannot delete {self.file_path.name}", severity="error", timeout=5) + logging.error(f"Permission denied deleting file: {self.file_path}") + except Exception as e: + app.notify(f"✗ Error deleting file: {e}", severity="error", timeout=5) + logging.error(f"Error deleting file {self.file_path}: {e}", exc_info=True) + + self.app.pop_screen() # type: ignore + else: + # Cancel + self.app.pop_screen() # type: ignore + + def on_key(self, event): + if event.key == "y": + # Simulate delete button press + delete_button = self.query_one("#delete") + self.on_button_pressed(type('Event', (), {'button': delete_button})()) + elif event.key == "n" or event.key == "escape": + self.app.pop_screen() # type: ignore diff --git a/renamer/views/help_screen.py b/renamer/views/help_screen.py new file mode 100644 index 0000000..18c6aa8 --- /dev/null +++ b/renamer/views/help_screen.py @@ -0,0 +1,68 @@ +from textual.screen import Screen +from textual.widgets import Static, Button +from textual.containers import Vertical + + +class HelpScreen(Screen): + def compose(self): + try: + from importlib.metadata import version + app_version = version("renamer") + except Exception: + app_version = "unknown" + + help_text = f""" +Media File Renamer v{app_version} + +A powerful tool for analyzing and renaming media files with intelligent metadata extraction. + +NAVIGATION: +• Use arrow keys to navigate the file tree +• Right arrow: Expand directory +• Left arrow: Collapse directory +• Enter/Space: Select file + +ACTIONS: +• o: Open directory - Change the scan directory +• s: Scan - Refresh the current directory +• f: Refresh - Reload metadata for selected file +• r: Rename - Rename selected file with proposed name +• c: Convert - Convert AVI file to MKV container with metadata +• d: Delete - Delete selected file (with confirmation) +• p: Expand/Collapse - Toggle expansion of selected directory +• m: Toggle Mode - Switch between technical and catalog display modes +• ctrl+s: Settings - Open settings window +• ctrl+p: Command Palette - Access cache commands and more +• h: Help - Show this help screen +• q: Quit - Exit the application + +FEATURES: +• Automatic metadata extraction from filenames +• MediaInfo integration for technical details +• Intelligent title, year, and format detection +• Support for special editions and collections +• Real-time file analysis and renaming suggestions + +FILE ANALYSIS: +The app extracts various metadata including: +• Movie/series titles and years +• Video resolution and frame rates +• Audio languages and formats +• Special edition information +• Collection order numbers +• HDR and source information + +Press any key to close this help screen. + """.strip() + + with Vertical(): + yield Static(help_text, id="help_content") + yield Button("Close", id="close") + + def on_button_pressed(self, event): + if event.button.id == "close": + self.app.pop_screen() + + def on_key(self, event): + # Close on any key press + self.app.pop_screen() diff --git a/renamer/views/media_panel.py b/renamer/views/media_panel.py index 3d1e47e..def3d4e 100644 --- a/renamer/views/media_panel.py +++ b/renamer/views/media_panel.py @@ -22,8 +22,8 @@ class MediaPanelView: self.tmdb_section(), self.tracksinfo_section(), self.filename_section(), - self.metadata_section(), self.mediainfo_section(), + self.metadata_section(), ] ) @@ -124,6 +124,7 @@ class MediaPanelView: return "\n".join( [ self._props.title("Media Info Extraction"), + self._props.mediainfo_general_tracks, self._props.mediainfo_duration, self._props.mediainfo_frame_class, self._props.mediainfo_interlace, @@ -131,8 +132,10 @@ class MediaPanelView: self._props.mediainfo_aspect_ratio, self._props.mediainfo_hdr, self._props.mediainfo_audio_langs, - self._props.mediainfo_anamorphic, self._props.mediainfo_extension, self._props.mediainfo_3d_layout, + self._props.mediainfo_video_tracks, + self._props.mediainfo_audio_tracks, + self._props.mediainfo_subtitle_tracks, ] ) diff --git a/renamer/views/media_panel_properties.py b/renamer/views/media_panel_properties.py index b3fc646..148ff77 100644 --- a/renamer/views/media_panel_properties.py +++ b/renamer/views/media_panel_properties.py @@ -205,6 +205,37 @@ class MediaPanelProperties: # ============================================================ # MediaInfo Extraction Properties # ============================================================ + @property + @conditional_decorators.wrap("RAW General Info: ") + @text_decorators.colour(name="gray") + @conditional_decorators.default("Not extracted") + def mediainfo_general_tracks(self) -> str: + """Get MediaInfo raw general track info formatted with label.""" + return self._extractor.get("general_track", "MediaInfo") + + @property + @conditional_decorators.wrap("RAW Video Tracks: ") + @text_decorators.colour(name="gray") + @conditional_decorators.default("Not extracted") + def mediainfo_video_tracks(self) -> str: + """Get MediaInfo raw video tracks formatted with label.""" + return self._extractor.get("video_tracks", "MediaInfo") + + @property + @conditional_decorators.wrap("RAW Audio Tracks: ") + @text_decorators.colour(name="gray") + @conditional_decorators.default("Not extracted") + def mediainfo_audio_tracks(self) -> str: + """Get MediaInfo raw audio tracks formatted with label.""" + return self._extractor.get("audio_tracks", "MediaInfo") + + @property + @conditional_decorators.wrap("RAW Subtitle Tracks: ") + @text_decorators.colour(name="gray") + @conditional_decorators.default("Not extracted") + def mediainfo_subtitle_tracks(self) -> str: + """Get MediaInfo raw subtitle tracks formatted with label.""" + return self._extractor.get("subtitle_tracks", "MediaInfo") @property @conditional_decorators.wrap("Duration: ") @@ -264,14 +295,6 @@ class MediaPanelProperties: """Get MediaInfo audio languages formatted with label.""" return self._extractor.get("audio_langs", "MediaInfo") - @property - @conditional_decorators.wrap("Anamorphic: ") - @text_decorators.colour(name="grey") - @conditional_decorators.default("Not extracted") - def mediainfo_anamorphic(self) -> str: - """Get MediaInfo anamorphic formatted with label.""" - return self._extractor.get("anamorphic", "MediaInfo") - @property @conditional_decorators.wrap("Extension: ") @text_decorators.colour(name="grey") @@ -471,6 +494,13 @@ class MediaPanelProperties: """Get selected audio languages formatted with label.""" return self._extractor.get("audio_langs") + @property + @text_decorators.colour(name="green") + @conditional_decorators.wrap("General Info: ") + def general_info(self) -> list[str]: + """Get general track info formatted with label.""" + return self._extractor.get("general_tracks", "MediaInfo") or [] + @property def video_tracks(self) -> list[str]: """Return formatted video track data""" @@ -510,4 +540,3 @@ 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/renamer/views/open_screen.py b/renamer/views/open_screen.py new file mode 100644 index 0000000..f5cbd81 --- /dev/null +++ b/renamer/views/open_screen.py @@ -0,0 +1,30 @@ +from textual.screen import Screen +from textual.widgets import Input, Button +from pathlib import Path + + +class OpenScreen(Screen): + def compose(self): + yield Input(placeholder="Enter directory path", value=".", id="dir_input") + yield Button("OK", id="ok") + + def on_button_pressed(self, event): + if event.button.id == "ok": + self.submit_path() + + def on_input_submitted(self, event): + self.submit_path() + + def submit_path(self): + path_str = self.query_one("#dir_input", Input).value + path = Path(path_str) + if not path.exists(): + # Show error + self.query_one("#dir_input", Input).value = f"Path does not exist: {path_str}" + return + if not path.is_dir(): + self.query_one("#dir_input", Input).value = f"Not a directory: {path_str}" + return + self.app.scan_dir = path # type: ignore + self.app.scan_files() # type: ignore + self.app.pop_screen() diff --git a/renamer/views/rename_confirm_screen.py b/renamer/views/rename_confirm_screen.py new file mode 100644 index 0000000..ed1aced --- /dev/null +++ b/renamer/views/rename_confirm_screen.py @@ -0,0 +1,173 @@ +from textual.screen import Screen +from textual.widgets import Input, Button, Static +from textual.containers import Vertical, Horizontal, Center +from textual.markup import escape +from pathlib import Path +import logging + + +class RenameConfirmScreen(Screen): + CSS = """ + #confirm_content { + text-align: center; + } + # Button { + # background: $surface; + # border: solid $surface; + } + Button:focus { + background: $primary; + # color: $text-primary; + # border: solid $primary; + } + #buttons { + align: center middle; + } + #new_name_input { + width: 100%; + margin: 1 0; + } + #new_name_display { + text-align: center; + margin-bottom: 1; + } + #warning_content { + text-align: center; + margin-bottom: 0; + } + """ + + def __init__(self, old_path: Path, new_name: str): + super().__init__() + self.old_path = old_path + self.new_name = new_name.replace("/", "-").replace("\\", "-") + self.new_path = old_path.parent / self.new_name + self.was_edited = False + + def compose(self): + from ..formatters.text_formatter import TextFormatter + + confirm_text = f""" +{TextFormatter.bold(TextFormatter.red("RENAME CONFIRMATION"))} + +RAW name: {escape(self.old_path.name)} + +Current name: {TextFormatter.cyan(escape(self.old_path.name))} +Proposed name: {TextFormatter.green(escape(self.new_name))} + +{TextFormatter.yellow("Edit the new name below:")} + """.strip() + + warning_text = f""" +{TextFormatter.bold(TextFormatter.red("This action cannot be undone!"))} +Do you want to proceed with renaming? + """.strip() + + with Center(): + with Vertical(): + yield Static(confirm_text, id="confirm_content", markup=True) + yield Input(value=self.new_name, id="new_name_input", placeholder="New file name") + yield Static(f"{TextFormatter.green(escape(self.new_name))}", id="new_name_display", markup=True) + yield Static(warning_text, id="warning_content", markup=True) + with Horizontal(id="buttons"): + yield Button("Rename (y)", id="rename") + yield Button("Cancel (n)", id="cancel") + + def on_mount(self): + self.set_focus(self.query_one("#rename")) + + def on_input_changed(self, event): + if event.input.id == "new_name_input": + self.new_name = event.input.value.replace("/", "-").replace("\\", "-") + self.new_path = self.old_path.parent / self.new_name + self.was_edited = True + # Update the display + from ..formatters.text_formatter import TextFormatter + display = self.query_one("#new_name_display", Static) + display.update(f"{TextFormatter.green(escape(self.new_name))}") + + def on_button_pressed(self, event): + if event.button.id == "rename": + # Check if new name is the same as old name + if self.new_name == self.old_path.name: + self.app.notify("Proposed name is the same as current name; no rename needed.", severity="information", timeout=3) + self.app.pop_screen() + return + + try: + logging.info(f"Starting rename: old_path={self.old_path}, new_path={self.new_path}") + logging.info(f"Old file name: {self.old_path.name}") + logging.info(f"New file name: {self.new_name}") + logging.info(f"New path parent: {self.new_path.parent}, Old path parent: {self.old_path.parent}") + if "/" in self.new_name or "\\" in self.new_name: + logging.warning(f"New name contains path separators: {self.new_name}") + self.old_path.rename(self.new_path) + logging.info(f"Rename successful: {self.old_path} -> {self.new_path}") + # Update the tree node + self.app.update_renamed_file(self.old_path, self.new_path) # type: ignore + self.app.pop_screen() + except Exception as e: + logging.error(f"Rename failed: {self.old_path} -> {self.new_path}, error: {str(e)}") + # Show error + content = self.query_one("#confirm_content", Static) + content.update(f"Error renaming file: {str(e)}") + elif event.button.id == "cancel": + self.app.pop_screen() + + def on_key(self, event): + current = self.focused + if current and hasattr(current, 'id'): + if current.id == "new_name_input": + # When input is focused, let left/right move cursor, use up/down to change focus + if event.key == "up": + self.set_focus(self.query_one("#cancel")) + elif event.key == "down": + self.set_focus(self.query_one("#rename")) + elif current.id in ("rename", "cancel"): + if event.key == "left": + if current.id == "rename": + self.set_focus(self.query_one("#new_name_input")) + elif current.id == "cancel": + self.set_focus(self.query_one("#rename")) + elif event.key == "right": + if current.id == "new_name_input": + self.set_focus(self.query_one("#rename")) + elif current.id == "rename": + self.set_focus(self.query_one("#cancel")) + elif event.key == "up": + if current.id == "rename": + self.set_focus(self.query_one("#new_name_input")) + elif current.id == "cancel": + self.set_focus(self.query_one("#rename")) + elif event.key == "down": + if current.id == "new_name_input": + self.set_focus(self.query_one("#rename")) + elif current.id == "rename": + self.set_focus(self.query_one("#cancel")) + elif current.id == "cancel": + self.set_focus(self.query_one("#new_name_input")) + + # Hotkeys only work when not focused on input + if not current or not hasattr(current, 'id') or current.id != "new_name_input": + if event.key == "y": + # Trigger rename + try: + logging.info(f"Hotkey rename: old_path={self.old_path}, new_path={self.new_path}") + logging.info(f"Old file name: {self.old_path.name}") + logging.info(f"New file name: {self.new_name}") + logging.info(f"New path parent: {self.new_path.parent}, Old path parent: {self.old_path.parent}") + if "/" in self.new_name or "\\" in self.new_name: + logging.warning(f"New name contains path separators: {self.new_name}") + self.old_path.rename(self.new_path) + logging.info(f"Hotkey rename successful: {self.old_path} -> {self.new_path}") + # Update the tree node + self.app.update_renamed_file(self.old_path, self.new_path) # type: ignore + self.app.pop_screen() + except Exception as e: + logging.error(f"Hotkey rename failed: {self.old_path} -> {self.new_path}, error: {str(e)}") + # Show error + content = self.query_one("#confirm_content", Static) + content.update(f"Error renaming file: {str(e)}") + elif event.key == "n": + # Cancel + self.app.pop_screen() diff --git a/renamer/views/settings_screen.py b/renamer/views/settings_screen.py new file mode 100644 index 0000000..613a88f --- /dev/null +++ b/renamer/views/settings_screen.py @@ -0,0 +1,151 @@ +from textual.screen import Screen +from textual.widgets import Input, Button, Static +from textual.containers import Vertical, Horizontal, Center + + +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") + + # Poster selection + yield Static("Poster Display (Catalog Mode):", classes="label") + with Horizontal(): + yield Button("No", id="poster_no", variant="primary" if settings.get("poster") == "no" else "default") + yield Button("ASCII", id="poster_pseudo", variant="primary" if settings.get("poster") == "pseudo" else "default") + yield Button("Viu", id="poster_viu", variant="primary" if settings.get("poster") == "viu" else "default") + yield Button("RichPixels", id="poster_richpixels", variant="primary" if settings.get("poster") == "richpixels" else "default") + + # HEVC quality selection + yield Static("HEVC Encoding Quality (for conversions):", classes="label") + with Horizontal(): + yield Button("CRF 18 (Visually Lossless)", id="hevc_crf_18", variant="primary" if settings.get("hevc_crf") == 18 else "default") + yield Button("CRF 23 (High Quality)", id="hevc_crf_23", variant="primary" if settings.get("hevc_crf") == 23 else "default") + yield Button("CRF 28 (Balanced)", id="hevc_crf_28", variant="primary" if settings.get("hevc_crf") == 28 else "default") + + # HEVC preset selection + yield Static("HEVC Encoding Speed (faster = lower quality/smaller file):", classes="label") + with Horizontal(): + yield Button("Ultrafast", id="hevc_preset_ultrafast", variant="primary" if settings.get("hevc_preset") == "ultrafast" else "default") + yield Button("Veryfast", id="hevc_preset_veryfast", variant="primary" if settings.get("hevc_preset") == "veryfast" else "default") + yield Button("Fast", id="hevc_preset_fast", variant="primary" if settings.get("hevc_preset") == "fast" else "default") + yield Button("Medium", id="hevc_preset_medium", variant="primary" if settings.get("hevc_preset") == "medium" 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" + elif event.button.id.startswith("poster_"): + # Toggle poster buttons + poster_mode = event.button.id.split("_", 1)[1] # Use split with maxsplit=1 to handle "richpixels" + self.app.settings.set("poster", poster_mode) # type: ignore + # Update button variants + no_btn = self.query_one("#poster_no", Button) + pseudo_btn = self.query_one("#poster_pseudo", Button) + viu_btn = self.query_one("#poster_viu", Button) + richpixels_btn = self.query_one("#poster_richpixels", Button) + no_btn.variant = "primary" if poster_mode == "no" else "default" + pseudo_btn.variant = "primary" if poster_mode == "pseudo" else "default" + viu_btn.variant = "primary" if poster_mode == "viu" else "default" + richpixels_btn.variant = "primary" if poster_mode == "richpixels" else "default" + elif event.button.id.startswith("hevc_crf_"): + # Toggle HEVC CRF buttons + crf_value = int(event.button.id.split("_")[-1]) + self.app.settings.set("hevc_crf", crf_value) # type: ignore + # Update button variants + crf18_btn = self.query_one("#hevc_crf_18", Button) + crf23_btn = self.query_one("#hevc_crf_23", Button) + crf28_btn = self.query_one("#hevc_crf_28", Button) + crf18_btn.variant = "primary" if crf_value == 18 else "default" + crf23_btn.variant = "primary" if crf_value == 23 else "default" + crf28_btn.variant = "primary" if crf_value == 28 else "default" + elif event.button.id.startswith("hevc_preset_"): + # Toggle HEVC preset buttons + preset_value = event.button.id.split("_")[-1] + self.app.settings.set("hevc_preset", preset_value) # type: ignore + # Update button variants + ultrafast_btn = self.query_one("#hevc_preset_ultrafast", Button) + veryfast_btn = self.query_one("#hevc_preset_veryfast", Button) + fast_btn = self.query_one("#hevc_preset_fast", Button) + medium_btn = self.query_one("#hevc_preset_medium", Button) + ultrafast_btn.variant = "primary" if preset_value == "ultrafast" else "default" + veryfast_btn.variant = "primary" if preset_value == "veryfast" else "default" + fast_btn.variant = "primary" if preset_value == "fast" else "default" + medium_btn.variant = "primary" if preset_value == "medium" 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