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

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