feat: Enhance renamer app with help screen, rename confirmation, and special info formatting
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
@@ -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))} <<"
|
||||
|
||||
9
renamer/formatters/special_info_formatter.py
Normal file
9
renamer/formatters/special_info_formatter.py
Normal 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 ""
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user