mirror of
https://github.com/shadoll/moma.git
synced 2026-08-28 19:43:28 +00:00
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:
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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',
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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,
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user