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
|
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)
|
||||||
|
|||||||
@@ -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(),
|
||||||
@@ -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 = {}
|
||||||
|
|||||||
@@ -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))} <<"
|
||||||
|
|||||||
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.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
|
||||||
|
|
||||||
|
|
||||||
@@ -28,3 +29,142 @@ class OpenScreen(Screen):
|
|||||||
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()
|
||||||
Reference in New Issue
Block a user