From a6507dec316929ef4e6818acf2a3b4a208f4ba8d Mon Sep 17 00:00:00 2001 From: sHa Date: Fri, 26 Dec 2025 23:11:16 +0000 Subject: [PATCH] feat: Enhance renamer app with help screen, rename confirmation, and special info formatting --- renamer/app.py | 77 +++++++++- renamer/formatters/media_formatter.py | 38 ++++- renamer/formatters/proposed_name_formatter.py | 8 +- renamer/formatters/special_info_formatter.py | 9 ++ renamer/screens.py | 144 +++++++++++++++++- 5 files changed, 259 insertions(+), 17 deletions(-) create mode 100644 renamer/formatters/special_info_formatter.py diff --git a/renamer/app.py b/renamer/app.py index dea546f..8cac7a4 100644 --- a/renamer/app.py +++ b/renamer/app.py @@ -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) diff --git a/renamer/formatters/media_formatter.py b/renamer/formatters/media_formatter.py index 566e601..49e90c3 100644 --- a/renamer/formatters/media_formatter.py +++ b/renamer/formatters/media_formatter.py @@ -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 "", + "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 = {} diff --git a/renamer/formatters/proposed_name_formatter.py b/renamer/formatters/proposed_name_formatter.py index 442cfc2..5488d64 100644 --- a/renamer/formatters/proposed_name_formatter.py +++ b/renamer/formatters/proposed_name_formatter.py @@ -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))} <<" diff --git a/renamer/formatters/special_info_formatter.py b/renamer/formatters/special_info_formatter.py new file mode 100644 index 0000000..63dff59 --- /dev/null +++ b/renamer/formatters/special_info_formatter.py @@ -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 "" \ No newline at end of file diff --git a/renamer/screens.py b/renamer/screens.py index 71b613e..32b4a97 100644 --- a/renamer/screens.py +++ b/renamer/screens.py @@ -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() \ No newline at end of file + 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() \ No newline at end of file