feat: Add conversion and deletion confirmation screens, enhance media panel properties

- Implemented ConvertConfirmScreen for confirming AVI to MKV conversions with audio and subtitle options.
- Added DeleteConfirmScreen for confirming file deletions with detailed file information.
- Enhanced MediaPanelView to include additional MediaInfo properties such as video, audio, and subtitle tracks.
- Updated MediaPanelProperties to extract and display raw MediaInfo track data.
- Introduced HelpScreen for user guidance on application features and navigation.
- Created OpenScreen for directory path input with validation.
- Developed RenameConfirmScreen for renaming files with user confirmation and editing capabilities.
- Added SettingsScreen for configuring application settings, including cache TTL and HEVC encoding options.
- Updated imports and module exports in views to accommodate new screens.
This commit is contained in:
sha
2026-04-11 20:49:28 +03:00
parent 2e5cef4424
commit 8274cb4e9f
19 changed files with 1112 additions and 913 deletions
+12
View File
@@ -7,8 +7,20 @@ orchestrate multiple formatters to build complete UI panels.
from .proposed_filename import ProposedFilenameView
from .media_panel import MediaPanelView
from .open_screen import OpenScreen
from .help_screen import HelpScreen
from .rename_confirm_screen import RenameConfirmScreen
from .settings_screen import SettingsScreen
from .convert_confirm_screen import ConvertConfirmScreen
from .delete_confirm_screen import DeleteConfirmScreen
__all__ = [
'ProposedFilenameView',
'MediaPanelView',
'OpenScreen',
'HelpScreen',
'RenameConfirmScreen',
'SettingsScreen',
'ConvertConfirmScreen',
'DeleteConfirmScreen',
]
+185
View File
@@ -0,0 +1,185 @@
from textual.screen import Screen
from textual.widgets import Button, Static
from textual.containers import Vertical, Horizontal, Center
from textual.markup import escape
from pathlib import Path
class ConvertConfirmScreen(Screen):
"""Confirmation screen for AVI to MKV conversion."""
CSS = """
#convert_content {
text-align: center;
}
Button:focus {
background: $primary;
}
#buttons {
align: center middle;
}
#conversion_details {
text-align: left;
margin: 1 2;
padding: 1 2;
border: solid $primary;
}
#warning_content {
text-align: center;
margin-bottom: 1;
margin-top: 1;
}
"""
def __init__(
self,
avi_path: Path,
mkv_path: Path,
audio_languages: list,
subtitle_files: list,
extractor
):
super().__init__()
self.avi_path = avi_path
self.mkv_path = mkv_path
self.audio_languages = audio_languages
self.subtitle_files = subtitle_files
self.extractor = extractor
def compose(self):
from ..formatters.text_formatter import TextFormatter
title_text = f"{TextFormatter.bold(TextFormatter.yellow('MKV CONVERSION'))}"
# Build details
details_lines = [
f"{TextFormatter.bold('Source:')} {TextFormatter.cyan(escape(self.avi_path.name))}",
f"{TextFormatter.bold('Output:')} {TextFormatter.green(escape(self.mkv_path.name))}",
"",
f"{TextFormatter.bold('Audio Languages:')}",
]
# Add audio language mapping
for i, lang in enumerate(self.audio_languages):
if lang:
details_lines.append(f" Track {i+1}: {TextFormatter.green(lang)}")
else:
details_lines.append(f" Track {i+1}: {TextFormatter.grey('(no language)')}")
# Add subtitle info
if self.subtitle_files:
details_lines.append("")
details_lines.append(f"{TextFormatter.bold('Subtitles to include:')}")
for sub_file in self.subtitle_files:
details_lines.append(f"{TextFormatter.blue(escape(sub_file.name))}")
else:
details_lines.append("")
details_lines.append(f"{TextFormatter.grey('No subtitle files found')}")
details_text = "\n".join(details_lines)
# Get HEVC CRF from settings
settings = self.app.settings # type: ignore
hevc_crf = settings.get("hevc_crf", 23)
info_text = f"""
{TextFormatter.bold('Choose conversion mode:')}
{TextFormatter.green('Copy Mode')} - Fast remux, no re-encoding (seconds to minutes)
{TextFormatter.yellow(f'HEVC Mode')} - Re-encode to H.265, CRF {hevc_crf} quality (minutes to hours)
{TextFormatter.grey('(Change quality in Settings with Ctrl+S)')}
""".strip()
with Center():
with Vertical():
yield Static(title_text, id="convert_content", markup=True)
yield Static(details_text, id="conversion_details", markup=True)
yield Static(info_text, id="info_text", markup=True)
with Horizontal(id="buttons"):
yield Button("Convert Copy (y)", id="convert_copy", variant="success")
yield Button("Convert HEVC (e)", id="convert_hevc", variant="primary")
yield Button("Cancel (n)", id="cancel", variant="error")
def on_mount(self):
self.set_focus(self.query_one("#convert_copy"))
def on_button_pressed(self, event):
if event.button.id == "convert_copy":
self._do_conversion(encode_hevc=False)
event.stop() # Prevent key event from also triggering
elif event.button.id == "convert_hevc":
self._do_conversion(encode_hevc=True)
event.stop() # Prevent key event from also triggering
elif event.button.id == "cancel":
self.app.pop_screen() # type: ignore
event.stop() # Prevent key event from also triggering
def _do_conversion(self, encode_hevc: bool):
"""Start conversion with the specified encoding mode."""
app = self.app
settings = app.settings # type: ignore
# Get CRF and preset from settings if using HEVC
crf = settings.get("hevc_crf", 23) if encode_hevc else 18
preset = settings.get("hevc_preset", "fast") if encode_hevc else "medium"
mode_str = f"HEVC CRF {crf} ({preset})" if encode_hevc else "Copy"
app.notify(f"Starting conversion ({mode_str})...", severity="information", timeout=2)
def do_conversion():
from ..services.conversion_service import ConversionService
import threading
import logging
conversion_service = ConversionService()
logging.info(f"Starting conversion of {self.avi_path} with encode_hevc={encode_hevc}, crf={crf}, preset={preset}")
logging.info(f"CPU architecture: {conversion_service.cpu_arch}")
success, message = conversion_service.convert_avi_to_mkv(
self.avi_path,
extractor=self.extractor,
encode_hevc=encode_hevc,
crf=crf,
preset=preset
)
logging.info(f"Conversion result: success={success}, message={message}")
# Schedule UI updates on the main thread
mkv_path = self.avi_path.with_suffix('.mkv')
def handle_success():
logging.info(f"handle_success called: {mkv_path}")
app.notify(f"{message}", severity="information", timeout=5)
logging.info(f"Adding file to tree: {mkv_path}")
app.add_file_to_tree(mkv_path)
logging.info("Conversion success handler completed")
def handle_error():
logging.info(f"handle_error called: {message}")
app.notify(f"{message}", severity="error", timeout=10)
logging.info("Conversion error handler completed")
if success:
logging.info(f"Conversion successful, scheduling UI update for {mkv_path}")
app.call_later(handle_success)
else:
logging.error(f"Conversion failed: {message}")
app.call_later(handle_error)
# Run conversion in background thread
import threading
threading.Thread(target=do_conversion, daemon=True).start()
# Close the screen
self.app.pop_screen() # type: ignore
def on_key(self, event):
if event.key == "y":
# Copy mode
self._do_conversion(encode_hevc=False)
elif event.key == "e":
# HEVC mode
self._do_conversion(encode_hevc=True)
elif event.key == "n" or event.key == "escape":
self.app.pop_screen() # type: ignore
+111
View File
@@ -0,0 +1,111 @@
from textual.screen import Screen
from textual.widgets import Button, Static
from textual.containers import Vertical, Horizontal, Center
from textual.markup import escape
from pathlib import Path
import logging
class DeleteConfirmScreen(Screen):
"""Confirmation screen for file deletion."""
CSS = """
#delete_content {
text-align: center;
}
Button:focus {
background: $primary;
}
#buttons {
align: center middle;
}
#file_details {
text-align: left;
margin: 1 2;
padding: 1 2;
border: solid $error;
}
#warning_content {
text-align: center;
margin-bottom: 1;
margin-top: 1;
}
"""
def __init__(self, file_path: Path):
super().__init__()
self.file_path = file_path
def compose(self):
from ..formatters.text_formatter import TextFormatter
title_text = f"{TextFormatter.bold(TextFormatter.red('DELETE FILE'))}"
# Build file details
file_size = self.file_path.stat().st_size if self.file_path.exists() else 0
from ..formatters.size_formatter import SizeFormatter
size_str = SizeFormatter.format_size_full(file_size)
details_lines = [
f"{TextFormatter.bold('File:')} {TextFormatter.cyan(escape(self.file_path.name))}",
f"{TextFormatter.bold('Path:')} {TextFormatter.grey(escape(str(self.file_path.parent)))}",
f"{TextFormatter.bold('Size:')} {TextFormatter.yellow(size_str)}",
]
details_text = "\n".join(details_lines)
warning_text = f"""
{TextFormatter.bold(TextFormatter.red("WARNING: This action cannot be undone!"))}
{TextFormatter.yellow("The file will be permanently deleted from your system.")}
Are you sure you want to delete this file?
""".strip()
with Center():
with Vertical():
yield Static(title_text, id="delete_content", markup=True)
yield Static(details_text, id="file_details", markup=True)
yield Static(warning_text, id="warning_content", markup=True)
with Horizontal(id="buttons"):
yield Button("No (n)", id="cancel", variant="primary")
yield Button("Yes (y)", id="delete", variant="error")
def on_mount(self):
# Set focus to "No" button by default (safer option)
self.set_focus(self.query_one("#cancel"))
def on_button_pressed(self, event):
if event.button.id == "delete":
# Delete the file
app = self.app # type: ignore
try:
if self.file_path.exists():
self.file_path.unlink()
app.notify(f"✓ Deleted: {self.file_path.name}", severity="information", timeout=3)
logging.info(f"File deleted: {self.file_path}")
# Remove from tree
app.remove_file_from_tree(self.file_path)
else:
app.notify(f"✗ File not found: {self.file_path.name}", severity="error", timeout=3)
except PermissionError:
app.notify(f"✗ Permission denied: Cannot delete {self.file_path.name}", severity="error", timeout=5)
logging.error(f"Permission denied deleting file: {self.file_path}")
except Exception as e:
app.notify(f"✗ Error deleting file: {e}", severity="error", timeout=5)
logging.error(f"Error deleting file {self.file_path}: {e}", exc_info=True)
self.app.pop_screen() # type: ignore
else:
# Cancel
self.app.pop_screen() # type: ignore
def on_key(self, event):
if event.key == "y":
# Simulate delete button press
delete_button = self.query_one("#delete")
self.on_button_pressed(type('Event', (), {'button': delete_button})())
elif event.key == "n" or event.key == "escape":
self.app.pop_screen() # type: ignore
+68
View File
@@ -0,0 +1,68 @@
from textual.screen import Screen
from textual.widgets import Static, Button
from textual.containers import Vertical
class HelpScreen(Screen):
def compose(self):
try:
from importlib.metadata import version
app_version = version("renamer")
except Exception:
app_version = "unknown"
help_text = f"""
Media File Renamer v{app_version}
A powerful tool for analyzing and renaming media files with intelligent metadata extraction.
NAVIGATION:
• Use arrow keys to navigate the file tree
• Right arrow: Expand directory
• Left arrow: Collapse directory
• Enter/Space: Select file
ACTIONS:
• o: Open directory - Change the scan directory
• s: Scan - Refresh the current directory
• f: Refresh - Reload metadata for selected file
• r: Rename - Rename selected file with proposed name
• c: Convert - Convert AVI file to MKV container with metadata
• d: Delete - Delete selected file (with confirmation)
• p: Expand/Collapse - Toggle expansion of selected directory
• m: Toggle Mode - Switch between technical and catalog display modes
• ctrl+s: Settings - Open settings window
• ctrl+p: Command Palette - Access cache commands and more
• h: Help - Show this help screen
• q: Quit - Exit the application
FEATURES:
• Automatic metadata extraction from filenames
• MediaInfo integration for technical details
• Intelligent title, year, and format detection
• Support for special editions and collections
• Real-time file analysis and renaming suggestions
FILE ANALYSIS:
The app extracts various metadata including:
• Movie/series titles and years
• Video resolution and frame rates
• Audio languages and formats
• Special edition information
• Collection order numbers
• HDR and source information
Press any key to close this help screen.
""".strip()
with Vertical():
yield Static(help_text, id="help_content")
yield Button("Close", id="close")
def on_button_pressed(self, event):
if event.button.id == "close":
self.app.pop_screen()
def on_key(self, event):
# Close on any key press
self.app.pop_screen()
+5 -2
View File
@@ -22,8 +22,8 @@ class MediaPanelView:
self.tmdb_section(),
self.tracksinfo_section(),
self.filename_section(),
self.metadata_section(),
self.mediainfo_section(),
self.metadata_section(),
]
)
@@ -124,6 +124,7 @@ class MediaPanelView:
return "\n".join(
[
self._props.title("Media Info Extraction"),
self._props.mediainfo_general_tracks,
self._props.mediainfo_duration,
self._props.mediainfo_frame_class,
self._props.mediainfo_interlace,
@@ -131,8 +132,10 @@ class MediaPanelView:
self._props.mediainfo_aspect_ratio,
self._props.mediainfo_hdr,
self._props.mediainfo_audio_langs,
self._props.mediainfo_anamorphic,
self._props.mediainfo_extension,
self._props.mediainfo_3d_layout,
self._props.mediainfo_video_tracks,
self._props.mediainfo_audio_tracks,
self._props.mediainfo_subtitle_tracks,
]
)
+38 -9
View File
@@ -205,6 +205,37 @@ class MediaPanelProperties:
# ============================================================
# MediaInfo Extraction Properties
# ============================================================
@property
@conditional_decorators.wrap("RAW General Info: ")
@text_decorators.colour(name="gray")
@conditional_decorators.default("Not extracted")
def mediainfo_general_tracks(self) -> str:
"""Get MediaInfo raw general track info formatted with label."""
return self._extractor.get("general_track", "MediaInfo")
@property
@conditional_decorators.wrap("RAW Video Tracks: ")
@text_decorators.colour(name="gray")
@conditional_decorators.default("Not extracted")
def mediainfo_video_tracks(self) -> str:
"""Get MediaInfo raw video tracks formatted with label."""
return self._extractor.get("video_tracks", "MediaInfo")
@property
@conditional_decorators.wrap("RAW Audio Tracks: ")
@text_decorators.colour(name="gray")
@conditional_decorators.default("Not extracted")
def mediainfo_audio_tracks(self) -> str:
"""Get MediaInfo raw audio tracks formatted with label."""
return self._extractor.get("audio_tracks", "MediaInfo")
@property
@conditional_decorators.wrap("RAW Subtitle Tracks: ")
@text_decorators.colour(name="gray")
@conditional_decorators.default("Not extracted")
def mediainfo_subtitle_tracks(self) -> str:
"""Get MediaInfo raw subtitle tracks formatted with label."""
return self._extractor.get("subtitle_tracks", "MediaInfo")
@property
@conditional_decorators.wrap("Duration: ")
@@ -264,14 +295,6 @@ class MediaPanelProperties:
"""Get MediaInfo audio languages formatted with label."""
return self._extractor.get("audio_langs", "MediaInfo")
@property
@conditional_decorators.wrap("Anamorphic: ")
@text_decorators.colour(name="grey")
@conditional_decorators.default("Not extracted")
def mediainfo_anamorphic(self) -> str:
"""Get MediaInfo anamorphic formatted with label."""
return self._extractor.get("anamorphic", "MediaInfo")
@property
@conditional_decorators.wrap("Extension: ")
@text_decorators.colour(name="grey")
@@ -471,6 +494,13 @@ class MediaPanelProperties:
"""Get selected audio languages formatted with label."""
return self._extractor.get("audio_langs")
@property
@text_decorators.colour(name="green")
@conditional_decorators.wrap("General Info: ")
def general_info(self) -> list[str]:
"""Get general track info formatted with label."""
return self._extractor.get("general_tracks", "MediaInfo") or []
@property
def video_tracks(self) -> list[str]:
"""Return formatted video track data"""
@@ -510,4 +540,3 @@ class MediaPanelProperties:
@track_decorators.subtitle_track()
def subtitle_track(self, track, index) -> str:
"""Get subtitle track info formatted with label."""
return track
+30
View File
@@ -0,0 +1,30 @@
from textual.screen import Screen
from textual.widgets import Input, Button
from pathlib import Path
class OpenScreen(Screen):
def compose(self):
yield Input(placeholder="Enter directory path", value=".", id="dir_input")
yield Button("OK", id="ok")
def on_button_pressed(self, event):
if event.button.id == "ok":
self.submit_path()
def on_input_submitted(self, event):
self.submit_path()
def submit_path(self):
path_str = self.query_one("#dir_input", Input).value
path = Path(path_str)
if not path.exists():
# Show error
self.query_one("#dir_input", Input).value = f"Path does not exist: {path_str}"
return
if not path.is_dir():
self.query_one("#dir_input", Input).value = f"Not a directory: {path_str}"
return
self.app.scan_dir = path # type: ignore
self.app.scan_files() # type: ignore
self.app.pop_screen()
+173
View File
@@ -0,0 +1,173 @@
from textual.screen import Screen
from textual.widgets import Input, Button, Static
from textual.containers import Vertical, Horizontal, Center
from textual.markup import escape
from pathlib import Path
import logging
class RenameConfirmScreen(Screen):
CSS = """
#confirm_content {
text-align: center;
}
# Button {
# background: $surface;
# border: solid $surface;
}
Button:focus {
background: $primary;
# color: $text-primary;
# border: solid $primary;
}
#buttons {
align: center middle;
}
#new_name_input {
width: 100%;
margin: 1 0;
}
#new_name_display {
text-align: center;
margin-bottom: 1;
}
#warning_content {
text-align: center;
margin-bottom: 0;
}
"""
def __init__(self, old_path: Path, new_name: str):
super().__init__()
self.old_path = old_path
self.new_name = new_name.replace("/", "-").replace("\\", "-")
self.new_path = old_path.parent / self.new_name
self.was_edited = False
def compose(self):
from ..formatters.text_formatter import TextFormatter
confirm_text = f"""
{TextFormatter.bold(TextFormatter.red("RENAME CONFIRMATION"))}
RAW name: {escape(self.old_path.name)}
Current name: {TextFormatter.cyan(escape(self.old_path.name))}
Proposed name: {TextFormatter.green(escape(self.new_name))}
{TextFormatter.yellow("Edit the new name below:")}
""".strip()
warning_text = f"""
{TextFormatter.bold(TextFormatter.red("This action cannot be undone!"))}
Do you want to proceed with renaming?
""".strip()
with Center():
with Vertical():
yield Static(confirm_text, id="confirm_content", markup=True)
yield Input(value=self.new_name, id="new_name_input", placeholder="New file name")
yield Static(f"{TextFormatter.green(escape(self.new_name))}", id="new_name_display", markup=True)
yield Static(warning_text, id="warning_content", markup=True)
with Horizontal(id="buttons"):
yield Button("Rename (y)", id="rename")
yield Button("Cancel (n)", id="cancel")
def on_mount(self):
self.set_focus(self.query_one("#rename"))
def on_input_changed(self, event):
if event.input.id == "new_name_input":
self.new_name = event.input.value.replace("/", "-").replace("\\", "-")
self.new_path = self.old_path.parent / self.new_name
self.was_edited = True
# Update the display
from ..formatters.text_formatter import TextFormatter
display = self.query_one("#new_name_display", Static)
display.update(f"{TextFormatter.green(escape(self.new_name))}")
def on_button_pressed(self, event):
if event.button.id == "rename":
# Check if new name is the same as old name
if self.new_name == self.old_path.name:
self.app.notify("Proposed name is the same as current name; no rename needed.", severity="information", timeout=3)
self.app.pop_screen()
return
try:
logging.info(f"Starting rename: old_path={self.old_path}, new_path={self.new_path}")
logging.info(f"Old file name: {self.old_path.name}")
logging.info(f"New file name: {self.new_name}")
logging.info(f"New path parent: {self.new_path.parent}, Old path parent: {self.old_path.parent}")
if "/" in self.new_name or "\\" in self.new_name:
logging.warning(f"New name contains path separators: {self.new_name}")
self.old_path.rename(self.new_path)
logging.info(f"Rename successful: {self.old_path} -> {self.new_path}")
# Update the tree node
self.app.update_renamed_file(self.old_path, self.new_path) # type: ignore
self.app.pop_screen()
except Exception as e:
logging.error(f"Rename failed: {self.old_path} -> {self.new_path}, error: {str(e)}")
# Show error
content = self.query_one("#confirm_content", Static)
content.update(f"Error renaming file: {str(e)}")
elif event.button.id == "cancel":
self.app.pop_screen()
def on_key(self, event):
current = self.focused
if current and hasattr(current, 'id'):
if current.id == "new_name_input":
# When input is focused, let left/right move cursor, use up/down to change focus
if event.key == "up":
self.set_focus(self.query_one("#cancel"))
elif event.key == "down":
self.set_focus(self.query_one("#rename"))
elif current.id in ("rename", "cancel"):
if event.key == "left":
if current.id == "rename":
self.set_focus(self.query_one("#new_name_input"))
elif current.id == "cancel":
self.set_focus(self.query_one("#rename"))
elif event.key == "right":
if current.id == "new_name_input":
self.set_focus(self.query_one("#rename"))
elif current.id == "rename":
self.set_focus(self.query_one("#cancel"))
elif event.key == "up":
if current.id == "rename":
self.set_focus(self.query_one("#new_name_input"))
elif current.id == "cancel":
self.set_focus(self.query_one("#rename"))
elif event.key == "down":
if current.id == "new_name_input":
self.set_focus(self.query_one("#rename"))
elif current.id == "rename":
self.set_focus(self.query_one("#cancel"))
elif current.id == "cancel":
self.set_focus(self.query_one("#new_name_input"))
# Hotkeys only work when not focused on input
if not current or not hasattr(current, 'id') or current.id != "new_name_input":
if event.key == "y":
# Trigger rename
try:
logging.info(f"Hotkey rename: old_path={self.old_path}, new_path={self.new_path}")
logging.info(f"Old file name: {self.old_path.name}")
logging.info(f"New file name: {self.new_name}")
logging.info(f"New path parent: {self.new_path.parent}, Old path parent: {self.old_path.parent}")
if "/" in self.new_name or "\\" in self.new_name:
logging.warning(f"New name contains path separators: {self.new_name}")
self.old_path.rename(self.new_path)
logging.info(f"Hotkey rename successful: {self.old_path} -> {self.new_path}")
# Update the tree node
self.app.update_renamed_file(self.old_path, self.new_path) # type: ignore
self.app.pop_screen()
except Exception as e:
logging.error(f"Hotkey rename failed: {self.old_path} -> {self.new_path}, error: {str(e)}")
# Show error
content = self.query_one("#confirm_content", Static)
content.update(f"Error renaming file: {str(e)}")
elif event.key == "n":
# Cancel
self.app.pop_screen()
+151
View File
@@ -0,0 +1,151 @@
from textual.screen import Screen
from textual.widgets import Input, Button, Static
from textual.containers import Vertical, Horizontal, Center
class SettingsScreen(Screen):
CSS = """
#settings_content {
text-align: center;
}
Button:focus {
background: $primary;
}
#buttons {
align: center middle;
}
.input_field {
width: 100%;
margin: 1 0;
}
.label {
text-align: left;
margin-bottom: 0;
}
"""
def compose(self):
from ..formatters.text_formatter import TextFormatter
settings = self.app.settings # type: ignore
content = f"""
{TextFormatter.bold("SETTINGS")}
Configure application settings.
""".strip()
with Center():
with Vertical():
yield Static(content, id="settings_content", markup=True)
# Mode selection
yield Static("Display Mode:", classes="label")
with Horizontal():
yield Button("Technical", id="mode_technical", variant="primary" if settings.get("mode") == "technical" else "default")
yield Button("Catalog", id="mode_catalog", variant="primary" if settings.get("mode") == "catalog" else "default")
# Poster selection
yield Static("Poster Display (Catalog Mode):", classes="label")
with Horizontal():
yield Button("No", id="poster_no", variant="primary" if settings.get("poster") == "no" else "default")
yield Button("ASCII", id="poster_pseudo", variant="primary" if settings.get("poster") == "pseudo" else "default")
yield Button("Viu", id="poster_viu", variant="primary" if settings.get("poster") == "viu" else "default")
yield Button("RichPixels", id="poster_richpixels", variant="primary" if settings.get("poster") == "richpixels" else "default")
# HEVC quality selection
yield Static("HEVC Encoding Quality (for conversions):", classes="label")
with Horizontal():
yield Button("CRF 18 (Visually Lossless)", id="hevc_crf_18", variant="primary" if settings.get("hevc_crf") == 18 else "default")
yield Button("CRF 23 (High Quality)", id="hevc_crf_23", variant="primary" if settings.get("hevc_crf") == 23 else "default")
yield Button("CRF 28 (Balanced)", id="hevc_crf_28", variant="primary" if settings.get("hevc_crf") == 28 else "default")
# HEVC preset selection
yield Static("HEVC Encoding Speed (faster = lower quality/smaller file):", classes="label")
with Horizontal():
yield Button("Ultrafast", id="hevc_preset_ultrafast", variant="primary" if settings.get("hevc_preset") == "ultrafast" else "default")
yield Button("Veryfast", id="hevc_preset_veryfast", variant="primary" if settings.get("hevc_preset") == "veryfast" else "default")
yield Button("Fast", id="hevc_preset_fast", variant="primary" if settings.get("hevc_preset") == "fast" else "default")
yield Button("Medium", id="hevc_preset_medium", variant="primary" if settings.get("hevc_preset") == "medium" else "default")
# TTL inputs
yield Static("Cache TTL - Extractors (hours):", classes="label")
yield Input(value=str(settings.get("cache_ttl_extractors") // 3600), id="ttl_extractors", classes="input_field")
yield Static("Cache TTL - TMDB (hours):", classes="label")
yield Input(value=str(settings.get("cache_ttl_tmdb") // 3600), id="ttl_tmdb", classes="input_field")
yield Static("Cache TTL - Posters (days):", classes="label")
yield Input(value=str(settings.get("cache_ttl_posters") // 86400), id="ttl_posters", classes="input_field")
with Horizontal(id="buttons"):
yield Button("Save", id="save")
yield Button("Cancel", id="cancel")
def on_button_pressed(self, event):
if event.button.id == "save":
self.save_settings()
self.app.pop_screen() # type: ignore
elif event.button.id == "cancel":
self.app.pop_screen() # type: ignore
elif event.button.id.startswith("mode_"):
# Toggle mode buttons
mode = event.button.id.split("_")[1]
self.app.settings.set("mode", mode) # type: ignore
# Update button variants
tech_btn = self.query_one("#mode_technical", Button)
cat_btn = self.query_one("#mode_catalog", Button)
tech_btn.variant = "primary" if mode == "technical" else "default"
cat_btn.variant = "primary" if mode == "catalog" else "default"
elif event.button.id.startswith("poster_"):
# Toggle poster buttons
poster_mode = event.button.id.split("_", 1)[1] # Use split with maxsplit=1 to handle "richpixels"
self.app.settings.set("poster", poster_mode) # type: ignore
# Update button variants
no_btn = self.query_one("#poster_no", Button)
pseudo_btn = self.query_one("#poster_pseudo", Button)
viu_btn = self.query_one("#poster_viu", Button)
richpixels_btn = self.query_one("#poster_richpixels", Button)
no_btn.variant = "primary" if poster_mode == "no" else "default"
pseudo_btn.variant = "primary" if poster_mode == "pseudo" else "default"
viu_btn.variant = "primary" if poster_mode == "viu" else "default"
richpixels_btn.variant = "primary" if poster_mode == "richpixels" else "default"
elif event.button.id.startswith("hevc_crf_"):
# Toggle HEVC CRF buttons
crf_value = int(event.button.id.split("_")[-1])
self.app.settings.set("hevc_crf", crf_value) # type: ignore
# Update button variants
crf18_btn = self.query_one("#hevc_crf_18", Button)
crf23_btn = self.query_one("#hevc_crf_23", Button)
crf28_btn = self.query_one("#hevc_crf_28", Button)
crf18_btn.variant = "primary" if crf_value == 18 else "default"
crf23_btn.variant = "primary" if crf_value == 23 else "default"
crf28_btn.variant = "primary" if crf_value == 28 else "default"
elif event.button.id.startswith("hevc_preset_"):
# Toggle HEVC preset buttons
preset_value = event.button.id.split("_")[-1]
self.app.settings.set("hevc_preset", preset_value) # type: ignore
# Update button variants
ultrafast_btn = self.query_one("#hevc_preset_ultrafast", Button)
veryfast_btn = self.query_one("#hevc_preset_veryfast", Button)
fast_btn = self.query_one("#hevc_preset_fast", Button)
medium_btn = self.query_one("#hevc_preset_medium", Button)
ultrafast_btn.variant = "primary" if preset_value == "ultrafast" else "default"
veryfast_btn.variant = "primary" if preset_value == "veryfast" else "default"
fast_btn.variant = "primary" if preset_value == "fast" else "default"
medium_btn.variant = "primary" if preset_value == "medium" else "default"
def save_settings(self):
try:
# Get values and convert to seconds
ttl_extractors = int(self.query_one("#ttl_extractors", Input).value) * 3600
ttl_tmdb = int(self.query_one("#ttl_tmdb", Input).value) * 3600
ttl_posters = int(self.query_one("#ttl_posters", Input).value) * 86400
self.app.settings.set("cache_ttl_extractors", ttl_extractors) # type: ignore
self.app.settings.set("cache_ttl_tmdb", ttl_tmdb) # type: ignore
self.app.settings.set("cache_ttl_posters", ttl_posters) # type: ignore
self.app.notify("Settings saved!", severity="information", timeout=2) # type: ignore
except ValueError:
self.app.notify("Invalid TTL values. Please enter numbers only.", severity="error", timeout=3) # type: ignore