feat: restructure renamer package and implement media extraction features

- Updated `pyproject.toml` to reflect new package structure.
- Created `renamer/__init__.py` to initialize the package.
- Implemented `RenamerApp` in `renamer/app.py` for the main application interface.
- Added constants for video extensions in `renamer/constants.py`.
- Developed `MediaExtractor` class in `renamer/extractor.py` for extracting metadata from media files.
- Created various extractor classes in `renamer/extractors/` for handling filename, metadata, and media info extraction.
- Added formatting classes in `renamer/formatters/` for displaying media information and proposed filenames.
- Implemented utility functions in `renamer/utils.py` for detecting file types and extracting media track information.
- Introduced `OpenScreen` in `renamer/screens.py` for user input of directory paths.
- Enhanced error handling and user feedback throughout the application.
This commit is contained in:
sha
2025-12-25 03:10:40 +00:00
parent 9e331e58ce
commit 305dd5f43e
21 changed files with 792 additions and 329 deletions
+169
View File
@@ -0,0 +1,169 @@
from textual.app import App, ComposeResult
from textual.widgets import Tree, Static, Footer, LoadingIndicator
from textual.containers import Horizontal, Container, ScrollableContainer, Vertical
from pathlib import Path
import threading
import time
from .constants import VIDEO_EXTENSIONS
from .utils import get_media_tracks
from .screens import OpenScreen
from .extractor import MediaExtractor
from .formatters.media_formatter import MediaFormatter
class RenamerApp(App):
CSS = """
#left {
width: 50%;
padding: 1;
}
#right {
width: 50%;
padding: 1;
}
"""
BINDINGS = [
("q", "quit", "Quit"),
("o", "open", "Open directory"),
("s", "scan", "Scan"),
]
def __init__(self, scan_dir):
super().__init__()
self.scan_dir = Path(scan_dir) if scan_dir else None
def compose(self) -> ComposeResult:
with Horizontal():
with Container(id="left"):
yield Tree("Files", id="file_tree")
with Container(id="right"):
with Vertical():
yield LoadingIndicator(id="loading")
with ScrollableContainer(id="details_container"):
yield Static("Select a file to view details", id="details", markup=True)
yield Static("", id="proposed", markup=True)
yield Footer()
def on_mount(self):
loading = self.query_one("#loading", LoadingIndicator)
loading.display = False
self.scan_files()
def scan_files(self):
if 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()
tree.root.add(".", data=self.scan_dir)
self.build_tree(self.scan_dir, tree.root)
tree.root.expand()
self.set_focus(tree)
def build_tree(self, path: Path, node):
try:
for item in sorted(path.iterdir()):
try:
if item.is_dir():
subnode = node.add(item.name, data=item)
self.build_tree(item, subnode)
elif item.is_file() and item.suffix.lower() in VIDEO_EXTENSIONS:
node.add(item.name, data=item)
except PermissionError:
pass
except PermissionError:
pass
def _start_loading_animation(self):
loading = self.query_one("#loading", LoadingIndicator)
loading.display = True
details = self.query_one("#details", Static)
details.update("Retrieving media data")
proposed = self.query_one("#proposed", Static)
proposed.update("")
def _stop_loading_animation(self):
loading = self.query_one("#loading", LoadingIndicator)
loading.display = False
def on_tree_node_highlighted(self, event):
node = event.node
if node.data and isinstance(node.data, Path):
if node.data.is_dir():
self._stop_loading_animation()
details = self.query_one("#details", Static)
details.update("Directory")
proposed = self.query_one("#proposed", Static)
proposed.update("")
elif node.data.is_file():
self._start_loading_animation()
threading.Thread(target=self._extract_and_show_details, args=(node.data,)).start()
def _extract_and_show_details(self, file_path: Path):
time.sleep(1) # Minimum delay to show loading
# Initialize extractors and formatters
extractor = MediaExtractor()
formatter = MediaFormatter()
# Extract all data
rename_data = extractor.extract_all(file_path)
# Get media tracks info
tracks_text = get_media_tracks(file_path)
if not tracks_text:
tracks_text = "[grey]No track info available[/grey]"
# Format file info
full_info = formatter.format_file_info(file_path, rename_data)
full_info += f"\n\n{tracks_text}"
# Format proposed name
ext_name = file_path.suffix.lower().lstrip('.')
proposed_name = formatter.format_proposed_name(rename_data, ext_name)
# Format rename lines
rename_lines = formatter.format_rename_lines(rename_data, proposed_name)
full_info += f"\n\n" + "\n".join(rename_lines[:-1])
# Update UI
self.call_later(self._update_details, full_info, proposed_name)
def _update_details(self, full_info: str, proposed_name: str):
self._stop_loading_animation()
details = self.query_one("#details", Static)
details.update(full_info)
proposed = self.query_one("#proposed", Static)
proposed.update(f"[bold yellow]Proposed filename: {proposed_name}[/bold yellow]")
def action_quit(self):
self.exit()
def action_open(self):
self.push_screen(OpenScreen())
def action_scan(self):
if self.scan_dir:
self.scan_files()
def on_key(self, event):
if event.key == "right":
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_dir():
if not node.is_expanded:
node.expand()
tree.cursor_line = node.line + 1
event.prevent_default()
elif event.key == "left":
tree = self.query_one("#file_tree", Tree)
node = tree.cursor_node
if node and node.parent:
if node.is_expanded:
node.collapse()
else:
tree.cursor_line = node.parent.line
event.prevent_default()