Add initial project structure with core functionality
This commit is contained in:
298
main.py
Normal file
298
main.py
Normal 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()
|
||||
Reference in New Issue
Block a user