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 from pathlib import Path
import threading import threading
import time import time
import concurrent.futures
from .constants import MEDIA_TYPES from .constants import MEDIA_TYPES
from .screens import OpenScreen from .screens import OpenScreen, HelpScreen, RenameConfirmScreen
from .extractor import MediaExtractor from .extractor import MediaExtractor
from .formatters.media_formatter import MediaFormatter from .formatters.media_formatter import MediaFormatter
from .formatters.proposed_name_formatter import ProposedNameFormatter from .formatters.proposed_name_formatter import ProposedNameFormatter
from .formatters.text_formatter import TextFormatter from .formatters.text_formatter import TextFormatter
VERSION = "0.2.0"
class RenamerApp(App): class RenamerApp(App):
CSS = """ CSS = """
#left { #left {
@@ -30,12 +32,16 @@ class RenamerApp(App):
("q", "quit", "Quit"), ("q", "quit", "Quit"),
("o", "open", "Open directory"), ("o", "open", "Open directory"),
("s", "scan", "Scan"), ("s", "scan", "Scan"),
("r", "refresh", "Refresh"), ("f", "refresh", "Refresh"),
("r", "rename", "Rename"),
("p", "expand", "Toggle Tree"),
("h", "help", "Help"),
] ]
def __init__(self, scan_dir): def __init__(self, scan_dir):
super().__init__() super().__init__()
self.scan_dir = Path(scan_dir) if scan_dir else None self.scan_dir = Path(scan_dir) if scan_dir else None
self.tree_expanded = False
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
with Horizontal(): with Horizontal():
@@ -57,14 +63,15 @@ class RenamerApp(App):
self.scan_files() self.scan_files()
def scan_files(self): 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 = self.query_one("#details", Static)
details.update("Error: Directory does not exist or is not a directory") details.update("Error: Directory does not exist or is not a directory")
return return
tree = self.query_one("#file_tree", Tree) tree = self.query_one("#file_tree", Tree)
tree.clear() tree.clear()
self.build_tree(self.scan_dir, tree.root) 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) self.set_focus(tree)
def build_tree(self, path: Path, node): def build_tree(self, path: Path, node):
@@ -122,7 +129,7 @@ class RenamerApp(App):
self.call_later( self.call_later(
self._update_details, self._update_details,
MediaFormatter(extractor).file_info_panel(), MediaFormatter(extractor).file_info_panel(),
ProposedNameFormatter(extractor).rename_line_formatted(), ProposedNameFormatter(extractor).rename_line_formatted(file_path),
) )
except Exception as e: except Exception as e:
self.call_later( self.call_later(
@@ -158,6 +165,64 @@ class RenamerApp(App):
target=self._extract_and_show_details, args=(node.data,) target=self._extract_and_show_details, args=(node.data,)
).start() ).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): def on_key(self, event):
if event.key == "right": if event.key == "right":
tree = self.query_one("#file_tree", Tree) tree = self.query_one("#file_tree", Tree)

View File

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

View File

@@ -1,5 +1,6 @@
from .text_formatter import TextFormatter from .text_formatter import TextFormatter
from .date_formatter import DateFormatter from .date_formatter import DateFormatter
from .special_info_formatter import SpecialInfoFormatter
class ProposedNameFormatter: class ProposedNameFormatter:
@@ -15,7 +16,8 @@ class ProposedNameFormatter:
self.__frame_class = extractor.get("frame_class") or None self.__frame_class = extractor.get("frame_class") or None
self.__hdr = f",{extractor.get('hdr')}" if extractor.get("hdr") else "" self.__hdr = f",{extractor.get('hdr')}" if extractor.get("hdr") else ""
self.__audio_langs = extractor.get("audio_langs") or None 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" self.__extension = extractor.get("extension") or "ext"
def __str__(self) -> str: def __str__(self) -> str:
@@ -25,6 +27,8 @@ class ProposedNameFormatter:
def rename_line(self) -> str: 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}" 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""" """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))} <<" 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.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 from pathlib import Path
@@ -27,4 +28,143 @@ class OpenScreen(Screen):
return return
self.app.scan_dir = path self.app.scan_dir = path
self.app.scan_files() 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()