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:
sHa
2025-12-25 03:10:40 +00:00
parent 9e331e58ce
commit 305dd5f43e
21 changed files with 792 additions and 329 deletions

View File

@@ -97,6 +97,3 @@ uv tool uninstall renamerq
## Dependencies
- textual: TUI framework
- mutagen: Media metadata detection
## License
[Add license here]

327
main.py
View File

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

View File

@@ -12,7 +12,7 @@ dependencies = [
]
[project.scripts]
renamer = "main:main"
renamer = "renamer.main:main"
[tool.uv]
package = true

7
renamer/__init__.py Normal file
View 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
View 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
View 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
View 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)
}

View File

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

View 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

View 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

View 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)
}

View File

@@ -0,0 +1 @@
# Formatters package

View 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")

View 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}')

View 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]")

View 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 ""

View 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}"

View 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
View 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
View 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'