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

327
main.py
View File

@@ -1,330 +1,6 @@
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
from renamer.app import RenamerApp
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"
def get_media_tracks(file_path):
"""Extract compact media track information"""
tracks_info = []
try:
media_info = MediaInfo.parse(file_path)
video_tracks = [t for t in media_info.tracks if t.track_type == 'Video']
audio_tracks = [t for t in media_info.tracks if t.track_type == 'Audio']
sub_tracks = [t for t in media_info.tracks if t.track_type == 'Text']
# Video tracks
for i, v in enumerate(video_tracks[:2]): # Up to 2 videos
codec = getattr(v, 'format', None) or getattr(v, 'codec', None) or 'unknown'
width = getattr(v, 'width', None) or '?'
height = getattr(v, 'height', None) or '?'
bitrate = getattr(v, 'bit_rate', None)
fps = getattr(v, 'frame_rate', None)
profile = getattr(v, 'format_profile', None)
video_str = f"{codec} {width}x{height}"
if bitrate:
video_str += f" {bitrate}bps"
if fps:
video_str += f" {fps}fps"
if profile:
video_str += f" ({profile})"
tracks_info.append(f"[green]Video {i+1}:[/green] {video_str}")
# Audio tracks
for i, a in enumerate(audio_tracks[:3]): # Up to 3 audios
codec = getattr(a, 'format', None) or getattr(a, 'codec', None) or 'unknown'
channels = getattr(a, 'channel_s', None) or '?'
lang = getattr(a, 'language', None) or 'und'
bitrate = getattr(a, 'bit_rate', None)
audio_str = f"{codec} {channels}ch {lang}"
if bitrate:
audio_str += f" {bitrate}bps"
tracks_info.append(f"[yellow]Audio {i+1}:[/yellow] {audio_str}")
# Subtitle tracks
for i, s in enumerate(sub_tracks[:3]): # Up to 3 subs
lang = getattr(s, 'language', None) or 'und'
format = getattr(s, 'format', None) or getattr(s, 'codec', None) or 'unknown'
sub_str = f"{lang} ({format})"
tracks_info.append(f"[magenta]Sub {i+1}:[/magenta] {sub_str}")
except Exception as e:
tracks_info.append(f"[red]Track info error: {str(e)}[/red]")
return "\n".join(tracks_info) if tracks_info else ""
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", markup=True)
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}")
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 = ""
# Get media tracks info
tracks_text = get_media_tracks(file_path)
if not tracks_text:
tracks_text = "[grey]No track info available[/grey]"
# 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)}\n\n[bold green]Size:[/bold green] {size_full}\n[bold cyan]File:[/bold cyan] {file_name}\n{ext_info}\n[bold magenta]Modified:[/bold magenta] {date_formatted}"
if extra_text:
full_info += f"\n\n{extra_text}"
full_info += f"\n\n{tracks_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")
@@ -333,5 +9,6 @@ def main():
app = RenamerApp(args.directory)
app.run()
if __name__ == "__main__":
main()