commit 8f3cd517f35751e90664c50a8d33a7b7634f4906 Author: sHa Date: Thu Dec 25 00:05:16 2025 +0000 Add initial project structure with core functionality diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..505a3b1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +# Python-generated files +__pycache__/ +*.py[oc] +build/ +dist/ +wheels/ +*.egg-info + +# Virtual environments +.venv diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..2c07333 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.11 diff --git a/README.md b/README.md new file mode 100644 index 0000000..6b8f16c --- /dev/null +++ b/README.md @@ -0,0 +1,102 @@ +# Renamer - Media File Renamer and Metadata Editor + +A terminal-based (TUI) application for scanning directories, viewing media file details, and managing file metadata. Built with Python and Textual. + +## Features + +- Recursive directory scanning for video files +- Tree view navigation with keyboard and mouse support +- File details display (size, extensions, metadata) +- Command-based interface with hotkeys +- Container type detection using Mutagen + +## Installation + +### Prerequisites +- Python 3.11+ +- UV package manager + +### Install UV (if not already installed) +```bash +curl -LsSf https://astral.sh/uv/install.sh | sh +``` + +### Install the Application +```bash +# Clone or download the project +cd /path/to/renamer + +# Install dependencies and build +uv sync + +# Install as a global tool +uv tool install . +``` + +## Usage + +### Running the App +```bash +# Scan current directory +renamer + +# Scan specific directory +renamer /path/to/media/directory +``` + +### Commands +- **q**: Quit the application +- **o**: Open directory selection dialog +- **s**: Rescan current directory + +### Navigation +- Use arrow keys to navigate the file tree +- Mouse clicks supported +- Select a video file to view its details in the right panel + +## Development + +### Setup Development Environment +```bash +# Install in development mode +uv sync + +# Run directly (development) +uv run python main.py + +# Or run installed version +renamer +``` + +### Running Without Rebuilding (Development) +```bash +# Run directly from source (no installation needed) +uv run python main.py + +# Or run with specific directory +uv run python main.py /path/to/directory +``` + +### Uninstall +```bash +uv tool uninstall renamerq +``` + +## Supported Video Formats +- .mkv +- .avi +- .mov +- .mp4 +- .wmv +- .flv +- .webm +- .m4v +- .3gp +- .ogv + +## Dependencies +- textual: TUI framework +- mutagen: Media metadata detection + +## License +[Add license here] diff --git a/main.py b/main.py new file mode 100644 index 0000000..8a1726e --- /dev/null +++ b/main.py @@ -0,0 +1,298 @@ +from textual.app import App, ComposeResult +from textual.widgets import Tree, Static, Footer +from textual.containers import Horizontal, Container, ScrollableContainer +from textual.screen import Screen +from textual.widgets import Input, Button +from pathlib import Path +import mutagen +import magic +from pymediainfo import MediaInfo +import os +import argparse +from datetime import datetime + +VIDEO_EXTENSIONS = {'.mkv', '.avi', '.mov', '.mp4', '.wmv', '.flv', '.webm', '.m4v', '.3gp', '.ogv'} + +VIDEO_EXT_DESCRIPTIONS = { + 'mkv': 'Matroska multimedia container', + 'avi': 'Audio Video Interleave', + 'mov': 'QuickTime movie', + 'mp4': 'MPEG-4 video container', + 'wmv': 'Windows Media Video', + 'flv': 'Flash Video', + 'webm': 'WebM multimedia', + 'm4v': 'MPEG-4 video', + '3gp': '3GPP multimedia', + 'ogv': 'Ogg Video', +} + +META_DESCRIPTIONS = { + 'MP4': 'MPEG-4 video container', + 'Matroska': 'Matroska multimedia container', + 'AVI': 'Audio Video Interleave', + 'QuickTime': 'QuickTime movie', + 'ASF': 'Windows Media', + 'FLV': 'Flash Video', + 'WebM': 'WebM multimedia', + 'Ogg': 'Ogg multimedia', +} + +def format_size(bytes_size): + """Format bytes to human readable with unit""" + for unit in ['B', 'KB', 'MB', 'GB', 'TB']: + if bytes_size < 1024: + return f"{bytes_size:.1f} {unit}" + bytes_size /= 1024 + return f"{bytes_size:.1f} TB" + +class OpenScreen(Screen): + def compose(self): + yield Input(placeholder="Enter directory path", value=".", id="dir_input") + yield Button("OK", id="ok") + + def on_button_pressed(self, event): + if event.button.id == "ok": + self.submit_path() + + def on_input_submitted(self, event): + self.submit_path() + + def submit_path(self): + path_str = self.query_one("#dir_input", Input).value + path = Path(path_str) + if not path.exists(): + # Show error + self.query_one("#dir_input", Input).value = f"Path does not exist: {path_str}" + return + if not path.is_dir(): + self.query_one("#dir_input", Input).value = f"Not a directory: {path_str}" + return + self.app.scan_dir = path + self.app.scan_files() + self.app.pop_screen() + +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 ScrollableContainer(): + yield Static("Select a file to view details", id="details") + yield Footer() + + def on_mount(self): + 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 on_tree_node_highlighted(self, event): + node = event.node + if node.data and isinstance(node.data, Path): + if node.data.is_dir(): + details = self.query_one("#details", Static) + details.update("Directory") + elif node.data.is_file(): + self.show_details(node.data) + + def show_details(self, file_path: Path): + details = self.query_one("#details", Static) + size = file_path.stat().st_size + size_formatted = format_size(size) + size_full = f"{size_formatted} ({size:,} bytes)" + ext_name = file_path.suffix.lower().lstrip('.') + ext_desc = VIDEO_EXT_DESCRIPTIONS.get(ext_name, f'Unknown extension .{ext_name}') + mtime = file_path.stat().st_mtime + date_formatted = datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M:%S") + file_name = file_path.name + # Detect real type and extract metadata + try: + info = mutagen.File(file_path) + mime = None + if info is None: + # Fallback to magic + mime = magic.from_file(str(file_path), mime=True) + if mime == 'video/x-matroska': + meta_type = 'Matroska' + meta_desc = 'Matroska multimedia container' + elif mime == 'video/mp4': + meta_type = 'MP4' + meta_desc = 'MPEG-4 video container' + elif mime == 'video/x-msvideo': + meta_type = 'AVI' + meta_desc = 'Audio Video Interleave' + elif mime == 'video/quicktime': + meta_type = 'QuickTime' + meta_desc = 'QuickTime movie' + elif mime == 'video/x-ms-wmv': + meta_type = 'ASF' + meta_desc = 'Windows Media' + elif mime == 'video/x-flv': + meta_type = 'FLV' + meta_desc = 'Flash Video' + elif mime == 'video/webm': + meta_type = 'WebM' + meta_desc = 'WebM multimedia' + elif mime == 'video/ogg': + meta_type = 'Ogg' + meta_desc = 'Ogg multimedia' + else: + meta_type = 'Unknown' + meta_desc = f'Unknown MIME: {mime}' + else: + meta_type = type(info).__name__ + meta_desc = META_DESCRIPTIONS.get(meta_type, f'Unknown type {meta_type}') + + # Extract additional metadata + extra_info = [] + if info: + duration = getattr(info, 'length', None) + title = getattr(info, 'title', None) or getattr(info, 'get', lambda x, default=None: default)('title', [None])[0] + artist = getattr(info, 'artist', None) or getattr(info, 'get', lambda x, default=None: default)('artist', [None])[0] + if duration: + extra_info.append(f"[cyan]Duration:[/cyan] {duration:.1f} seconds") + if title: + extra_info.append(f"[cyan]Title:[/cyan] {title}") + if artist: + extra_info.append(f"[cyan]Artist:[/cyan] {artist}") + + # Use MediaInfo for detailed track info + try: + media_info = MediaInfo.parse(file_path) + for track in media_info.tracks: + if track.track_type == 'Video': + codec = track.codec or 'unknown' + width = track.width or '?' + height = track.height or '?' + extra_info.append(f"[green]Video:[/green] {codec} {width}x{height}") + break # Only first video track + elif track.track_type == 'Audio': + codec = track.codec or 'unknown' + channels = track.channel_s or '?' + lang = track.language or 'und' + extra_info.append(f"[yellow]Audio:[/yellow] {codec} {channels}ch {lang}") + elif track.track_type == 'Text': # Subtitles + lang = track.language or 'und' + extra_info.append(f"[magenta]Subtitles:[/magenta] {lang}") + except Exception as e: + extra_info.append(f"[red]Track info error: {str(e)}[/red]") + + extra_text = "\n".join(extra_info) if extra_info else "" + except Exception as e: + meta_type = f'Error: {str(e)}' + meta_desc = f'Error detecting type' + extra_text = "" + + # Check if extensions match + match = False + if ext_name.upper() == meta_type: + match = True + elif ext_name == 'mkv' and meta_type == 'Matroska': + match = True + elif ext_name == 'avi' and meta_type == 'AVI': + match = True + elif ext_name == 'mov' and meta_type == 'QuickTime': + match = True + elif ext_name == 'wmv' and meta_type == 'ASF': + match = True + elif ext_name == 'flv' and meta_type == 'FLV': + match = True + elif ext_name == 'webm' and meta_type == 'WebM': + match = True + elif ext_name == 'ogv' and meta_type == 'Ogg': + match = True + + if match: + ext_info = f"[bold green]Extension:[/bold green] {ext_name} - [grey]{ext_desc}[/grey]" + else: + ext_info = f"[bold yellow]Extension:[/bold yellow] {ext_name} - [grey]{ext_desc}[/grey]\n[bold red]Meta extension:[/bold red] {meta_type} - [grey]{meta_desc}[/grey]\n[bold red]Warning: Extensions do not match![/bold red]" + + full_info = f"[bold blue]Path:[/bold blue] {str(file_path).replace('[', '[[')}\n[bold green]Size:[/bold green] {size_full}\n[bold cyan]File:[/bold cyan] {file_name.replace('[', '[[')}\n{ext_info}\n[bold magenta]Modified:[/bold magenta] {date_formatted}" + if extra_text: + full_info += f"\n{extra_text}" + + details.update(full_info) + + 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() + +def main(): + parser = argparse.ArgumentParser(description="Media file renamer") + parser.add_argument("directory", nargs="?", default=".", help="Directory to scan") + args = parser.parse_args() + app = RenamerApp(args.directory) + app.run() + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a9fb8d0 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,18 @@ +[project] +name = "renamer" +version = "0.1.1" +description = "Add your description here" +readme = "README.md" +requires-python = ">=3.11" +dependencies = [ + "mutagen>=1.47.0", + "textual>=6.11.0", + "python-magic>=0.4.27", + "pymediainfo>=6.0.0", +] + +[project.scripts] +renamer = "main:main" + +[tool.uv] +package = true diff --git a/todo.txt b/todo.txt new file mode 100644 index 0000000..9100121 --- /dev/null +++ b/todo.txt @@ -0,0 +1,14 @@ +Project: Media File Renamer and Metadata Editor (Python TUI with Textual) + +TODO Steps: +1. Set up Python project structure with UV package manager +2. Install dependencies: textual, mutagen (for metadata detection), pathlib for file handling +3. Implement recursive directory scanning for video files (*.mkv, *.avi, *.mov, *.mp4, *.wmv, *.flv, *.webm, etc.) +4. Detect real media container type using mutagen or similar library (not by extension) +5. Create Textual TUI application with split layout (left: file tree, right: file details) +6. Implement file tree display with navigation (keyboard arrows, mouse support) +7. Add bottom command bar with initial 'exit' command +8. Display file details on right side: file size, extension from filename, extension from metadata, file date +9. Add functionality to select files in the tree and update right panel +10. Implement metadata editing capabilities (future steps) +11. Implement file renaming functionality (future steps) \ No newline at end of file