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.
This commit is contained in:
sha
2026-04-11 20:49:28 +03:00
parent 2e5cef4424
commit 8274cb4e9f
19 changed files with 1112 additions and 913 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
# Renamer - Media File Renamer and Metadata Viewer # moma - Media Manager, File Renamer and Metadata Viewer
**Version**: 0.7.0-dev **Version**: 0.7.0-dev
+1 -3
View File
@@ -7,12 +7,11 @@ from rich.markup import escape
from pathlib import Path from pathlib import Path
from functools import partial from functools import partial
import threading import threading
import time
import logging import logging
from .logging_config import LoggerConfig # Initialize logging singleton from .logging_config import LoggerConfig # Initialize logging singleton
from .constants import MEDIA_TYPES 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 .extractors.extractor import MediaExtractor
from .views import MediaPanelView, ProposedFilenameView from .views import MediaPanelView, ProposedFilenameView
from .formatters.text_formatter import TextFormatter from .formatters.text_formatter import TextFormatter
@@ -579,7 +578,6 @@ By Category:"""
async def action_delete(self): async def action_delete(self):
"""Delete a file with confirmation.""" """Delete a file with confirmation."""
from .screens import DeleteConfirmScreen
tree = self.query_one("#file_tree", Tree) tree = self.query_one("#file_tree", Tree)
node = tree.cursor_node node = tree.cursor_node
+1 -1
View File
@@ -44,7 +44,7 @@ class FileInfoExtractor:
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.settings = None # Will be set by Settings singleton if needed
self._stat = file_path.stat() 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() @cached_method()
def extract_size(self) -> int: def extract_size(self) -> int:
+320 -185
View File
@@ -5,96 +5,240 @@ from ..constants import FRAME_CLASSES, get_extension_from_format
from ..cache import cached_method, Cache from ..cache import cached_method, Cache
import langcodes import langcodes
import logging import logging
import functools
logger = logging.getLogger(__name__) 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 MediaInfoExtractor:
"""Class to extract information from MediaInfo""" """Class to extract information from MediaInfo"""
def __init__(self, file_path: Path, use_cache: bool = True): def __init__(self, file_path: Path, use_cache: bool = True):
self.file_path = file_path 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.settings = None # Will be set by Settings singleton if needed
self._cache = {} # Internal cache for method results 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() @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: def extract_duration(self) -> float | None:
"""Extract duration from media info in seconds""" """Extract duration from media info in seconds"""
if self.media_info: tracks = self._get_tracks(track_type="General")
for track in self.media_info.tracks: # Type assertion: decorators guarantee tracks is a list
if track.track_type == 'General': assert isinstance(tracks, list)
return getattr(track, 'duration', 0) / 1000 if getattr(track, 'duration', None) else None for track in tracks:
return (
getattr(track, "duration", 0) / 1000
if getattr(track, "duration", None)
else None
)
return 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: def extract_frame_class(self) -> str | None:
"""Extract frame class from media info (480p, 720p, 1080p, etc.)""" """Extract frame class from media info (480p, 720p, 1080p, etc.)"""
if not self.video_tracks: track = self._get_track(track_type="Video", track_id=0)
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
# Check if interlaced - try multiple attributes scan_type_attr = getattr(track, "scan_type", None)
# 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)
logger.debug(f"[{self.file_path.name}] Frame class detection - Resolution: {width}x{height}") interlaced = self.extract_interlaced()
logger.debug(f"[{self.file_path.name}] scan_type attribute: {scan_type_attr!r} (type: {type(scan_type_attr).__name__})") scan_order = getattr(track, "scan_order", None)
logger.debug(f"[{self.file_path.name}] interlaced attribute: {interlaced!r} (type: {type(interlaced).__name__})")
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 # Determine scan type from available attributes
# Check scan_type first (e.g., "Interlaced", "Progressive", "MBAFF") # Check scan_type first (e.g., "Interlaced", "Progressive", "MBAFF")
if scan_type_attr and isinstance(scan_type_attr, str): if scan_type_attr and isinstance(scan_type_attr, str):
scan_type = 'i' if 'interlaced' in scan_type_attr.lower() else 'p' 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}") logger.debug(
# Then check interlaced flag (e.g., "Yes", "No") f"[{self.file_path.name}] Using scan_type: {scan_type_attr!r} -> scan_type={scan_type!r}"
elif interlaced and isinstance(interlaced, str): )
scan_type = 'i' if interlaced.lower() in ['yes', 'true', '1'] else 'p' # Check scan_order (e.g., "TFF", "BFF" for interlaced, "Progressive" for progressive)
logger.debug(f"[{self.file_path.name}] Using interlaced: {interlaced!r} -> scan_type={scan_type!r}") 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: else:
# Default to progressive if no information available # Default to progressive if no information available
scan_type = 'p' scan_type = "p"
logger.debug(f"[{self.file_path.name}] No scan type info, defaulting to progressive") logger.debug(
f"[{self.file_path.name}] No scan type info, defaulting to progressive"
)
# Calculate effective height for frame class determination # Calculate effective height for frame class determination
aspect_ratio = 16 / 9 aspect_ratio = 16 / 9
@@ -107,9 +251,9 @@ class MediaInfoExtractor:
# Use a larger tolerance (10 pixels) to handle cinema/ultrawide aspect ratios # Use a larger tolerance (10 pixels) to handle cinema/ultrawide aspect ratios
width_matches = [] width_matches = []
for frame_class, info in FRAME_CLASSES.items(): 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): 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)) width_matches.append((frame_class, diff))
if width_matches: if width_matches:
@@ -123,67 +267,68 @@ class MediaInfoExtractor:
# First try exact match with standard frame classes # First try exact match with standard frame classes
frame_class = f"{int(round(effective_height))}{scan_type}" frame_class = f"{int(round(effective_height))}{scan_type}"
if frame_class in FRAME_CLASSES: 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 return frame_class
# Find closest standard height match # Find closest standard height match
closest_class = None closest_class = None
min_diff = float('inf') min_diff = float("inf")
for fc, info in FRAME_CLASSES.items(): for fc, info in FRAME_CLASSES.items():
if fc.endswith(scan_type): if fc.endswith(scan_type):
diff = abs(effective_height - info['nominal_height']) diff = abs(effective_height - info["nominal_height"])
if diff < min_diff: if diff < min_diff:
min_diff = diff min_diff = diff
closest_class = fc closest_class = fc
# Return closest standard match if within reasonable distance (20 pixels) # Return closest standard match if within reasonable distance (20 pixels)
if closest_class and min_diff <= 20: 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 return closest_class
# For non-standard resolutions, create a custom frame 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 return frame_class
@cached_method() @requires_tracks_type("Video")
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()
def extract_aspect_ratio(self) -> str | None: def extract_aspect_ratio(self) -> str | None:
"""Extract video aspect ratio from media info""" """Extract video aspect ratio from media info"""
if not self.video_tracks: tracks = self._get_tracks(track_type="Video")
return None # Type assertion: decorator guarantees tracks is a list
aspect_ratio = getattr(self.video_tracks[0], 'display_aspect_ratio', None) assert isinstance(tracks, list)
track = tracks[0]
aspect_ratio = getattr(track, "display_aspect_ratio", None)
if aspect_ratio: if aspect_ratio:
return str(aspect_ratio) return str(aspect_ratio)
return None return None
@cached_method() @requires_tracks_type("Video")
def extract_hdr(self) -> str | None: def extract_hdr(self) -> str | None:
"""Extract HDR info from media info""" """Extract HDR info from media info"""
if not self.video_tracks: tracks = self._get_tracks(track_type="Video")
return None # Type assertion: decorator guarantees tracks is a list
profile = getattr(self.video_tracks[0], 'format_profile', '') or '' assert isinstance(tracks, list)
if 'HDR' in profile.upper(): track = tracks[0]
return 'HDR' profile = getattr(track, "format_profile", "") or ""
if "HDR" in profile.upper():
return "HDR"
return None return None
@cached_method() @requires_tracks
@requires_tracks_type("Audio")
def extract_audio_langs(self) -> str | None: def extract_audio_langs(self) -> str | None:
"""Extract audio languages from media info""" """Extract audio languages from media info"""
if not self.audio_tracks: tracks = self._get_tracks(track_type="Audio")
if not isinstance(tracks, list):
return None return None
langs = [] langs = []
for a in self.audio_tracks: for a in tracks:
lang_code = getattr(a, 'language', 'und') or 'und' lang_code = getattr(a, "language", "und") or "und"
try: try:
# Try to get the 3-letter code # Try to get the 3-letter code
lang_obj = langcodes.Language.get(lang_code.lower()) lang_obj = langcodes.Language.get(lang_code.lower())
@@ -195,75 +340,26 @@ class MediaInfoExtractor:
langs.append(lang_code.lower()[:3]) langs.append(lang_code.lower()[:3])
lang_counts = Counter(langs) lang_counts = Counter(langs)
audio_langs = [f"{count}{lang}" if count > 1 else lang for lang, count in lang_counts.items()] audio_langs = [
return ','.join(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: def is_3d(self) -> bool:
"""Check if the video is 3D""" """Check if the video is 3D"""
if not self.video_tracks: track = self._get_track("Video", 0)
if not track:
return False 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: if multi_view and int(multi_view) > 1:
return True return True
stereoscopic = getattr(self.video_tracks[0], 'stereoscopic', None) stereoscopic = getattr(track, "stereoscopic", None)
if stereoscopic == 'Yes': if stereoscopic == "Yes":
return True return True
return False return False
@cached_method() @requires_tracks_type("General")
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()
def extract_extension(self) -> str | None: def extract_extension(self) -> str | None:
"""Extract file extension based on container format. """Extract file extension based on container format.
@@ -273,12 +369,9 @@ class MediaInfoExtractor:
Returns: Returns:
File extension (e.g., "mp4", "mkv") or None if format is unknown File extension (e.g., "mp4", "mkv") or None if format is unknown
""" """
if not self.media_info:
return None general_track = self._get_track(track_type="General", track_id=0)
general_track = next((t for t in self.media_info.tracks if t.track_type == 'General'), None) format_ = getattr(general_track, "format", None)
if not general_track:
return None
format_ = getattr(general_track, 'format', None)
if not format_: if not format_:
return None return None
@@ -286,20 +379,24 @@ class MediaInfoExtractor:
ext = get_extension_from_format(format_) ext = get_extension_from_format(format_)
# Special case: Matroska 3D uses mk3d extension # Special case: Matroska 3D uses mk3d extension
if ext == 'mkv' and self.is_3d(): if ext == "mkv" and self.is_3d():
return 'mk3d' return "mk3d"
return ext return ext
@cached_method() @requires_tracks_type("Video")
def extract_3d_layout(self) -> str | None: def extract_3d_layout(self) -> str | None:
"""Extract 3D stereoscopic layout from MediaInfo""" """Extract 3D stereoscopic layout from MediaInfo"""
if not self.is_3d(): if not self.is_3d():
return None 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 return stereoscopic if stereoscopic else None
@cached_method() @requires_tracks_type("Video")
def extract_interlaced(self) -> bool | None: def extract_interlaced(self) -> bool | None:
"""Determine if the video is interlaced. """Determine if the video is interlaced.
@@ -308,39 +405,77 @@ class MediaInfoExtractor:
False: Video is progressive (explicitly set) False: Video is progressive (explicitly set)
None: Information not available in MediaInfo None: Information not available in MediaInfo
""" """
if not self.video_tracks: tracks = self._get_tracks(track_type="Video")
logger.debug(f"[{self.file_path.name}] Interlaced detection: No video tracks") # Type assertion: decorator guarantees tracks is a list
return None assert isinstance(tracks, list)
track = tracks[0]
scan_type_attr = getattr(self.video_tracks[0], 'scan_type', None) scan_type_attr = getattr(track, "scan_type", None)
interlaced = getattr(self.video_tracks[0], 'interlaced', 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}] Interlaced detection:")
logger.debug(f"[{self.file_path.name}] scan_type: {scan_type_attr!r} (type: {type(scan_type_attr).__name__})") logger.debug(
logger.debug(f"[{self.file_path.name}] interlaced: {interlaced!r} (type: {type(interlaced).__name__})") 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") # Check scan_type attribute first (e.g., "Interlaced", "Progressive", "MBAFF")
if scan_type_attr and isinstance(scan_type_attr, str): if scan_type_attr and isinstance(scan_type_attr, str):
scan_lower = scan_type_attr.lower() scan_lower = scan_type_attr.lower()
if 'interlaced' in scan_lower or 'mbaff' in scan_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})") logger.debug(
f"[{self.file_path.name}] Result: True (from scan_type={scan_type_attr!r})"
)
return True return True
elif 'progressive' in scan_lower: elif "progressive" in scan_lower:
logger.debug(f"[{self.file_path.name}] Result: False (from scan_type={scan_type_attr!r})") logger.debug(
f"[{self.file_path.name}] Result: False (from scan_type={scan_type_attr!r})"
)
return False return False
# If scan_type has some other value, fall through to check interlaced # If scan_type has some other value, fall through to check other attributes
logger.debug(f"[{self.file_path.name}] scan_type unrecognized, checking interlaced attribute") 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") # Check interlaced attribute (e.g., "Yes", "No")
if interlaced and isinstance(interlaced, str): if interlaced and isinstance(interlaced, str):
interlaced_lower = interlaced.lower() interlaced_lower = interlaced.lower()
if interlaced_lower in ['yes', 'true', '1']: if interlaced_lower in ["yes", "true", "1"]:
logger.debug(f"[{self.file_path.name}] Result: True (from interlaced={interlaced!r})") logger.debug(
f"[{self.file_path.name}] Result: True (from interlaced={interlaced!r})"
)
return True return True
elif interlaced_lower in ['no', 'false', '0']: elif interlaced_lower in ["no", "false", "0"]:
logger.debug(f"[{self.file_path.name}] Result: False (from interlaced={interlaced!r})") logger.debug(
f"[{self.file_path.name}] Result: False (from interlaced={interlaced!r})"
)
return False return False
# No information available # 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 return None
+1 -1
View File
@@ -42,7 +42,7 @@ class MetadataExtractor:
self.file_path = file_path 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.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: try:
self.info = mutagen.File(file_path) # type: ignore self.info = mutagen.File(file_path) # type: ignore
except Exception as e: except Exception as e:
@@ -43,7 +43,7 @@ class SpecialInfoDecorators:
""" """
def decorator(func: Callable) -> Callable: def decorator(func: Callable) -> Callable:
@wraps(func) @wraps(func)
def wrapper(*args, **kwargs) -> str: def wrapper(*args, **kwargs) -> str | None:
result = func(*args, **kwargs) result = func(*args, **kwargs)
return SpecialInfoFormatter.format_database_info(result) return SpecialInfoFormatter.format_database_info(result)
return wrapper return wrapper
+1 -1
View File
@@ -11,7 +11,7 @@ class SpecialInfoFormatter:
return special_info or "" return special_info or ""
@staticmethod @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""" """Format database info dictionary or tuple/list into a string"""
import logging import logging
import os import os
+1 -2
View File
@@ -36,8 +36,7 @@ class LoggerConfig:
level=logging.DEBUG, level=logging.DEBUG,
format='%(asctime)s - %(levelname)s - %(message)s' format='%(asctime)s - %(levelname)s - %(message)s'
) )
else: # When FORMATTER_LOG is not '1', do not configure logging at all
logging.basicConfig(level=logging.INFO)
LoggerConfig._initialized = True LoggerConfig._initialized = True
-698
View File
@@ -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
@@ -37,6 +37,9 @@ def test_frame_class_detection(test_case):
mock_track.interlaced = 'Yes' if interlaced else 'No' mock_track.interlaced = 'Yes' if interlaced else 'No'
extractor.video_tracks = [mock_track] 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 # Test the method
actual = MediaInfoExtractor.extract_frame_class(extractor) actual = MediaInfoExtractor.extract_frame_class(extractor)
+12
View File
@@ -7,8 +7,20 @@ orchestrate multiple formatters to build complete UI panels.
from .proposed_filename import ProposedFilenameView from .proposed_filename import ProposedFilenameView
from .media_panel import MediaPanelView 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__ = [ __all__ = [
'ProposedFilenameView', 'ProposedFilenameView',
'MediaPanelView', 'MediaPanelView',
'OpenScreen',
'HelpScreen',
'RenameConfirmScreen',
'SettingsScreen',
'ConvertConfirmScreen',
'DeleteConfirmScreen',
] ]
+185
View File
@@ -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
+111
View File
@@ -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
+68
View File
@@ -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()
+5 -2
View File
@@ -22,8 +22,8 @@ class MediaPanelView:
self.tmdb_section(), self.tmdb_section(),
self.tracksinfo_section(), self.tracksinfo_section(),
self.filename_section(), self.filename_section(),
self.metadata_section(),
self.mediainfo_section(), self.mediainfo_section(),
self.metadata_section(),
] ]
) )
@@ -124,6 +124,7 @@ class MediaPanelView:
return "\n".join( return "\n".join(
[ [
self._props.title("Media Info Extraction"), self._props.title("Media Info Extraction"),
self._props.mediainfo_general_tracks,
self._props.mediainfo_duration, self._props.mediainfo_duration,
self._props.mediainfo_frame_class, self._props.mediainfo_frame_class,
self._props.mediainfo_interlace, self._props.mediainfo_interlace,
@@ -131,8 +132,10 @@ class MediaPanelView:
self._props.mediainfo_aspect_ratio, self._props.mediainfo_aspect_ratio,
self._props.mediainfo_hdr, self._props.mediainfo_hdr,
self._props.mediainfo_audio_langs, self._props.mediainfo_audio_langs,
self._props.mediainfo_anamorphic,
self._props.mediainfo_extension, self._props.mediainfo_extension,
self._props.mediainfo_3d_layout, self._props.mediainfo_3d_layout,
self._props.mediainfo_video_tracks,
self._props.mediainfo_audio_tracks,
self._props.mediainfo_subtitle_tracks,
] ]
) )
+38 -9
View File
@@ -205,6 +205,37 @@ class MediaPanelProperties:
# ============================================================ # ============================================================
# MediaInfo Extraction Properties # 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 @property
@conditional_decorators.wrap("Duration: ") @conditional_decorators.wrap("Duration: ")
@@ -264,14 +295,6 @@ class MediaPanelProperties:
"""Get MediaInfo audio languages formatted with label.""" """Get MediaInfo audio languages formatted with label."""
return self._extractor.get("audio_langs", "MediaInfo") 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 @property
@conditional_decorators.wrap("Extension: ") @conditional_decorators.wrap("Extension: ")
@text_decorators.colour(name="grey") @text_decorators.colour(name="grey")
@@ -471,6 +494,13 @@ class MediaPanelProperties:
"""Get selected audio languages formatted with label.""" """Get selected audio languages formatted with label."""
return self._extractor.get("audio_langs") 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 @property
def video_tracks(self) -> list[str]: def video_tracks(self) -> list[str]:
"""Return formatted video track data""" """Return formatted video track data"""
@@ -510,4 +540,3 @@ class MediaPanelProperties:
@track_decorators.subtitle_track() @track_decorators.subtitle_track()
def subtitle_track(self, track, index) -> str: def subtitle_track(self, track, index) -> str:
"""Get subtitle track info formatted with label.""" """Get subtitle track info formatted with label."""
return track
+30
View File
@@ -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()
+173
View File
@@ -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()
+151
View File
@@ -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