feat: Enhance renamer app with help screen, rename confirmation, and special info formatting

This commit is contained in:
sHa
2025-12-26 23:11:16 +00:00
parent 691d1e7b2d
commit a6507dec31
5 changed files with 259 additions and 17 deletions

View File

@@ -4,16 +4,18 @@ from textual.containers import Horizontal, Container, ScrollableContainer, Verti
from pathlib import Path
import threading
import time
import concurrent.futures
from .constants import MEDIA_TYPES
from .screens import OpenScreen
from .screens import OpenScreen, HelpScreen, RenameConfirmScreen
from .extractor import MediaExtractor
from .formatters.media_formatter import MediaFormatter
from .formatters.proposed_name_formatter import ProposedNameFormatter
from .formatters.text_formatter import TextFormatter
VERSION = "0.2.0"
class RenamerApp(App):
CSS = """
#left {
@@ -30,12 +32,16 @@ class RenamerApp(App):
("q", "quit", "Quit"),
("o", "open", "Open directory"),
("s", "scan", "Scan"),
("r", "refresh", "Refresh"),
("f", "refresh", "Refresh"),
("r", "rename", "Rename"),
("p", "expand", "Toggle Tree"),
("h", "help", "Help"),
]
def __init__(self, scan_dir):
super().__init__()
self.scan_dir = Path(scan_dir) if scan_dir else None
self.tree_expanded = False
def compose(self) -> ComposeResult:
with Horizontal():
@@ -57,14 +63,15 @@ class RenamerApp(App):
self.scan_files()
def scan_files(self):
if not self.scan_dir.exists() or not self.scan_dir.is_dir():
if not self.scan_dir or not self.scan_dir.exists() or not self.scan_dir.is_dir():
details = self.query_one("#details", Static)
details.update("Error: Directory does not exist or is not a directory")
return
tree = self.query_one("#file_tree", Tree)
tree.clear()
self.build_tree(self.scan_dir, tree.root)
tree.root.expand()
tree.root.expand() # Expand root level
self.tree_expanded = False # Sub-levels are collapsed
self.set_focus(tree)
def build_tree(self, path: Path, node):
@@ -122,7 +129,7 @@ class RenamerApp(App):
self.call_later(
self._update_details,
MediaFormatter(extractor).file_info_panel(),
ProposedNameFormatter(extractor).rename_line_formatted(),
ProposedNameFormatter(extractor).rename_line_formatted(file_path),
)
except Exception as e:
self.call_later(
@@ -158,6 +165,64 @@ class RenamerApp(App):
target=self._extract_and_show_details, args=(node.data,)
).start()
async def action_help(self):
self.push_screen(HelpScreen())
async def action_rename(self):
tree = self.query_one("#file_tree", Tree)
node = tree.cursor_node
if node and node.data and isinstance(node.data, Path) and node.data.is_file():
# Get the proposed name from the extractor
extractor = MediaExtractor(node.data)
proposed_formatter = ProposedNameFormatter(extractor)
new_name = str(proposed_formatter)
if new_name and new_name != node.data.name:
self.push_screen(RenameConfirmScreen(node.data, new_name))
async def action_expand(self):
tree = self.query_one("#file_tree", Tree)
if self.tree_expanded:
# Collapse all sub-levels, keep root expanded
def collapse_sub(node):
if node != tree.root:
node.collapse()
for child in node.children:
collapse_sub(child)
collapse_sub(tree.root)
self.tree_expanded = False
else:
# Expand all
def expand_all(node):
node.expand()
for child in node.children:
expand_all(child)
expand_all(tree.root)
self.tree_expanded = True
def update_renamed_file(self, old_path: Path, new_path: Path):
"""Update the tree node for a renamed file."""
tree = self.query_one("#file_tree", Tree)
def find_node(node):
if node.data == old_path:
return node
for child in node.children:
found = find_node(child)
if found:
return found
return None
node = find_node(tree.root)
if node:
node.label = new_path.name
node.data = new_path
# If this node is currently selected, refresh the details
if tree.cursor_node == node:
self._start_loading_animation()
threading.Thread(
target=self._extract_and_show_details, args=(new_path,)
).start()
def on_key(self, event):
if event.key == "right":
tree = self.query_one("#file_tree", Tree)

View File

@@ -7,6 +7,7 @@ from .text_formatter import TextFormatter
from .track_formatter import TrackFormatter
from .resolution_formatter import ResolutionFormatter
from .duration_formatter import DurationFormatter
from .special_info_formatter import SpecialInfoFormatter
class MediaFormatter:
@@ -69,6 +70,7 @@ class MediaFormatter:
"""Return formatted file info panel string"""
sections = [
self.file_info(),
self.selected_data(),
self.tracks_info(),
self.filename_extracted_data(),
self.metadata_extracted_data(),
@@ -132,7 +134,7 @@ class MediaFormatter:
"label_formatters": [TextFormatter.bold, TextFormatter.uppercase],
}
]
# Get video tracks
video_tracks = self.extractor.get("video_tracks", "MediaInfo") or []
for item in video_tracks:
@@ -145,7 +147,7 @@ class MediaFormatter:
"display_formatters": [TextFormatter.green],
}
)
# Get audio tracks
audio_tracks = self.extractor.get("audio_tracks", "MediaInfo") or []
for i, item in enumerate(audio_tracks, start=1):
@@ -158,7 +160,7 @@ class MediaFormatter:
"display_formatters": [TextFormatter.yellow],
}
)
# Get subtitle tracks
subtitle_tracks = self.extractor.get("subtitle_tracks", "MediaInfo") or []
for i, item in enumerate(subtitle_tracks, start=1):
@@ -312,20 +314,42 @@ class MediaFormatter:
"label_formatters": [TextFormatter.bold],
"value": self.extractor.get("special_info", "Filename")
or "Not extracted",
"value_formatters": [lambda x: ", ".join(x) if isinstance(x, list) else x, TextFormatter.blue],
"value_formatters": [
SpecialInfoFormatter.format_special_info,
TextFormatter.blue,
],
"display_formatters": [TextFormatter.grey],
},
{
"label": "Movie DB",
"label_formatters": [TextFormatter.bold],
"value": self.extractor.get("movie_db", "Filename")
or "Not extracted",
"value": self.extractor.get("movie_db", "Filename") or "Not extracted",
"display_formatters": [TextFormatter.grey],
}
},
]
return [self._format_data_item(item) for item in data]
def selected_data(self) -> list[str]:
"""Return formatted selected data string"""
data = [
{
"label": "Selected Data",
"label_formatters": [TextFormatter.bold, TextFormatter.uppercase],
},
{
"label": "Special info",
"label_formatters": [TextFormatter.bold],
"value": self.extractor.get("special_info") or "<None>",
"value_formatters": [
SpecialInfoFormatter.format_special_info,
TextFormatter.blue,
],
"display_formatters": [TextFormatter.yellow],
},
]
return [self._format_data_item(item) for item in data]
def _format_extra_metadata(self, metadata: dict) -> str:
"""Format extra metadata like duration, title, artist"""
data = {}

View File

@@ -1,5 +1,6 @@
from .text_formatter import TextFormatter
from .date_formatter import DateFormatter
from .special_info_formatter import SpecialInfoFormatter
class ProposedNameFormatter:
@@ -15,7 +16,8 @@ class ProposedNameFormatter:
self.__frame_class = extractor.get("frame_class") or None
self.__hdr = f",{extractor.get('hdr')}" if extractor.get("hdr") else ""
self.__audio_langs = extractor.get("audio_langs") or None
self.__special_info = f" [{', '.join(extractor.get('special_info'))}]" if extractor.get("special_info") else ""
# self.__special_info = f" [{SpecialInfoFormatter.format_special_info(extractor.get('special_info'))}]" if extractor.get("special_info") else ""
self.__special_info = f" \[{SpecialInfoFormatter.format_special_info(extractor.get('special_info'))}]" if extractor.get("special_info") else ""
self.__extension = extractor.get("extension") or "ext"
def __str__(self) -> str:
@@ -25,6 +27,8 @@ class ProposedNameFormatter:
def rename_line(self) -> str:
return f"{self.__order}{self.__title} {self.__year}{self.__special_info}{self.__source} [{self.__frame_class}{self.__hdr},{self.__audio_langs}].{self.__extension}"
def rename_line_formatted(self) -> str:
def rename_line_formatted(self, file_path) -> str:
"""Format the proposed name for display with color"""
if file_path.name == str(self):
return f">> {TextFormatter.green(str(self))} <<"
return f">> {TextFormatter.bold_yellow(str(self))} <<"

View File

@@ -0,0 +1,9 @@
class SpecialInfoFormatter:
"""Formatter for special info lists"""
@staticmethod
def format_special_info(special_info):
"""Convert special info list to comma-separated string"""
if isinstance(special_info, list):
return ", ".join(special_info)
return special_info or ""

View File

@@ -1,5 +1,6 @@
from textual.screen import Screen
from textual.widgets import Input, Button
from textual.widgets import Input, Button, Static
from textual.containers import Vertical, Horizontal, Center, Container
from pathlib import Path
@@ -27,4 +28,143 @@ class OpenScreen(Screen):
return
self.app.scan_dir = path
self.app.scan_files()
self.app.pop_screen()
self.app.pop_screen()
class HelpScreen(Screen):
def compose(self):
from .app import VERSION
help_text = f"""
Media File Renamer v{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
• p: Expand/Collapse - Toggle expansion of selected directory
• 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;
}
"""
def __init__(self, old_path: Path, new_name: str):
super().__init__()
self.old_path = old_path
self.new_name = new_name
self.new_path = old_path.parent / new_name
def compose(self):
from .formatters.text_formatter import TextFormatter
confirm_text = f"""
{TextFormatter.bold(TextFormatter.red("RENAME CONFIRMATION"))}
Current name: {TextFormatter.cyan(self.old_path.name)}
New name: {TextFormatter.green(self.new_name)}
{TextFormatter.yellow("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)
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_button_pressed(self, event):
if event.button.id == "rename":
try:
self.old_path.rename(self.new_path)
# Update the tree node
self.app.update_renamed_file(self.old_path, self.new_path)
self.app.pop_screen()
except Exception as 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):
if event.key == "left":
self.set_focus(self.query_one("#rename"))
elif event.key == "right":
self.set_focus(self.query_one("#cancel"))
elif event.key == "y":
# Trigger rename
try:
self.old_path.rename(self.new_path)
# Update the tree node
self.app.update_renamed_file(self.old_path, self.new_path)
self.app.pop_screen()
except Exception as 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()