337 lines
12 KiB
Python
337 lines
12 KiB
Python
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"
|
|
|
|
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")
|
|
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)
|
|
|
|
# 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\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\n{extra_text}"
|
|
if tracks_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")
|
|
parser.add_argument("directory", nargs="?", default=".", help="Directory to scan")
|
|
args = parser.parse_args()
|
|
app = RenamerApp(args.directory)
|
|
app.run()
|
|
|
|
if __name__ == "__main__":
|
|
main()
|