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:
@@ -97,6 +97,3 @@ uv tool uninstall renamerq
|
|||||||
## Dependencies
|
## Dependencies
|
||||||
- textual: TUI framework
|
- textual: TUI framework
|
||||||
- mutagen: Media metadata detection
|
- mutagen: Media metadata detection
|
||||||
|
|
||||||
## License
|
|
||||||
[Add license here]
|
|
||||||
|
|||||||
327
main.py
327
main.py
@@ -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
|
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():
|
def main():
|
||||||
parser = argparse.ArgumentParser(description="Media file renamer")
|
parser = argparse.ArgumentParser(description="Media file renamer")
|
||||||
@@ -333,5 +9,6 @@ def main():
|
|||||||
app = RenamerApp(args.directory)
|
app = RenamerApp(args.directory)
|
||||||
app.run()
|
app.run()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
renamer = "main:main"
|
renamer = "renamer.main:main"
|
||||||
|
|
||||||
[tool.uv]
|
[tool.uv]
|
||||||
package = true
|
package = true
|
||||||
|
|||||||
7
renamer/__init__.py
Normal file
7
renamer/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# Renamer package
|
||||||
|
|
||||||
|
from .app import RenamerApp
|
||||||
|
from .extractor import MediaExtractor
|
||||||
|
from .formatters.media_formatter import MediaFormatter
|
||||||
|
|
||||||
|
__all__ = ['RenamerApp', 'MediaExtractor', 'MediaFormatter']
|
||||||
169
renamer/app.py
Normal file
169
renamer/app.py
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
from textual.app import App, ComposeResult
|
||||||
|
from textual.widgets import Tree, Static, Footer, LoadingIndicator
|
||||||
|
from textual.containers import Horizontal, Container, ScrollableContainer, Vertical
|
||||||
|
from pathlib import Path
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
from .constants import VIDEO_EXTENSIONS
|
||||||
|
from .utils import get_media_tracks
|
||||||
|
from .screens import OpenScreen
|
||||||
|
from .extractor import MediaExtractor
|
||||||
|
from .formatters.media_formatter import MediaFormatter
|
||||||
|
|
||||||
|
|
||||||
|
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 Vertical():
|
||||||
|
yield LoadingIndicator(id="loading")
|
||||||
|
with ScrollableContainer(id="details_container"):
|
||||||
|
yield Static("Select a file to view details", id="details", markup=True)
|
||||||
|
yield Static("", id="proposed", markup=True)
|
||||||
|
yield Footer()
|
||||||
|
|
||||||
|
def on_mount(self):
|
||||||
|
loading = self.query_one("#loading", LoadingIndicator)
|
||||||
|
loading.display = False
|
||||||
|
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 _start_loading_animation(self):
|
||||||
|
loading = self.query_one("#loading", LoadingIndicator)
|
||||||
|
loading.display = True
|
||||||
|
details = self.query_one("#details", Static)
|
||||||
|
details.update("Retrieving media data")
|
||||||
|
proposed = self.query_one("#proposed", Static)
|
||||||
|
proposed.update("")
|
||||||
|
|
||||||
|
def _stop_loading_animation(self):
|
||||||
|
loading = self.query_one("#loading", LoadingIndicator)
|
||||||
|
loading.display = False
|
||||||
|
|
||||||
|
def on_tree_node_highlighted(self, event):
|
||||||
|
node = event.node
|
||||||
|
if node.data and isinstance(node.data, Path):
|
||||||
|
if node.data.is_dir():
|
||||||
|
self._stop_loading_animation()
|
||||||
|
details = self.query_one("#details", Static)
|
||||||
|
details.update("Directory")
|
||||||
|
proposed = self.query_one("#proposed", Static)
|
||||||
|
proposed.update("")
|
||||||
|
elif node.data.is_file():
|
||||||
|
self._start_loading_animation()
|
||||||
|
threading.Thread(target=self._extract_and_show_details, args=(node.data,)).start()
|
||||||
|
|
||||||
|
def _extract_and_show_details(self, file_path: Path):
|
||||||
|
time.sleep(1) # Minimum delay to show loading
|
||||||
|
# Initialize extractors and formatters
|
||||||
|
extractor = MediaExtractor()
|
||||||
|
formatter = MediaFormatter()
|
||||||
|
|
||||||
|
# Extract all data
|
||||||
|
rename_data = extractor.extract_all(file_path)
|
||||||
|
|
||||||
|
# Get media tracks info
|
||||||
|
tracks_text = get_media_tracks(file_path)
|
||||||
|
if not tracks_text:
|
||||||
|
tracks_text = "[grey]No track info available[/grey]"
|
||||||
|
|
||||||
|
# Format file info
|
||||||
|
full_info = formatter.format_file_info(file_path, rename_data)
|
||||||
|
full_info += f"\n\n{tracks_text}"
|
||||||
|
|
||||||
|
# Format proposed name
|
||||||
|
ext_name = file_path.suffix.lower().lstrip('.')
|
||||||
|
proposed_name = formatter.format_proposed_name(rename_data, ext_name)
|
||||||
|
|
||||||
|
# Format rename lines
|
||||||
|
rename_lines = formatter.format_rename_lines(rename_data, proposed_name)
|
||||||
|
full_info += f"\n\n" + "\n".join(rename_lines[:-1])
|
||||||
|
|
||||||
|
# Update UI
|
||||||
|
self.call_later(self._update_details, full_info, proposed_name)
|
||||||
|
|
||||||
|
def _update_details(self, full_info: str, proposed_name: str):
|
||||||
|
self._stop_loading_animation()
|
||||||
|
details = self.query_one("#details", Static)
|
||||||
|
details.update(full_info)
|
||||||
|
|
||||||
|
proposed = self.query_one("#proposed", Static)
|
||||||
|
proposed.update(f"[bold yellow]Proposed filename: {proposed_name}[/bold yellow]")
|
||||||
|
|
||||||
|
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()
|
||||||
34
renamer/constants.py
Normal file
34
renamer/constants.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
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',
|
||||||
|
}
|
||||||
|
|
||||||
|
SOURCE_DICT = {
|
||||||
|
'WEB-DL': ['WEB-DL', 'WEBRip', 'WEB-Rip', 'WEB'],
|
||||||
|
'BDRip': ['BDRip', 'BD-Rip', 'BDRIP'],
|
||||||
|
'BDRemux': ['BDRemux', 'BD-Remux', 'BDREMUX'],
|
||||||
|
'DVDRip': ['DVDRip', 'DVD-Rip', 'DVDRIP'],
|
||||||
|
'HDTV': ['HDTV'],
|
||||||
|
'BluRay': ['BluRay', 'BLURAY', 'Blu-ray'],
|
||||||
|
}
|
||||||
61
renamer/extractor.py
Normal file
61
renamer/extractor.py
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
from .extractors.filename_extractor import FilenameExtractor
|
||||||
|
from .extractors.metadata_extractor import MetadataExtractor
|
||||||
|
from .extractors.mediainfo_extractor import MediaInfoExtractor
|
||||||
|
|
||||||
|
|
||||||
|
class MediaExtractor:
|
||||||
|
"""Class to extract various metadata from media files using specialized extractors"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.mediainfo_extractor = MediaInfoExtractor()
|
||||||
|
|
||||||
|
def extract_title(self, file_path: Path) -> str | None:
|
||||||
|
"""Extract movie title from metadata or filename"""
|
||||||
|
# Try metadata first
|
||||||
|
title = MetadataExtractor.extract_title(file_path)
|
||||||
|
if title:
|
||||||
|
return title
|
||||||
|
# Fallback to filename
|
||||||
|
return FilenameExtractor.extract_title(file_path)
|
||||||
|
|
||||||
|
def extract_year(self, file_path: Path) -> str | None:
|
||||||
|
"""Extract year from filename"""
|
||||||
|
return FilenameExtractor.extract_year(file_path)
|
||||||
|
|
||||||
|
def extract_source(self, file_path: Path) -> str | None:
|
||||||
|
"""Extract video source from filename"""
|
||||||
|
return FilenameExtractor.extract_source(file_path)
|
||||||
|
|
||||||
|
def extract_resolution(self, file_path: Path) -> str | None:
|
||||||
|
"""Extract resolution from media info or filename"""
|
||||||
|
# Try media info first
|
||||||
|
resolution = self.mediainfo_extractor.extract_resolution(file_path)
|
||||||
|
if resolution:
|
||||||
|
return resolution
|
||||||
|
# Fallback to filename
|
||||||
|
return FilenameExtractor.extract_resolution(file_path)
|
||||||
|
|
||||||
|
def extract_hdr(self, file_path: Path) -> str | None:
|
||||||
|
"""Extract HDR info from media info"""
|
||||||
|
return self.mediainfo_extractor.extract_hdr(file_path)
|
||||||
|
|
||||||
|
def extract_audio_langs(self, file_path: Path) -> str:
|
||||||
|
"""Extract audio languages from media info"""
|
||||||
|
return self.mediainfo_extractor.extract_audio_langs(file_path)
|
||||||
|
|
||||||
|
def extract_metadata(self, file_path: Path) -> dict:
|
||||||
|
"""Extract general metadata"""
|
||||||
|
return MetadataExtractor.extract_all_metadata(file_path)
|
||||||
|
|
||||||
|
def extract_all(self, file_path: Path) -> dict:
|
||||||
|
"""Extract all rename-related data"""
|
||||||
|
return {
|
||||||
|
'title': self.extract_title(file_path),
|
||||||
|
'year': self.extract_year(file_path),
|
||||||
|
'source': self.extract_source(file_path),
|
||||||
|
'resolution': self.extract_resolution(file_path),
|
||||||
|
'hdr': self.extract_hdr(file_path),
|
||||||
|
'audio_langs': self.extract_audio_langs(file_path),
|
||||||
|
'metadata': self.extract_metadata(file_path)
|
||||||
|
}
|
||||||
0
renamer/extractors/__init__.py
Normal file
0
renamer/extractors/__init__.py
Normal file
25
renamer/extractors/fileinfo_extractor.py
Normal file
25
renamer/extractors/fileinfo_extractor.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
class FileInfoExtractor:
|
||||||
|
"""Class to extract file information"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def extract_size(file_path: Path) -> int:
|
||||||
|
"""Extract file size in bytes"""
|
||||||
|
return file_path.stat().st_size
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def extract_modification_time(file_path: Path) -> float:
|
||||||
|
"""Extract file modification time"""
|
||||||
|
return file_path.stat().st_mtime
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def extract_file_name(file_path: Path) -> str:
|
||||||
|
"""Extract file name"""
|
||||||
|
return file_path.name
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def extract_file_path(file_path: Path) -> str:
|
||||||
|
"""Extract full file path as string"""
|
||||||
|
return str(file_path)
|
||||||
59
renamer/extractors/filename_extractor.py
Normal file
59
renamer/extractors/filename_extractor.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from ..constants import SOURCE_DICT
|
||||||
|
|
||||||
|
|
||||||
|
class FilenameExtractor:
|
||||||
|
"""Class to extract information from filename"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def extract_title(file_path: Path) -> str | None:
|
||||||
|
"""Extract movie title from filename"""
|
||||||
|
file_name = file_path.name
|
||||||
|
temp_name = re.sub(r'\s*\(\d{4}\)\s*|\s*\d{4}\s*|\.\d{4}\.', '', file_name)
|
||||||
|
|
||||||
|
# Find and remove source
|
||||||
|
source = FilenameExtractor.extract_source(file_path)
|
||||||
|
if source:
|
||||||
|
for alias in SOURCE_DICT[source]:
|
||||||
|
temp_name = re.sub(r'\b' + re.escape(alias) + r'\b', '', temp_name, flags=re.IGNORECASE)
|
||||||
|
|
||||||
|
return temp_name.rsplit('.', 1)[0].strip()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def extract_year(file_path: Path) -> str | None:
|
||||||
|
"""Extract year from filename"""
|
||||||
|
file_name = file_path.name
|
||||||
|
year_match = re.search(r'\((\d{4})\)|(\d{4})', file_name)
|
||||||
|
return (year_match.group(1) or year_match.group(2)) if year_match else None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def extract_source(file_path: Path) -> str | None:
|
||||||
|
"""Extract video source from filename"""
|
||||||
|
file_name = file_path.name
|
||||||
|
temp_name = re.sub(r'\s*\(\d{4}\)\s*|\s*\d{4}\s*|\.\d{4}\.', '', file_name)
|
||||||
|
|
||||||
|
for src, aliases in SOURCE_DICT.items():
|
||||||
|
for alias in aliases:
|
||||||
|
if re.search(r'\b' + re.escape(alias) + r'\b', temp_name, re.IGNORECASE):
|
||||||
|
return src
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def extract_resolution(file_path: Path) -> str | None:
|
||||||
|
"""Extract resolution from filename (e.g., 2160p, 1080p, 720p)"""
|
||||||
|
file_name = file_path.name
|
||||||
|
match = re.search(r'(\d{3,4})[pi]', file_name, re.IGNORECASE)
|
||||||
|
if match:
|
||||||
|
height = int(match.group(1))
|
||||||
|
if height >= 2160:
|
||||||
|
return '2160p'
|
||||||
|
elif height >= 1080:
|
||||||
|
return '1080p'
|
||||||
|
elif height >= 720:
|
||||||
|
return '720p'
|
||||||
|
elif height >= 480:
|
||||||
|
return '480p'
|
||||||
|
else:
|
||||||
|
return f'{height}p'
|
||||||
|
return None
|
||||||
76
renamer/extractors/mediainfo_extractor.py
Normal file
76
renamer/extractors/mediainfo_extractor.py
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
from pymediainfo import MediaInfo
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
|
|
||||||
|
class MediaInfoExtractor:
|
||||||
|
"""Class to extract information from MediaInfo"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.lang_map = {
|
||||||
|
'en': 'eng', 'fr': 'fre', 'de': 'ger', 'uk': 'ukr', 'ru': 'rus',
|
||||||
|
'es': 'spa', 'it': 'ita', 'pt': 'por', 'ja': 'jpn', 'ko': 'kor',
|
||||||
|
'zh': 'chi', 'und': 'und'
|
||||||
|
}
|
||||||
|
|
||||||
|
def extract_resolution(self, file_path: Path) -> str | None:
|
||||||
|
"""Extract resolution from media info"""
|
||||||
|
try:
|
||||||
|
media_info = MediaInfo.parse(file_path)
|
||||||
|
video_tracks = [t for t in media_info.tracks if t.track_type == 'Video']
|
||||||
|
if video_tracks:
|
||||||
|
height = getattr(video_tracks[0], 'height', None)
|
||||||
|
if height:
|
||||||
|
if height >= 2160:
|
||||||
|
return '2160p'
|
||||||
|
elif height >= 1080:
|
||||||
|
return '1080p'
|
||||||
|
elif height >= 720:
|
||||||
|
return '720p'
|
||||||
|
elif height >= 480:
|
||||||
|
return '480p'
|
||||||
|
else:
|
||||||
|
return f'{height}p'
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
def extract_hdr(self, file_path: Path) -> str | None:
|
||||||
|
"""Extract HDR info from media info"""
|
||||||
|
try:
|
||||||
|
media_info = MediaInfo.parse(file_path)
|
||||||
|
video_tracks = [t for t in media_info.tracks if t.track_type == 'Video']
|
||||||
|
if video_tracks:
|
||||||
|
profile = getattr(video_tracks[0], 'format_profile', '')
|
||||||
|
if 'HDR' in profile.upper():
|
||||||
|
return 'HDR'
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
def extract_audio_langs(self, file_path: Path) -> str:
|
||||||
|
"""Extract audio languages from media info"""
|
||||||
|
try:
|
||||||
|
media_info = MediaInfo.parse(file_path)
|
||||||
|
audio_tracks = [t for t in media_info.tracks if t.track_type == 'Audio']
|
||||||
|
langs = [getattr(a, 'language', 'und').lower()[:3] for a in audio_tracks]
|
||||||
|
langs = [self.lang_map.get(lang, lang) for lang in langs]
|
||||||
|
lang_counts = Counter(langs)
|
||||||
|
audio_langs = [f"{count}{lang}" if count > 1 else lang for lang, count in lang_counts.items()]
|
||||||
|
return ','.join(audio_langs)
|
||||||
|
except:
|
||||||
|
return ''
|
||||||
|
|
||||||
|
def extract_video_dimensions(self, file_path: Path) -> tuple[int, int] | None:
|
||||||
|
"""Extract video width and height"""
|
||||||
|
try:
|
||||||
|
media_info = MediaInfo.parse(file_path)
|
||||||
|
video_tracks = [t for t in media_info.tracks if t.track_type == 'Video']
|
||||||
|
if video_tracks:
|
||||||
|
width = getattr(video_tracks[0], 'width', None)
|
||||||
|
height = getattr(video_tracks[0], 'height', None)
|
||||||
|
if width and height:
|
||||||
|
return width, height
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
48
renamer/extractors/metadata_extractor.py
Normal file
48
renamer/extractors/metadata_extractor.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import mutagen
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
class MetadataExtractor:
|
||||||
|
"""Class to extract information from file metadata"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def extract_title(file_path: Path) -> str | None:
|
||||||
|
"""Extract title from metadata"""
|
||||||
|
try:
|
||||||
|
info = mutagen.File(file_path)
|
||||||
|
if info:
|
||||||
|
return getattr(info, 'title', None) or getattr(info, 'get', lambda x, default=None: default)('title', [None])[0]
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def extract_duration(file_path: Path) -> float | None:
|
||||||
|
"""Extract duration from metadata"""
|
||||||
|
try:
|
||||||
|
info = mutagen.File(file_path)
|
||||||
|
if info:
|
||||||
|
return getattr(info, 'length', None)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def extract_artist(file_path: Path) -> str | None:
|
||||||
|
"""Extract artist from metadata"""
|
||||||
|
try:
|
||||||
|
info = mutagen.File(file_path)
|
||||||
|
if info:
|
||||||
|
return getattr(info, 'artist', None) or getattr(info, 'get', lambda x, default=None: default)('artist', [None])[0]
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def extract_all_metadata(file_path: Path) -> dict:
|
||||||
|
"""Extract all metadata"""
|
||||||
|
return {
|
||||||
|
'title': MetadataExtractor.extract_title(file_path),
|
||||||
|
'duration': MetadataExtractor.extract_duration(file_path),
|
||||||
|
'artist': MetadataExtractor.extract_artist(file_path)
|
||||||
|
}
|
||||||
1
renamer/formatters/__init__.py
Normal file
1
renamer/formatters/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Formatters package
|
||||||
10
renamer/formatters/date_formatter.py
Normal file
10
renamer/formatters/date_formatter.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
class DateFormatter:
|
||||||
|
"""Class for formatting dates"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def format_modification_date(mtime: float) -> str:
|
||||||
|
"""Format file modification time"""
|
||||||
|
return datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
16
renamer/formatters/extension_extractor.py
Normal file
16
renamer/formatters/extension_extractor.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
from ..constants import VIDEO_EXT_DESCRIPTIONS
|
||||||
|
|
||||||
|
|
||||||
|
class ExtensionExtractor:
|
||||||
|
"""Class for extracting extension information"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_extension_name(file_path: Path) -> str:
|
||||||
|
"""Get extension name without dot"""
|
||||||
|
return file_path.suffix.lower().lstrip('.')
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_extension_description(ext_name: str) -> str:
|
||||||
|
"""Get description for extension"""
|
||||||
|
return VIDEO_EXT_DESCRIPTIONS.get(ext_name, f'Unknown extension .{ext_name}')
|
||||||
38
renamer/formatters/extension_formatter.py
Normal file
38
renamer/formatters/extension_formatter.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
from ..constants import VIDEO_EXT_DESCRIPTIONS
|
||||||
|
from ..utils import detect_file_type
|
||||||
|
|
||||||
|
|
||||||
|
class ExtensionFormatter:
|
||||||
|
"""Class for formatting extension information"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def check_extension_match(ext_name: str, meta_type: str) -> bool:
|
||||||
|
"""Check if file extension matches detected type"""
|
||||||
|
if ext_name.upper() == meta_type:
|
||||||
|
return True
|
||||||
|
elif ext_name == 'mkv' and meta_type == 'Matroska':
|
||||||
|
return True
|
||||||
|
elif ext_name == 'avi' and meta_type == 'AVI':
|
||||||
|
return True
|
||||||
|
elif ext_name == 'mov' and meta_type == 'QuickTime':
|
||||||
|
return True
|
||||||
|
elif ext_name == 'wmv' and meta_type == 'ASF':
|
||||||
|
return True
|
||||||
|
elif ext_name == 'flv' and meta_type == 'FLV':
|
||||||
|
return True
|
||||||
|
elif ext_name == 'webm' and meta_type == 'WebM':
|
||||||
|
return True
|
||||||
|
elif ext_name == 'ogv' and meta_type == 'Ogg':
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def format_extension_info(ext_name: str, ext_desc: str, meta_type: str, meta_desc: str, match: bool) -> str:
|
||||||
|
"""Format extension information with match status"""
|
||||||
|
if match:
|
||||||
|
return f"[bold green]Extension:[/bold green] {ext_name} - [grey]{ext_desc}[/grey]"
|
||||||
|
else:
|
||||||
|
return (f"[bold yellow]Extension:[/bold yellow] {ext_name} - [grey]{ext_desc}[/grey]\n"
|
||||||
|
f"[bold red]Meta extension:[/bold red] {meta_type} - [grey]{meta_desc}[/grey]\n"
|
||||||
|
"[bold red]Warning: Extensions do not match![/bold red]")
|
||||||
84
renamer/formatters/media_formatter.py
Normal file
84
renamer/formatters/media_formatter.py
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
from .size_formatter import SizeFormatter
|
||||||
|
from .date_formatter import DateFormatter
|
||||||
|
from .extension_extractor import ExtensionExtractor
|
||||||
|
from .extension_formatter import ExtensionFormatter
|
||||||
|
from ..utils import detect_file_type
|
||||||
|
|
||||||
|
|
||||||
|
class MediaFormatter:
|
||||||
|
"""Class to format media data for display"""
|
||||||
|
|
||||||
|
def format_file_info(self, file_path: Path, rename_data: dict) -> str:
|
||||||
|
"""Format complete file information for display"""
|
||||||
|
# Get file stats
|
||||||
|
size_full = SizeFormatter.format_size_full(file_path.stat().st_size)
|
||||||
|
date_formatted = DateFormatter.format_modification_date(file_path.stat().st_mtime)
|
||||||
|
|
||||||
|
# Get extension info
|
||||||
|
ext_name = ExtensionExtractor.get_extension_name(file_path)
|
||||||
|
ext_desc = ExtensionExtractor.get_extension_description(ext_name)
|
||||||
|
meta_type, meta_desc = detect_file_type(file_path)
|
||||||
|
match = ExtensionFormatter.check_extension_match(ext_name, meta_type)
|
||||||
|
ext_info = ExtensionFormatter.format_extension_info(ext_name, ext_desc, meta_type, meta_desc, match)
|
||||||
|
|
||||||
|
file_name = file_path.name
|
||||||
|
|
||||||
|
# Build basic info
|
||||||
|
full_info = f"[bold blue]Path:[/bold blue] {str(file_path)}\n\n"
|
||||||
|
full_info += f"[bold green]Size:[/bold green] {size_full}\n"
|
||||||
|
full_info += f"[bold cyan]File:[/bold cyan] {file_name}\n"
|
||||||
|
full_info += f"{ext_info}\n"
|
||||||
|
full_info += f"[bold magenta]Modified:[/bold magenta] {date_formatted}"
|
||||||
|
|
||||||
|
# Extra metadata
|
||||||
|
extra_text = self._format_extra_metadata(rename_data['metadata'])
|
||||||
|
if extra_text:
|
||||||
|
full_info += f"\n\n{extra_text}"
|
||||||
|
|
||||||
|
return full_info
|
||||||
|
|
||||||
|
def format_proposed_name(self, rename_data: dict, ext_name: str) -> str:
|
||||||
|
"""Format the proposed filename"""
|
||||||
|
proposed_parts = []
|
||||||
|
if rename_data['title']:
|
||||||
|
proposed_parts.append(rename_data['title'])
|
||||||
|
if rename_data['year']:
|
||||||
|
proposed_parts.append(f"({rename_data['year']})")
|
||||||
|
if rename_data['source']:
|
||||||
|
proposed_parts.append(rename_data['source'])
|
||||||
|
|
||||||
|
tags = []
|
||||||
|
if rename_data['resolution']:
|
||||||
|
tags.append(rename_data['resolution'])
|
||||||
|
if rename_data['hdr']:
|
||||||
|
tags.append(rename_data['hdr'])
|
||||||
|
if rename_data['audio_langs']:
|
||||||
|
tags.append(rename_data['audio_langs'])
|
||||||
|
if tags:
|
||||||
|
proposed_parts.append(f"[{','.join(tags)}]")
|
||||||
|
|
||||||
|
return ' '.join(proposed_parts) + f".{ext_name}"
|
||||||
|
|
||||||
|
def format_rename_lines(self, rename_data: dict, proposed_name: str) -> list[str]:
|
||||||
|
"""Format the rename information lines"""
|
||||||
|
lines = []
|
||||||
|
lines.append(f"Movie title: {rename_data['title'] or 'Unknown'}")
|
||||||
|
lines.append(f"Year: {rename_data['year'] or 'Unknown'}")
|
||||||
|
lines.append(f"Video source: {rename_data['source'] or 'Unknown'}")
|
||||||
|
lines.append(f"Resolution: {rename_data['resolution'] or 'Unknown'}")
|
||||||
|
lines.append(f"HDR: {rename_data['hdr'] or 'No'}")
|
||||||
|
lines.append(f"Audio langs: {rename_data['audio_langs'] or 'None'}")
|
||||||
|
lines.append(f"Proposed filename: {proposed_name}")
|
||||||
|
return lines
|
||||||
|
|
||||||
|
def _format_extra_metadata(self, metadata: dict) -> str:
|
||||||
|
"""Format extra metadata like duration, title, artist"""
|
||||||
|
extra_info = []
|
||||||
|
if metadata.get('duration'):
|
||||||
|
extra_info.append(f"[cyan]Duration:[/cyan] {metadata['duration']:.1f} seconds")
|
||||||
|
if metadata.get('title'):
|
||||||
|
extra_info.append(f"[cyan]Title:[/cyan] {metadata['title']}")
|
||||||
|
if metadata.get('artist'):
|
||||||
|
extra_info.append(f"[cyan]Artist:[/cyan] {metadata['artist']}")
|
||||||
|
return "\n".join(extra_info) if extra_info else ""
|
||||||
21
renamer/formatters/resolution_formatter.py
Normal file
21
renamer/formatters/resolution_formatter.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
class ResolutionFormatter:
|
||||||
|
"""Class for formatting video resolutions"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def format_resolution_p(height: int) -> str:
|
||||||
|
"""Format resolution as 2160p, 1080p, etc."""
|
||||||
|
if height >= 2160:
|
||||||
|
return '2160p'
|
||||||
|
elif height >= 1080:
|
||||||
|
return '1080p'
|
||||||
|
elif height >= 720:
|
||||||
|
return '720p'
|
||||||
|
elif height >= 480:
|
||||||
|
return '480p'
|
||||||
|
else:
|
||||||
|
return f'{height}p'
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def format_resolution_dimensions(width: int, height: int) -> str:
|
||||||
|
"""Format resolution as WIDTHxHEIGHT"""
|
||||||
|
return f"{width}x{height}"
|
||||||
17
renamer/formatters/size_formatter.py
Normal file
17
renamer/formatters/size_formatter.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
class SizeFormatter:
|
||||||
|
"""Class for formatting file sizes"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def format_size(bytes_size: int) -> str:
|
||||||
|
"""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"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def format_size_full(bytes_size: int) -> str:
|
||||||
|
"""Format size with both human readable and bytes"""
|
||||||
|
size_formatted = SizeFormatter.format_size(bytes_size)
|
||||||
|
return f"{size_formatted} ({bytes_size:,} bytes)"
|
||||||
30
renamer/screens.py
Normal file
30
renamer/screens.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
from textual.screen import Screen
|
||||||
|
from textual.widgets import Input, Button
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
93
renamer/utils.py
Normal file
93
renamer/utils.py
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
from pymediainfo import MediaInfo
|
||||||
|
from .constants import META_DESCRIPTIONS
|
||||||
|
import magic
|
||||||
|
import mutagen
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
|
|
||||||
|
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 ""
|
||||||
|
|
||||||
|
|
||||||
|
def detect_file_type(file_path):
|
||||||
|
"""Detect file type and return meta_type and desc"""
|
||||||
|
try:
|
||||||
|
info = mutagen.File(file_path)
|
||||||
|
if info is None:
|
||||||
|
# Fallback to magic
|
||||||
|
mime = magic.from_file(str(file_path), mime=True)
|
||||||
|
if mime == 'video/x-matroska':
|
||||||
|
return 'Matroska', 'Matroska multimedia container'
|
||||||
|
elif mime == 'video/mp4':
|
||||||
|
return 'MP4', 'MPEG-4 video container'
|
||||||
|
elif mime == 'video/x-msvideo':
|
||||||
|
return 'AVI', 'Audio Video Interleave'
|
||||||
|
elif mime == 'video/quicktime':
|
||||||
|
return 'QuickTime', 'QuickTime movie'
|
||||||
|
elif mime == 'video/x-ms-wmv':
|
||||||
|
return 'ASF', 'Windows Media'
|
||||||
|
elif mime == 'video/x-flv':
|
||||||
|
return 'FLV', 'Flash Video'
|
||||||
|
elif mime == 'video/webm':
|
||||||
|
return 'WebM', 'WebM multimedia'
|
||||||
|
elif mime == 'video/ogg':
|
||||||
|
return 'Ogg', 'Ogg multimedia'
|
||||||
|
else:
|
||||||
|
return 'Unknown', f'Unknown MIME: {mime}'
|
||||||
|
else:
|
||||||
|
meta_type = type(info).__name__
|
||||||
|
meta_desc = META_DESCRIPTIONS.get(meta_type, f'Unknown type {meta_type}')
|
||||||
|
return meta_type, meta_desc
|
||||||
|
except Exception as e:
|
||||||
|
return f'Error: {str(e)}', f'Error detecting type'
|
||||||
Reference in New Issue
Block a user