From 1d6eb9593e39883e9bcd01f172647ff201d578ac Mon Sep 17 00:00:00 2001 From: sHa Date: Fri, 26 Dec 2025 11:33:24 +0000 Subject: [PATCH] feat: Refactor formatting and extraction logic - Added `langcodes` dependency for improved language handling. - Replaced `ColorFormatter` with `TextFormatter` for consistent text styling across the application. - Introduced `TrackFormatter` for better track information formatting. - Updated `MediaFormatter` to utilize new formatting methods and improved data handling. - Refactored `MediaExtractor` to enhance data extraction logic and improve readability. - Removed deprecated `ColorFormatter` methods and replaced them with `TextFormatter` equivalents. - Added new methods for extracting and formatting audio and subtitle tracks. - Updated tests to reflect changes in the extraction logic and formatting. --- .vscode/settings.json | 7 + pyproject.toml | 1 + renamer/app.py | 10 +- renamer/extractor.py | 44 +-- renamer/extractors/mediainfo_extractor.py | 131 +++---- renamer/formatters/color_formatter.py | 54 --- renamer/formatters/extension_formatter.py | 22 +- renamer/formatters/helper_formatter.py | 7 + renamer/formatters/media_formatter.py | 369 +++++++++++++----- renamer/formatters/proposed_name_formatter.py | 15 +- renamer/formatters/resolution_formatter.py | 3 +- renamer/formatters/text_formatter.py | 103 +++++ renamer/formatters/track_formatter.py | 44 +++ renamer/test/test_mediainfo_extractor.py | 8 +- uv.lock | 11 + 15 files changed, 544 insertions(+), 285 deletions(-) create mode 100644 .vscode/settings.json delete mode 100644 renamer/formatters/color_formatter.py create mode 100644 renamer/formatters/helper_formatter.py create mode 100644 renamer/formatters/text_formatter.py create mode 100644 renamer/formatters/track_formatter.py diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..09bab06 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "python.testing.pytestArgs": [ + "renamer" + ], + "python.testing.unittestEnabled": false, + "python.testing.pytestEnabled": true +} \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 2e781b5..ea47ddc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,7 @@ dependencies = [ "python-magic>=0.4.27", "pymediainfo>=6.0.0", "pytest>=7.0.0", + "langcodes>=3.5.1", ] [project.scripts] diff --git a/renamer/app.py b/renamer/app.py index ef17cfe..f62f278 100644 --- a/renamer/app.py +++ b/renamer/app.py @@ -11,7 +11,7 @@ from .screens import OpenScreen from .extractor import MediaExtractor from .formatters.media_formatter import MediaFormatter from .formatters.proposed_name_formatter import ProposedNameFormatter -from .formatters.color_formatter import ColorFormatter +from .formatters.text_formatter import TextFormatter class RenamerApp(App): @@ -116,19 +116,17 @@ class RenamerApp(App): try: # Initialize extractors and formatters extractor = MediaExtractor(file_path) - formatter = MediaFormatter() - name_formatter = ProposedNameFormatter(extractor) # Update UI self.call_later( self._update_details, - formatter.format_file_info_panel(extractor), - name_formatter.format_display_string(), + MediaFormatter(extractor).file_info_panel(), + ProposedNameFormatter(extractor).rename_line_formatted(), ) except Exception as e: self.call_later( self._update_details, - ColorFormatter.red(f"Error extracting details: {str(e)}"), + TextFormatter.red(f"Error extracting details: {str(e)}"), "", ) diff --git a/renamer/extractor.py b/renamer/extractor.py index f7c60a1..913b392 100644 --- a/renamer/extractor.py +++ b/renamer/extractor.py @@ -18,57 +18,57 @@ class MediaExtractor: # Define sources for each data type self._sources = { 'title': [ - ('metadata', lambda: self.metadata_extractor.extract_title()), - ('filename', lambda: self.filename_extractor.extract_title()) + ('Metadata', lambda: self.metadata_extractor.extract_title()), + ('Filename', lambda: self.filename_extractor.extract_title()) ], 'year': [ - ('filename', lambda: self.filename_extractor.extract_year()) + ('Filename', lambda: self.filename_extractor.extract_year()) ], 'source': [ - ('filename', lambda: self.filename_extractor.extract_source()) + ('Filename', lambda: self.filename_extractor.extract_source()) ], 'frame_class': [ - ('mediainfo', lambda: self.mediainfo_extractor.extract_frame_class()), - ('filename', lambda: self.filename_extractor.extract_frame_class()) + ('MediaInfo', lambda: self.mediainfo_extractor.extract_frame_class()), + ('Filename', lambda: self.filename_extractor.extract_frame_class()) ], 'resolution': [ - ('mediainfo', lambda: self.mediainfo_extractor.extract_resolution()) + ('MediaInfo', lambda: self.mediainfo_extractor.extract_resolution()) ], 'aspect_ratio': [ - ('mediainfo', lambda: self.mediainfo_extractor.extract_aspect_ratio()) + ('MediaInfo', lambda: self.mediainfo_extractor.extract_aspect_ratio()) ], 'hdr': [ - ('mediainfo', lambda: self.mediainfo_extractor.extract_hdr()) + ('MediaInfo', lambda: self.mediainfo_extractor.extract_hdr()) ], 'audio_langs': [ - ('mediainfo', lambda: self.mediainfo_extractor.extract_audio_langs()) + ('MediaInfo', lambda: self.mediainfo_extractor.extract_audio_langs()) ], 'metadata': [ - ('metadata', lambda: self.metadata_extractor.extract_all_metadata()) + ('Metadata', lambda: self.metadata_extractor.extract_all_metadata()) ], 'meta_type': [ - ('metadata', lambda: self.metadata_extractor.extract_meta_type()) + ('Metadata', lambda: self.metadata_extractor.extract_meta_type()) ], 'meta_description': [ - ('metadata', lambda: self.metadata_extractor.extract_meta_description()) + ('Metadata', lambda: self.metadata_extractor.extract_meta_description()) ], 'file_size': [ - ('fileinfo', lambda: self.fileinfo_extractor.extract_size()) + ('FileInfo', lambda: self.fileinfo_extractor.extract_size()) ], 'modification_time': [ - ('fileinfo', lambda: self.fileinfo_extractor.extract_modification_time()) + ('FileInfo', lambda: self.fileinfo_extractor.extract_modification_time()) ], 'file_name': [ - ('fileinfo', lambda: self.fileinfo_extractor.extract_file_name()) + ('FileInfo', lambda: self.fileinfo_extractor.extract_file_name()) ], 'file_path': [ - ('fileinfo', lambda: self.fileinfo_extractor.extract_file_path()) + ('FileInfo', lambda: self.fileinfo_extractor.extract_file_path()) ], 'extension': [ - ('fileinfo', lambda: self.fileinfo_extractor.extract_extension()) + ('FileInfo', lambda: self.fileinfo_extractor.extract_extension()) ], 'tracks': [ - ('mediainfo', lambda: self.mediainfo_extractor.extract_tracks()) + ('MediaInfo', lambda: self.mediainfo_extractor.extract_tracks()) ] } @@ -83,7 +83,7 @@ class MediaExtractor: 'hdr': lambda x: x is not None, 'audio_langs': lambda x: x is not None, 'metadata': lambda x: x is not None, - 'tracks': lambda x: x != "" + 'tracks': lambda x: x is not None and any(x.get(k, []) for k in ['video_tracks', 'audio_tracks', 'subtitle_tracks']) } def get(self, key: str, source: str | None = None): @@ -95,10 +95,10 @@ class MediaExtractor: if source: for src, func in self._sources[key]: - if src == source: + if src.lower() == source.lower(): val = func() return val if condition(val) else None - raise ValueError(f"No such source '{source}' for key '{key}'") + return None # Source not found for this key, return None else: # Use fallback: return first valid value for src, func in self._sources[key]: diff --git a/renamer/extractors/mediainfo_extractor.py b/renamer/extractors/mediainfo_extractor.py index 67a2083..b539d94 100644 --- a/renamer/extractors/mediainfo_extractor.py +++ b/renamer/extractors/mediainfo_extractor.py @@ -2,7 +2,7 @@ from pathlib import Path from pymediainfo import MediaInfo from collections import Counter from ..constants import FRAME_CLASSES -from ..formatters.color_formatter import ColorFormatter +import langcodes class MediaInfoExtractor: @@ -37,16 +37,16 @@ class MediaInfoExtractor: return self._get_frame_class_from_height(height) return 'Unclassified' - def extract_resolution(self) -> str | None: - """Extract actual video resolution (WIDTHxHEIGHT) from media info""" + def extract_resolution(self) -> tuple[int, int] | None: + """Extract actual video resolution as (width, height) tuple from media info""" if not self.video_tracks: return None width = getattr(self.video_tracks[0], 'width', None) height = getattr(self.video_tracks[0], 'height', None) if width and height: - return f"{width}x{height}" + return width, height return None - + def extract_aspect_ratio(self) -> str | None: """Extract video aspect ratio from media info""" if not self.video_tracks: @@ -69,72 +69,65 @@ class MediaInfoExtractor: """Extract audio languages from media info""" if not self.audio_tracks: return '' - 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' - } - langs = [getattr(a, 'language', 'und').lower()[:3] for a in self.audio_tracks] - langs = [lang_map.get(lang, lang) for lang in langs] + langs = [] + for a in self.audio_tracks: + lang_code = getattr(a, 'language', 'und').lower() + try: + # Try to get the 3-letter code + lang_obj = langcodes.Language.get(lang_code) + alpha3 = lang_obj.to_alpha3() + langs.append(alpha3) + except: + # If conversion fails, use the original code + langs.append(lang_code[:3]) + 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) - def extract_video_dimensions(self) -> tuple[int, int] | None: - """Extract video width and height""" - if not self.video_tracks: - return None - width = getattr(self.video_tracks[0], 'width', None) - height = getattr(self.video_tracks[0], 'height', None) - if width and height: - return width, height - return None + def extract_video_tracks(self) -> list[dict]: + """Extract video track data""" + tracks = [] + for v in self.video_tracks[:2]: # Up to 2 videos + track_data = { + 'codec': getattr(v, 'format', None) or getattr(v, 'codec', None) or 'unknown', + 'width': getattr(v, 'width', None), + 'height': getattr(v, 'height', None), + 'bitrate': getattr(v, 'bit_rate', None), + 'fps': getattr(v, 'frame_rate', None), + 'profile': getattr(v, 'format_profile', None), + } + tracks.append(track_data) + return tracks - def extract_tracks(self) -> str: - """Extract compact media track information""" - tracks_info = [] - try: - # Video tracks - for i, v in enumerate(self.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(ColorFormatter.green(f"Video {i+1}: {video_str}")) - - # Audio tracks - for i, a in enumerate(self.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(ColorFormatter.yellow(f"Audio {i+1}: {audio_str}")) - - # Subtitle tracks - for i, s in enumerate(self.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(ColorFormatter.magenta(f"Sub {i+1}: {sub_str}")) - - except Exception as e: - tracks_info.append(ColorFormatter.red(f"Track info error: {str(e)}")) - - return "\n".join(tracks_info) if tracks_info else "" \ No newline at end of file + def extract_audio_tracks(self) -> list[dict]: + """Extract audio track data""" + tracks = [] + for a in self.audio_tracks[:3]: # Up to 3 audios + track_data = { + 'codec': getattr(a, 'format', None) or getattr(a, 'codec', None) or 'unknown', + 'channels': getattr(a, 'channel_s', None), + 'language': getattr(a, 'language', None) or 'und', + 'bitrate': getattr(a, 'bit_rate', None), + } + tracks.append(track_data) + return tracks + + def extract_subtitle_tracks(self) -> list[dict]: + """Extract subtitle track data""" + tracks = [] + for s in self.sub_tracks[:3]: # Up to 3 subs + track_data = { + 'language': getattr(s, 'language', None) or 'und', + 'format': getattr(s, 'format', None) or getattr(s, 'codec', None) or 'unknown', + } + tracks.append(track_data) + return tracks + + def extract_tracks(self) -> dict: + """Extract media track information as data""" + return { + 'video_tracks': self.extract_video_tracks(), + 'audio_tracks': self.extract_audio_tracks(), + 'subtitle_tracks': self.extract_subtitle_tracks(), + } \ No newline at end of file diff --git a/renamer/formatters/color_formatter.py b/renamer/formatters/color_formatter.py deleted file mode 100644 index 7c78eb7..0000000 --- a/renamer/formatters/color_formatter.py +++ /dev/null @@ -1,54 +0,0 @@ -class ColorFormatter: - """Class for formatting text with colors using Textual markup""" - - @staticmethod - def bold_blue(text: str) -> str: - return f"[bold blue]{text}[/bold blue]" - - @staticmethod - def bold_green(text: str) -> str: - return f"[bold green]{text}[/bold green]" - - @staticmethod - def bold_cyan(text: str) -> str: - return f"[bold cyan]{text}[/bold cyan]" - - @staticmethod - def bold_magenta(text: str) -> str: - return f"[bold magenta]{text}[/bold magenta]" - - @staticmethod - def bold_yellow(text: str) -> str: - return f"[bold yellow]{text}[/bold yellow]" - - @staticmethod - def bold_red(text: str) -> str: - return f"[bold red]{text}[/bold red]" - - @staticmethod - def green(text: str) -> str: - return f"[green]{text}[/green]" - - @staticmethod - def yellow(text: str) -> str: - return f"[yellow]{text}[/yellow]" - - @staticmethod - def magenta(text: str) -> str: - return f"[magenta]{text}[/magenta]" - - @staticmethod - def cyan(text: str) -> str: - return f"[cyan]{text}[/cyan]" - - @staticmethod - def red(text: str) -> str: - return f"[red]{text}[/red]" - - @staticmethod - def grey(text: str) -> str: - return f"[grey]{text}[/grey]" - - @staticmethod - def dim(text: str) -> str: - return f"[dim]{text}[/dim]" \ No newline at end of file diff --git a/renamer/formatters/extension_formatter.py b/renamer/formatters/extension_formatter.py index 07506c4..5266359 100644 --- a/renamer/formatters/extension_formatter.py +++ b/renamer/formatters/extension_formatter.py @@ -1,24 +1,16 @@ from pathlib import Path from ..constants import MEDIA_TYPES -from .color_formatter import ColorFormatter +from .text_formatter import TextFormatter 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 in MEDIA_TYPES and MEDIA_TYPES[ext_name]['meta_type'] == meta_type: - 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"{ColorFormatter.bold_green('Extension:')} {ext_name} - {ColorFormatter.grey(ext_desc)}" + def format_extension_info(ext_name: str) -> str: + """Format extension information""" + if ext_name in MEDIA_TYPES: + ext_desc = MEDIA_TYPES[ext_name]['description'] + return f"{ext_name} - {TextFormatter.grey(ext_desc)}" else: - return (f"{ColorFormatter.bold_yellow('Extension:')} {ext_name} - {ColorFormatter.grey(ext_desc)}\n" - f"{ColorFormatter.bold_red('Meta extension:')} {meta_type} - {ColorFormatter.grey(meta_desc)}\n" - f"{ColorFormatter.bold_red('Warning: Extensions do not match!')}") \ No newline at end of file + return f"{ext_name} - {TextFormatter.grey('Unknown extension')}" \ No newline at end of file diff --git a/renamer/formatters/helper_formatter.py b/renamer/formatters/helper_formatter.py new file mode 100644 index 0000000..730d7fd --- /dev/null +++ b/renamer/formatters/helper_formatter.py @@ -0,0 +1,7 @@ + +class HelperFormatter: + + @staticmethod + def escape_underscores(text: str) -> str: + """Escape underscores in a string by prefixing them with a backslash""" + return text.replace("_", r"\_") diff --git a/renamer/formatters/media_formatter.py b/renamer/formatters/media_formatter.py index e0df9a3..5d42e67 100644 --- a/renamer/formatters/media_formatter.py +++ b/renamer/formatters/media_formatter.py @@ -3,113 +3,220 @@ from .size_formatter import SizeFormatter from .date_formatter import DateFormatter from .extension_extractor import ExtensionExtractor from .extension_formatter import ExtensionFormatter -from .color_formatter import ColorFormatter +from .text_formatter import TextFormatter +from .track_formatter import TrackFormatter +from .resolution_formatter import ResolutionFormatter class MediaFormatter: """Class to format media data for display""" - def format_file_info_panel(self, extractor) -> str: - """Format file information for the file info panel""" - data = [ - { - "label": "Path", - "value": extractor.get("file_path"), - "format_func": ColorFormatter.bold_blue, - }, - { - "label": "Size", - "value": SizeFormatter.format_size_full(extractor.get("file_size")), - "format_func": ColorFormatter.bold_green, - }, - { - "label": "File", - "value": extractor.get("file_name"), - "format_func": ColorFormatter.bold_cyan, - }, - { - "label": "Modified", - "value": DateFormatter.format_modification_date( - extractor.get("modification_time") - ), - "format_func": ColorFormatter.bold_magenta, - }, - ] + def __init__(self, extractor): + self.extractor = extractor - # Get extension info - ext_name = ExtensionExtractor.get_extension_name( - Path(extractor.get("file_path")) - ) - ext_desc = ExtensionExtractor.get_extension_description(ext_name) - meta_type = extractor.get("meta_type") - meta_desc = extractor.get("meta_description") - match = ExtensionFormatter.check_extension_match(ext_name, meta_type) - ext_info = ExtensionFormatter.format_extension_info( - ext_name, ext_desc, meta_type, meta_desc, match - ) + def _format_data_item(self, item: dict) -> str: + """Apply all formatting to a data item and return the formatted string""" + # Define text formatters that should be applied before markup + text_formatters_set = { + TextFormatter.uppercase, + TextFormatter.lowercase, + TextFormatter.camelcase, + } - output = [ColorFormatter.bold_blue("FILE INFO"), ""] - output.extend( - item["format_func"](f"{item['label']}: {item['value']}") for item in data - ) - output.append(ext_info) + # Handle value formatting first (e.g., size formatting) + value = item.get("value") + if value is not None: + value_formatters = item.get("value_formatters", []) + if not isinstance(value_formatters, list): + value_formatters = [value_formatters] if value_formatters else [] + for formatter in value_formatters: + value = formatter(value) + + # Handle label formatting + label = item.get("label", "") + if label: + label_formatters = item.get("label_formatters", []) + if not isinstance(label_formatters, list): + label_formatters = [label_formatters] if label_formatters else [] + # Separate text and markup formatters, apply text first + text_fs = [f for f in label_formatters if f in text_formatters_set] + markup_fs = [f for f in label_formatters if f not in text_formatters_set] + ordered_formatters = text_fs + markup_fs + for formatter in ordered_formatters: + label = formatter(label) + + # Create the display string + if value is not None: + display_string = f"{label}: {value}" + else: + display_string = label + + # Handle display formatting (e.g., color) + display_formatters = item.get("display_formatters", []) + if not isinstance(display_formatters, list): + display_formatters = [display_formatters] if display_formatters else [] + # Separate text and markup formatters, apply text first + text_fs = [f for f in display_formatters if f in text_formatters_set] + markup_fs = [f for f in display_formatters if f not in text_formatters_set] + ordered_formatters = text_fs + markup_fs + for formatter in ordered_formatters: + display_string = formatter(display_string) + + return display_string + + def file_info_panel(self) -> str: + """Return formatted file info panel string""" + + output = self.file_info() # Add tracks info - tracks_text = extractor.get('tracks') - if not tracks_text: - tracks_text = ColorFormatter.grey("No track info available") output.append("") - output.append(tracks_text) + output.extend(self.tracks_info()) - # Add rename lines - rename_lines = self.format_rename_lines(extractor) + # Add filename extracted data output.append("") - output.extend(rename_lines) + output.extend(self.filename_extracted_data()) + + # Add mediainfo extracted data + output.append("") + output.extend(self.mediainfo_extracted_data()) return "\n".join(output) - def format_filename_extraction_panel(self, extractor) -> str: + def file_info(self) -> list[str]: + data = [ + { + "group": "File Info", + "label": "File Info", + "label_formatters": [TextFormatter.bold, TextFormatter.uppercase], + }, + { + "group": "File Info", + "label": "Path", + "label_formatters": [TextFormatter.bold], + "value": self.extractor.get("file_path"), + "display_formatters": [TextFormatter.blue], + }, + { + "group": "File Info", + "label": "Size", + "value": self.extractor.get("file_size"), + "value_formatters": [SizeFormatter.format_size_full], + "display_formatters": [TextFormatter.bold, TextFormatter.green], + }, + { + "group": "File Info", + "label": "Name", + "label_formatters": [TextFormatter.bold], + "value": self.extractor.get("file_name"), + "display_formatters": [TextFormatter.cyan], + }, + { + "group": "File Info", + "label": "Modified", + "label_formatters": [TextFormatter.bold], + "value": self.extractor.get("modification_time"), + "value_formatters": [DateFormatter.format_modification_date], + "display_formatters": [TextFormatter.bold, TextFormatter.magenta], + }, + { + "group": "File Info", + "label": "Extension", + "label_formatters": [TextFormatter.bold], + "value": self.extractor.get("extension"), + "value_formatters": [ExtensionFormatter.format_extension_info], + "display_formatters": [TextFormatter.green], + }, + ] + return [self._format_data_item(item) for item in data] + + def tracks_info(self) -> list[str]: + """Return formatted tracks information""" + data = [ + { + "group": "Tracks Info", + "label": "Tracks Info", + "label_formatters": [TextFormatter.bold, TextFormatter.uppercase], + } + ] + for item in self.extractor.get("tracks").get("video_tracks"): + data.append( + { + "group": "Tracks Info", + "label": "Video Track", + "value": item, + "value_formatters": TrackFormatter.format_video_track, + "display_formatters": [TextFormatter.green], + } + ) + for i, item in enumerate( + self.extractor.get("tracks").get("audio_tracks"), start=1 + ): + data.append( + { + "group": "Tracks Info", + "label": f"Audio Track {i}", + "value": item, + "value_formatters": TrackFormatter.format_audio_track, + "display_formatters": [TextFormatter.yellow], + } + ) + for i, item in enumerate( + self.extractor.get("tracks").get("subtitle_tracks"), start=1 + ): + data.append( + { + "group": "Tracks Info", + "label": f"Subtitle Track {i}", + "value": item, + "value_formatters": TrackFormatter.format_subtitle_track, + "display_formatters": [TextFormatter.magenta], + } + ) + + return [self._format_data_item(item) for item in data] + + def format_filename_extraction_panel(self) -> str: """Format filename extraction data for the filename panel""" data = [ { "label": "Title", - "value": extractor.get("title") or "Not found", - "format_func": ColorFormatter.yellow, + "value": self.extractor.get("title") or "Not found", + "display_formatters": [TextFormatter.yellow], }, { "label": "Year", - "value": extractor.get("year") or "Not found", - "format_func": ColorFormatter.yellow, + "value": self.extractor.get("year") or "Not found", + "display_formatters": [TextFormatter.yellow], }, { "label": "Source", - "value": extractor.get("source") or "Not found", - "format_func": ColorFormatter.yellow, + "value": self.extractor.get("source") or "Not found", + "display_formatters": [TextFormatter.yellow], }, { "label": "Frame Class", - "value": extractor.get("frame_class") or "Not found", - "format_func": ColorFormatter.yellow, + "value": self.extractor.get("frame_class") or "Not found", + "display_formatters": [TextFormatter.yellow], }, ] - output = [ColorFormatter.bold_yellow("FILENAME EXTRACTION"), ""] - output.extend( - item["format_func"](f"{item['label']}: {item['value']}") for item in data - ) + output = [TextFormatter.bold_yellow("FILENAME EXTRACTION"), ""] + for item in data: + output.append(self._format_data_item(item)) return "\n".join(output) - def format_metadata_extraction_panel(self, extractor) -> str: + def format_metadata_extraction_panel(self) -> str: """Format metadata extraction data for the metadata panel""" - metadata = extractor.get("metadata") or {} + metadata = self.extractor.get("metadata") or {} data = [] if metadata.get("duration"): data.append( { "label": "Duration", "value": f"{metadata['duration']:.1f} seconds", - "format_func": ColorFormatter.cyan, + "display_formatters": [TextFormatter.cyan], } ) if metadata.get("title"): @@ -117,7 +224,7 @@ class MediaFormatter: { "label": "Title", "value": metadata["title"], - "format_func": ColorFormatter.cyan, + "display_formatters": [TextFormatter.cyan], } ) if metadata.get("artist"): @@ -125,67 +232,119 @@ class MediaFormatter: { "label": "Artist", "value": metadata["artist"], - "format_func": ColorFormatter.cyan, + "display_formatters": [TextFormatter.cyan], } ) - output = [ColorFormatter.bold_cyan("METADATA EXTRACTION"), ""] + output = [TextFormatter.bold_cyan("METADATA EXTRACTION"), ""] if data: - output.extend( - item["format_func"](f"{item['label']}: {item['value']}") - for item in data - ) + for item in data: + output.append(self._format_data_item(item)) else: - output.append(ColorFormatter.dim("No metadata found")) + output.append(TextFormatter.dim("No metadata found")) return "\n".join(output) - def format_mediainfo_extraction_panel(self, extractor) -> str: + def mediainfo_extracted_data(self) -> list[str]: """Format media info extraction data for the mediainfo panel""" data = [ + { + "label": "Media Info Extraction", + "label_formatters": [TextFormatter.bold, TextFormatter.uppercase], + }, + { + "label": "Frame Class", + "label_formatters": [TextFormatter.bold], + "value": self.extractor.get("frame_class", "MediaInfo") + or "Not extracted", + "display_formatters": [TextFormatter.grey], + }, { "label": "Resolution", - "value": extractor.get("resolution") or "Not found", - "format_func": ColorFormatter.green, + "label_formatters": [TextFormatter.bold], + "value": self.extractor.get("resolution", "MediaInfo") + or "Not extracted", + "value_formatters": [ResolutionFormatter.format_resolution_dimensions], + "display_formatters": [TextFormatter.grey], }, { "label": "Aspect Ratio", - "value": extractor.get("aspect_ratio") or "Not found", - "format_func": ColorFormatter.green, + "label_formatters": [TextFormatter.bold], + "value": self.extractor.get("aspect_ratio", "MediaInfo") + or "Not extracted", + "display_formatters": [TextFormatter.grey], }, { "label": "HDR", - "value": extractor.get("hdr") or "Not found", - "format_func": ColorFormatter.green, + "label_formatters": [TextFormatter.bold], + "value": self.extractor.get("hdr", "MediaInfo") or "Not extracted", + "display_formatters": [TextFormatter.grey], }, { "label": "Audio Languages", - "value": extractor.get("audio_langs") or "Not found", - "format_func": ColorFormatter.green, + "label_formatters": [TextFormatter.bold], + "value": self.extractor.get("audio_langs", "MediaInfo") + or "Not extracted", + "display_formatters": [TextFormatter.grey], + }, + ] + return [self._format_data_item(item) for item in data] + + def filename_extracted_data(self) -> list[str]: + """Return formatted filename extracted data""" + data = [ + { + "label": "Filename Extracted Data", + "label_formatters": [TextFormatter.bold, TextFormatter.uppercase], + }, + { + "label": "Movie title", + "label_formatters": [TextFormatter.bold], + "value": self.extractor.get("title", "Filename"), + "display_formatters": [TextFormatter.grey], + }, + { + "label": "Year", + "label_formatters": [TextFormatter.bold], + "value": self.extractor.get("year", "Filename"), + "display_formatters": [TextFormatter.grey], + }, + { + "label": "Video source", + "label_formatters": [TextFormatter.bold], + "value": self.extractor.get("source", "Filename") or "Not extracted", + "display_formatters": [TextFormatter.grey], + }, + { + "label": "Frame class", + "label_formatters": [TextFormatter.bold], + "value": self.extractor.get("frame_class", "Filename") + or "Not extracted", + "display_formatters": [TextFormatter.grey], + }, + { + "label": "Aspect ratio", + "label_formatters": [TextFormatter.bold], + "value": self.extractor.get("aspect_ratio", "Filename") + or "Not extracted", + "display_formatters": [TextFormatter.grey], + }, + { + "label": "HDR", + "label_formatters": [TextFormatter.bold], + "value": self.extractor.get("hdr", "Filename") or "Not extracted", + "display_formatters": [TextFormatter.grey], + }, + { + "label": "Audio langs", + "label_formatters": [TextFormatter.bold], + "value": self.extractor.get("audio_langs", "Filename") + or "Not extracted", + "display_formatters": [TextFormatter.grey], }, ] - output = [ColorFormatter.bold_green("MEDIA INFO EXTRACTION"), ""] - output.extend( - item["format_func"](f"{item['label']}: {item['value']}") for item in data - ) - - return "\n".join(output) - - def format_rename_lines(self, extractor) -> list[str]: - """Format the rename information lines""" - data = { - "Movie title": extractor.get("title") or "Unknown", - "Year": extractor.get("year") or "Unknown", - "Video source": extractor.get("source") or "Unknown", - "Frame class": extractor.get("frame_class") or "Unknown", - "Resolution": extractor.get("resolution") or "Unknown", - "Aspect ratio": extractor.get("aspect_ratio") or "Unknown", - "HDR": extractor.get("hdr") or "No", - "Audio langs": extractor.get("audio_langs") or "None", - } - - return [f"{key}: {value}" for key, value in data.items()] + return [self._format_data_item(item) for item in data] def _format_extra_metadata(self, metadata: dict) -> str: """Format extra metadata like duration, title, artist""" @@ -198,5 +357,5 @@ class MediaFormatter: data["Artist"] = metadata["artist"] return "\n".join( - ColorFormatter.cyan(f"{key}: {value}") for key, value in data.items() + TextFormatter.cyan(f"{key}: {value}") for key, value in data.items() ) diff --git a/renamer/formatters/proposed_name_formatter.py b/renamer/formatters/proposed_name_formatter.py index 1ebd341..17732cd 100644 --- a/renamer/formatters/proposed_name_formatter.py +++ b/renamer/formatters/proposed_name_formatter.py @@ -1,4 +1,4 @@ -from .color_formatter import ColorFormatter +from .text_formatter import TextFormatter from .date_formatter import DateFormatter @@ -6,11 +6,11 @@ class ProposedNameFormatter: """Class for formatting proposed filenames""" def __init__(self, extractor): - self.extractor = extractor + """Initialize with media extractor data""" self.__title = extractor.get("title") or "Unknown Title" self.__year = DateFormatter.format_year(extractor.get("year")) - self.__source = extractor.get("source") or None + self.__source = f" {extractor.get('source')}" if extractor.get("source") else "" self.__frame_class = extractor.get("frame_class") or None self.__hdr = f",{extractor.get('hdr')}" if extractor.get("hdr") else "" self.__audio_langs = extractor.get("audio_langs") or None @@ -18,8 +18,11 @@ class ProposedNameFormatter: def __str__(self) -> str: """Convert the proposed name to string""" - return f"{self.__title} {self.__year} {self.__source} [{self.__frame_class}{self.__hdr},{self.__audio_langs}].{self.__extension}" + return self.rename_line() - def format_display_string(self) -> str: + def rename_line(self) -> str: + return f"{self.__title} {self.__year}{self.__source} [{self.__frame_class}{self.__hdr},{self.__audio_langs}].{self.__extension}" + + def rename_line_formatted(self) -> str: """Format the proposed name for display with color""" - return ColorFormatter.bold_yellow(str(self)) + return f">> {TextFormatter.bold_yellow(str(self))} <<" diff --git a/renamer/formatters/resolution_formatter.py b/renamer/formatters/resolution_formatter.py index adbc7b0..ead706c 100644 --- a/renamer/formatters/resolution_formatter.py +++ b/renamer/formatters/resolution_formatter.py @@ -54,6 +54,7 @@ class ResolutionFormatter: return f'{height}p' @staticmethod - def format_resolution_dimensions(width: int, height: int) -> str: + def format_resolution_dimensions(resolution: tuple[int, int]) -> str: """Format resolution as WIDTHxHEIGHT""" + width, height = resolution return f"{width}x{height}" \ No newline at end of file diff --git a/renamer/formatters/text_formatter.py b/renamer/formatters/text_formatter.py new file mode 100644 index 0000000..7e17ae3 --- /dev/null +++ b/renamer/formatters/text_formatter.py @@ -0,0 +1,103 @@ +class TextFormatter: + """Class for formatting text with colors and styles using Textual markup""" + + @staticmethod + def bold(text: str) -> str: + return f"[bold]{text}[/bold]" + + @staticmethod + def italic(text: str) -> str: + return f"[italic]{text}[/italic]" + + @staticmethod + def underline(text: str) -> str: + return f"[underline]{text}[/underline]" + + @staticmethod + def uppercase(text: str) -> str: + return text.upper() + + @staticmethod + def lowercase(text: str) -> str: + return text.lower() + + @staticmethod + def camelcase(text: str) -> str: + """Convert text to CamelCase (first letter of each word capitalized)""" + return ''.join(word.capitalize() for word in text.split()) + + @staticmethod + def bold_green(text: str) -> str: + """Deprecated: Use [TextFormatter.bold, TextFormatter.green] instead""" + import warnings + warnings.warn( + "TextFormatter.bold_green is deprecated. Use [TextFormatter.bold, TextFormatter.green] instead.", + DeprecationWarning, + stacklevel=2 + ) + return f"[bold green]{text}[/bold green]" + + @staticmethod + def bold_cyan(text: str) -> str: + """Deprecated: Use [TextFormatter.bold, TextFormatter.cyan] instead""" + import warnings + warnings.warn( + "TextFormatter.bold_cyan is deprecated. Use [TextFormatter.bold, TextFormatter.cyan] instead.", + DeprecationWarning, + stacklevel=2 + ) + return f"[bold cyan]{text}[/bold cyan]" + + @staticmethod + def bold_magenta(text: str) -> str: + """Deprecated: Use [TextFormatter.bold, TextFormatter.magenta] instead""" + import warnings + warnings.warn( + "TextFormatter.bold_magenta is deprecated. Use [TextFormatter.bold, TextFormatter.magenta] instead.", + DeprecationWarning, + stacklevel=2 + ) + return f"[bold magenta]{text}[/bold magenta]" + + @staticmethod + def bold_yellow(text: str) -> str: + """Deprecated: Use [TextFormatter.bold, TextFormatter.yellow] instead""" + import warnings + warnings.warn( + "TextFormatter.bold_yellow is deprecated. Use [TextFormatter.bold, TextFormatter.yellow] instead.", + DeprecationWarning, + stacklevel=2 + ) + return f"[bold yellow]{text}[/bold yellow]" + + @staticmethod + def green(text: str) -> str: + return f"[green]{text}[/green]" + + @staticmethod + def yellow(text: str) -> str: + return f"[yellow]{text}[/yellow]" + + @staticmethod + def magenta(text: str) -> str: + return f"[magenta]{text}[/magenta]" + + @staticmethod + def cyan(text: str) -> str: + return f"[cyan]{text}[/cyan]" + + @staticmethod + def red(text: str) -> str: + return f"[red]{text}[/red]" + + @staticmethod + def blue(text: str) -> str: + return f"[blue]{text}[/blue]" + + @staticmethod + def grey(text: str) -> str: + return f"[grey]{text}[/grey]" + + @staticmethod + def dim(text: str) -> str: + return f"[dim]{text}[/dim]" \ No newline at end of file diff --git a/renamer/formatters/track_formatter.py b/renamer/formatters/track_formatter.py new file mode 100644 index 0000000..111a559 --- /dev/null +++ b/renamer/formatters/track_formatter.py @@ -0,0 +1,44 @@ +class TrackFormatter: + """Class to format track information into display strings""" + + @staticmethod + def format_video_track(track: dict) -> str: + """Format a video track dict into a display string""" + codec = track.get('codec', 'unknown') + width = track.get('width', '?') + height = track.get('height', '?') + bitrate = track.get('bitrate') + fps = track.get('fps') + profile = track.get('profile') + + 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})" + + return video_str + + @staticmethod + def format_audio_track(track: dict) -> str: + """Format an audio track dict into a display string""" + codec = track.get('codec', 'unknown') + channels = track.get('channels', '?') + lang = track.get('language', 'und') + bitrate = track.get('bitrate') + + audio_str = f"{codec} {channels}ch {lang}" + if bitrate: + audio_str += f" {bitrate}bps" + + return audio_str + + @staticmethod + def format_subtitle_track(track: dict) -> str: + """Format a subtitle track dict into a display string""" + lang = track.get('language', 'und') + format = track.get('format', 'unknown') + + return f"{lang} ({format})" \ No newline at end of file diff --git a/renamer/test/test_mediainfo_extractor.py b/renamer/test/test_mediainfo_extractor.py index 35a9fb8..d69c43a 100644 --- a/renamer/test/test_mediainfo_extractor.py +++ b/renamer/test/test_mediainfo_extractor.py @@ -29,10 +29,4 @@ class TestMediaInfoExtractor: """Test extracting audio languages""" langs = extractor.extract_audio_langs(test_file) # Text files don't have audio tracks - assert langs == '' - - def test_extract_video_dimensions(self, extractor, test_file): - """Test extracting video dimensions""" - dims = extractor.extract_video_dimensions(test_file) - # Text files don't have video dimensions - assert dims is None \ No newline at end of file + assert langs == '' \ No newline at end of file diff --git a/uv.lock b/uv.lock index 8c0e53f..0f396f3 100644 --- a/uv.lock +++ b/uv.lock @@ -20,6 +20,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "langcodes" +version = "3.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/75/f9edc5d72945019312f359e69ded9f82392a81d49c5051ed3209b100c0d2/langcodes-3.5.1.tar.gz", hash = "sha256:40bff315e01b01d11c2ae3928dd4f5cbd74dd38f9bd912c12b9a3606c143f731", size = 191084, upload-time = "2025-12-02T16:22:01.627Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dd/c1/d10b371bcba7abce05e2b33910e39c33cfa496a53f13640b7b8e10bb4d2b/langcodes-3.5.1-py3-none-any.whl", hash = "sha256:b6a9c25c603804e2d169165091d0cdb23934610524a21d226e4f463e8e958a72", size = 183050, upload-time = "2025-12-02T16:21:59.954Z" }, +] + [[package]] name = "linkify-it-py" version = "2.0.3" @@ -158,6 +167,7 @@ name = "renamer" version = "0.1.1" source = { editable = "." } dependencies = [ + { name = "langcodes" }, { name = "mutagen" }, { name = "pymediainfo" }, { name = "pytest" }, @@ -167,6 +177,7 @@ dependencies = [ [package.metadata] requires-dist = [ + { name = "langcodes", specifier = ">=3.5.1" }, { name = "mutagen", specifier = ">=1.47.0" }, { name = "pymediainfo", specifier = ">=6.0.0" }, { name = "pytest", specifier = ">=7.0.0" },