diff --git a/README.md b/README.md index 6b8f16c..c49318e 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,3 @@ uv tool uninstall renamerq ## Dependencies - textual: TUI framework - mutagen: Media metadata detection - -## License -[Add license here] diff --git a/main.py b/main.py index 940ca3b..23f4e39 100644 --- a/main.py +++ b/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 -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() diff --git a/pyproject.toml b/pyproject.toml index a9fb8d0..4784f4a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,7 @@ dependencies = [ ] [project.scripts] -renamer = "main:main" +renamer = "renamer.main:main" [tool.uv] package = true diff --git a/renamer/__init__.py b/renamer/__init__.py new file mode 100644 index 0000000..c843dd0 --- /dev/null +++ b/renamer/__init__.py @@ -0,0 +1,7 @@ +# Renamer package + +from .app import RenamerApp +from .extractor import MediaExtractor +from .formatters.media_formatter import MediaFormatter + +__all__ = ['RenamerApp', 'MediaExtractor', 'MediaFormatter'] \ No newline at end of file diff --git a/renamer/app.py b/renamer/app.py new file mode 100644 index 0000000..ce79ad9 --- /dev/null +++ b/renamer/app.py @@ -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() \ No newline at end of file diff --git a/renamer/constants.py b/renamer/constants.py new file mode 100644 index 0000000..8eec79b --- /dev/null +++ b/renamer/constants.py @@ -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'], +} \ No newline at end of file diff --git a/renamer/extractor.py b/renamer/extractor.py new file mode 100644 index 0000000..5e55450 --- /dev/null +++ b/renamer/extractor.py @@ -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) + } \ No newline at end of file diff --git a/renamer/extractors/__init__.py b/renamer/extractors/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/renamer/extractors/fileinfo_extractor.py b/renamer/extractors/fileinfo_extractor.py new file mode 100644 index 0000000..12f46b3 --- /dev/null +++ b/renamer/extractors/fileinfo_extractor.py @@ -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) \ No newline at end of file diff --git a/renamer/extractors/filename_extractor.py b/renamer/extractors/filename_extractor.py new file mode 100644 index 0000000..fcec976 --- /dev/null +++ b/renamer/extractors/filename_extractor.py @@ -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 \ No newline at end of file diff --git a/renamer/extractors/mediainfo_extractor.py b/renamer/extractors/mediainfo_extractor.py new file mode 100644 index 0000000..ceb2c3d --- /dev/null +++ b/renamer/extractors/mediainfo_extractor.py @@ -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 \ No newline at end of file diff --git a/renamer/extractors/metadata_extractor.py b/renamer/extractors/metadata_extractor.py new file mode 100644 index 0000000..38de6bf --- /dev/null +++ b/renamer/extractors/metadata_extractor.py @@ -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) + } \ No newline at end of file diff --git a/renamer/formatters/__init__.py b/renamer/formatters/__init__.py new file mode 100644 index 0000000..cf08b25 --- /dev/null +++ b/renamer/formatters/__init__.py @@ -0,0 +1 @@ +# Formatters package \ No newline at end of file diff --git a/renamer/formatters/date_formatter.py b/renamer/formatters/date_formatter.py new file mode 100644 index 0000000..d7210e1 --- /dev/null +++ b/renamer/formatters/date_formatter.py @@ -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") \ No newline at end of file diff --git a/renamer/formatters/extension_extractor.py b/renamer/formatters/extension_extractor.py new file mode 100644 index 0000000..6006a4b --- /dev/null +++ b/renamer/formatters/extension_extractor.py @@ -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}') \ No newline at end of file diff --git a/renamer/formatters/extension_formatter.py b/renamer/formatters/extension_formatter.py new file mode 100644 index 0000000..05e5d83 --- /dev/null +++ b/renamer/formatters/extension_formatter.py @@ -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]") \ No newline at end of file diff --git a/renamer/formatters/media_formatter.py b/renamer/formatters/media_formatter.py new file mode 100644 index 0000000..09dffd9 --- /dev/null +++ b/renamer/formatters/media_formatter.py @@ -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 "" \ No newline at end of file diff --git a/renamer/formatters/resolution_formatter.py b/renamer/formatters/resolution_formatter.py new file mode 100644 index 0000000..2861d43 --- /dev/null +++ b/renamer/formatters/resolution_formatter.py @@ -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}" \ No newline at end of file diff --git a/renamer/formatters/size_formatter.py b/renamer/formatters/size_formatter.py new file mode 100644 index 0000000..eff272e --- /dev/null +++ b/renamer/formatters/size_formatter.py @@ -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)" \ No newline at end of file diff --git a/renamer/screens.py b/renamer/screens.py new file mode 100644 index 0000000..71b613e --- /dev/null +++ b/renamer/screens.py @@ -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() \ No newline at end of file diff --git a/renamer/utils.py b/renamer/utils.py new file mode 100644 index 0000000..4024da1 --- /dev/null +++ b/renamer/utils.py @@ -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' \ No newline at end of file