Add initial project structure with core functionality

This commit is contained in:
sHa
2025-12-25 00:05:16 +00:00
commit 8f3cd517f3
6 changed files with 443 additions and 0 deletions

10
.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
# Python-generated files
__pycache__/
*.py[oc]
build/
dist/
wheels/
*.egg-info
# Virtual environments
.venv

1
.python-version Normal file
View File

@@ -0,0 +1 @@
3.11

102
README.md Normal file
View File

@@ -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]

298
main.py Normal file
View File

@@ -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()

18
pyproject.toml Normal file
View File

@@ -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

14
todo.txt Normal file
View File

@@ -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)