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.
This commit is contained in:
sHa
2025-12-26 11:33:24 +00:00
parent d2ec235458
commit 1d6eb9593e
15 changed files with 544 additions and 285 deletions

7
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,7 @@
{
"python.testing.pytestArgs": [
"renamer"
],
"python.testing.unittestEnabled": false,
"python.testing.pytestEnabled": true
}

View File

@@ -10,6 +10,7 @@ dependencies = [
"python-magic>=0.4.27", "python-magic>=0.4.27",
"pymediainfo>=6.0.0", "pymediainfo>=6.0.0",
"pytest>=7.0.0", "pytest>=7.0.0",
"langcodes>=3.5.1",
] ]
[project.scripts] [project.scripts]

View File

@@ -11,7 +11,7 @@ from .screens import OpenScreen
from .extractor import MediaExtractor from .extractor import MediaExtractor
from .formatters.media_formatter import MediaFormatter from .formatters.media_formatter import MediaFormatter
from .formatters.proposed_name_formatter import ProposedNameFormatter from .formatters.proposed_name_formatter import ProposedNameFormatter
from .formatters.color_formatter import ColorFormatter from .formatters.text_formatter import TextFormatter
class RenamerApp(App): class RenamerApp(App):
@@ -116,19 +116,17 @@ class RenamerApp(App):
try: try:
# Initialize extractors and formatters # Initialize extractors and formatters
extractor = MediaExtractor(file_path) extractor = MediaExtractor(file_path)
formatter = MediaFormatter()
name_formatter = ProposedNameFormatter(extractor)
# Update UI # Update UI
self.call_later( self.call_later(
self._update_details, self._update_details,
formatter.format_file_info_panel(extractor), MediaFormatter(extractor).file_info_panel(),
name_formatter.format_display_string(), ProposedNameFormatter(extractor).rename_line_formatted(),
) )
except Exception as e: except Exception as e:
self.call_later( self.call_later(
self._update_details, self._update_details,
ColorFormatter.red(f"Error extracting details: {str(e)}"), TextFormatter.red(f"Error extracting details: {str(e)}"),
"", "",
) )

View File

@@ -18,57 +18,57 @@ class MediaExtractor:
# Define sources for each data type # Define sources for each data type
self._sources = { self._sources = {
'title': [ 'title': [
('metadata', lambda: self.metadata_extractor.extract_title()), ('Metadata', lambda: self.metadata_extractor.extract_title()),
('filename', lambda: self.filename_extractor.extract_title()) ('Filename', lambda: self.filename_extractor.extract_title())
], ],
'year': [ 'year': [
('filename', lambda: self.filename_extractor.extract_year()) ('Filename', lambda: self.filename_extractor.extract_year())
], ],
'source': [ 'source': [
('filename', lambda: self.filename_extractor.extract_source()) ('Filename', lambda: self.filename_extractor.extract_source())
], ],
'frame_class': [ 'frame_class': [
('mediainfo', lambda: self.mediainfo_extractor.extract_frame_class()), ('MediaInfo', lambda: self.mediainfo_extractor.extract_frame_class()),
('filename', lambda: self.filename_extractor.extract_frame_class()) ('Filename', lambda: self.filename_extractor.extract_frame_class())
], ],
'resolution': [ 'resolution': [
('mediainfo', lambda: self.mediainfo_extractor.extract_resolution()) ('MediaInfo', lambda: self.mediainfo_extractor.extract_resolution())
], ],
'aspect_ratio': [ 'aspect_ratio': [
('mediainfo', lambda: self.mediainfo_extractor.extract_aspect_ratio()) ('MediaInfo', lambda: self.mediainfo_extractor.extract_aspect_ratio())
], ],
'hdr': [ 'hdr': [
('mediainfo', lambda: self.mediainfo_extractor.extract_hdr()) ('MediaInfo', lambda: self.mediainfo_extractor.extract_hdr())
], ],
'audio_langs': [ 'audio_langs': [
('mediainfo', lambda: self.mediainfo_extractor.extract_audio_langs()) ('MediaInfo', lambda: self.mediainfo_extractor.extract_audio_langs())
], ],
'metadata': [ 'metadata': [
('metadata', lambda: self.metadata_extractor.extract_all_metadata()) ('Metadata', lambda: self.metadata_extractor.extract_all_metadata())
], ],
'meta_type': [ 'meta_type': [
('metadata', lambda: self.metadata_extractor.extract_meta_type()) ('Metadata', lambda: self.metadata_extractor.extract_meta_type())
], ],
'meta_description': [ 'meta_description': [
('metadata', lambda: self.metadata_extractor.extract_meta_description()) ('Metadata', lambda: self.metadata_extractor.extract_meta_description())
], ],
'file_size': [ 'file_size': [
('fileinfo', lambda: self.fileinfo_extractor.extract_size()) ('FileInfo', lambda: self.fileinfo_extractor.extract_size())
], ],
'modification_time': [ 'modification_time': [
('fileinfo', lambda: self.fileinfo_extractor.extract_modification_time()) ('FileInfo', lambda: self.fileinfo_extractor.extract_modification_time())
], ],
'file_name': [ 'file_name': [
('fileinfo', lambda: self.fileinfo_extractor.extract_file_name()) ('FileInfo', lambda: self.fileinfo_extractor.extract_file_name())
], ],
'file_path': [ 'file_path': [
('fileinfo', lambda: self.fileinfo_extractor.extract_file_path()) ('FileInfo', lambda: self.fileinfo_extractor.extract_file_path())
], ],
'extension': [ 'extension': [
('fileinfo', lambda: self.fileinfo_extractor.extract_extension()) ('FileInfo', lambda: self.fileinfo_extractor.extract_extension())
], ],
'tracks': [ '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, 'hdr': lambda x: x is not None,
'audio_langs': lambda x: x is not None, 'audio_langs': lambda x: x is not None,
'metadata': 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): def get(self, key: str, source: str | None = None):
@@ -95,10 +95,10 @@ class MediaExtractor:
if source: if source:
for src, func in self._sources[key]: for src, func in self._sources[key]:
if src == source: if src.lower() == source.lower():
val = func() val = func()
return val if condition(val) else None 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: else:
# Use fallback: return first valid value # Use fallback: return first valid value
for src, func in self._sources[key]: for src, func in self._sources[key]:

View File

@@ -2,7 +2,7 @@ from pathlib import Path
from pymediainfo import MediaInfo from pymediainfo import MediaInfo
from collections import Counter from collections import Counter
from ..constants import FRAME_CLASSES from ..constants import FRAME_CLASSES
from ..formatters.color_formatter import ColorFormatter import langcodes
class MediaInfoExtractor: class MediaInfoExtractor:
@@ -37,14 +37,14 @@ class MediaInfoExtractor:
return self._get_frame_class_from_height(height) return self._get_frame_class_from_height(height)
return 'Unclassified' return 'Unclassified'
def extract_resolution(self) -> str | None: def extract_resolution(self) -> tuple[int, int] | None:
"""Extract actual video resolution (WIDTHxHEIGHT) from media info""" """Extract actual video resolution as (width, height) tuple from media info"""
if not self.video_tracks: if not self.video_tracks:
return None return None
width = getattr(self.video_tracks[0], 'width', None) width = getattr(self.video_tracks[0], 'width', None)
height = getattr(self.video_tracks[0], 'height', None) height = getattr(self.video_tracks[0], 'height', None)
if width and height: if width and height:
return f"{width}x{height}" return width, height
return None return None
def extract_aspect_ratio(self) -> str | None: def extract_aspect_ratio(self) -> str | None:
@@ -69,72 +69,65 @@ class MediaInfoExtractor:
"""Extract audio languages from media info""" """Extract audio languages from media info"""
if not self.audio_tracks: if not self.audio_tracks:
return '' return ''
lang_map = { langs = []
'en': 'eng', 'fr': 'fre', 'de': 'ger', 'uk': 'ukr', 'ru': 'rus', for a in self.audio_tracks:
'es': 'spa', 'it': 'ita', 'pt': 'por', 'ja': 'jpn', 'ko': 'kor', lang_code = getattr(a, 'language', 'und').lower()
'zh': 'chi', 'und': 'und' try:
} # Try to get the 3-letter code
langs = [getattr(a, 'language', 'und').lower()[:3] for a in self.audio_tracks] lang_obj = langcodes.Language.get(lang_code)
langs = [lang_map.get(lang, lang) for lang in langs] 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) lang_counts = Counter(langs)
audio_langs = [f"{count}{lang}" if count > 1 else lang for lang, count in lang_counts.items()] audio_langs = [f"{count}{lang}" if count > 1 else lang for lang, count in lang_counts.items()]
return ','.join(audio_langs) return ','.join(audio_langs)
def extract_video_dimensions(self) -> tuple[int, int] | None: def extract_video_tracks(self) -> list[dict]:
"""Extract video width and height""" """Extract video track data"""
if not self.video_tracks: tracks = []
return None for v in self.video_tracks[:2]: # Up to 2 videos
width = getattr(self.video_tracks[0], 'width', None) track_data = {
height = getattr(self.video_tracks[0], 'height', None) 'codec': getattr(v, 'format', None) or getattr(v, 'codec', None) or 'unknown',
if width and height: 'width': getattr(v, 'width', None),
return width, height 'height': getattr(v, 'height', None),
return 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: def extract_audio_tracks(self) -> list[dict]:
"""Extract compact media track information""" """Extract audio track data"""
tracks_info = [] tracks = []
try: for a in self.audio_tracks[:3]: # Up to 3 audios
# Video tracks track_data = {
for i, v in enumerate(self.video_tracks[:2]): # Up to 2 videos 'codec': getattr(a, 'format', None) or getattr(a, 'codec', None) or 'unknown',
codec = getattr(v, 'format', None) or getattr(v, 'codec', None) or 'unknown' 'channels': getattr(a, 'channel_s', None),
width = getattr(v, 'width', None) or '?' 'language': getattr(a, 'language', None) or 'und',
height = getattr(v, 'height', None) or '?' 'bitrate': getattr(a, 'bit_rate', None),
bitrate = getattr(v, 'bit_rate', None) }
fps = getattr(v, 'frame_rate', None) tracks.append(track_data)
profile = getattr(v, 'format_profile', None) return tracks
video_str = f"{codec} {width}x{height}" def extract_subtitle_tracks(self) -> list[dict]:
if bitrate: """Extract subtitle track data"""
video_str += f" {bitrate}bps" tracks = []
if fps: for s in self.sub_tracks[:3]: # Up to 3 subs
video_str += f" {fps}fps" track_data = {
if profile: 'language': getattr(s, 'language', None) or 'und',
video_str += f" ({profile})" 'format': getattr(s, 'format', None) or getattr(s, 'codec', None) or 'unknown',
}
tracks.append(track_data)
return tracks
tracks_info.append(ColorFormatter.green(f"Video {i+1}: {video_str}")) def extract_tracks(self) -> dict:
"""Extract media track information as data"""
# Audio tracks return {
for i, a in enumerate(self.audio_tracks[:3]): # Up to 3 audios 'video_tracks': self.extract_video_tracks(),
codec = getattr(a, 'format', None) or getattr(a, 'codec', None) or 'unknown' 'audio_tracks': self.extract_audio_tracks(),
channels = getattr(a, 'channel_s', None) or '?' 'subtitle_tracks': self.extract_subtitle_tracks(),
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 ""

View File

@@ -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]"

View File

@@ -1,24 +1,16 @@
from pathlib import Path from pathlib import Path
from ..constants import MEDIA_TYPES from ..constants import MEDIA_TYPES
from .color_formatter import ColorFormatter from .text_formatter import TextFormatter
class ExtensionFormatter: class ExtensionFormatter:
"""Class for formatting extension information""" """Class for formatting extension information"""
@staticmethod @staticmethod
def check_extension_match(ext_name: str, meta_type: str) -> bool: def format_extension_info(ext_name: str) -> str:
"""Check if file extension matches detected type""" """Format extension information"""
if ext_name in MEDIA_TYPES and MEDIA_TYPES[ext_name]['meta_type'] == meta_type: if ext_name in MEDIA_TYPES:
return True ext_desc = MEDIA_TYPES[ext_name]['description']
return False return f"{ext_name} - {TextFormatter.grey(ext_desc)}"
@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)}"
else: else:
return (f"{ColorFormatter.bold_yellow('Extension:')} {ext_name} - {ColorFormatter.grey(ext_desc)}\n" return f"{ext_name} - {TextFormatter.grey('Unknown extension')}"
f"{ColorFormatter.bold_red('Meta extension:')} {meta_type} - {ColorFormatter.grey(meta_desc)}\n"
f"{ColorFormatter.bold_red('Warning: Extensions do not match!')}")

View File

@@ -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"\_")

View File

@@ -3,113 +3,220 @@ from .size_formatter import SizeFormatter
from .date_formatter import DateFormatter from .date_formatter import DateFormatter
from .extension_extractor import ExtensionExtractor from .extension_extractor import ExtensionExtractor
from .extension_formatter import ExtensionFormatter 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 MediaFormatter:
"""Class to format media data for display""" """Class to format media data for display"""
def format_file_info_panel(self, extractor) -> str: def __init__(self, extractor):
"""Format file information for the file info panel""" self.extractor = extractor
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,
},
]
# Get extension info def _format_data_item(self, item: dict) -> str:
ext_name = ExtensionExtractor.get_extension_name( """Apply all formatting to a data item and return the formatted string"""
Path(extractor.get("file_path")) # Define text formatters that should be applied before markup
) text_formatters_set = {
ext_desc = ExtensionExtractor.get_extension_description(ext_name) TextFormatter.uppercase,
meta_type = extractor.get("meta_type") TextFormatter.lowercase,
meta_desc = extractor.get("meta_description") TextFormatter.camelcase,
match = ExtensionFormatter.check_extension_match(ext_name, meta_type) }
ext_info = ExtensionFormatter.format_extension_info(
ext_name, ext_desc, meta_type, meta_desc, match
)
output = [ColorFormatter.bold_blue("FILE INFO"), ""] # Handle value formatting first (e.g., size formatting)
output.extend( value = item.get("value")
item["format_func"](f"{item['label']}: {item['value']}") for item in data if value is not None:
) value_formatters = item.get("value_formatters", [])
output.append(ext_info) 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 # Add tracks info
tracks_text = extractor.get('tracks')
if not tracks_text:
tracks_text = ColorFormatter.grey("No track info available")
output.append("") output.append("")
output.append(tracks_text) output.extend(self.tracks_info())
# Add rename lines # Add filename extracted data
rename_lines = self.format_rename_lines(extractor)
output.append("") 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) 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""" """Format filename extraction data for the filename panel"""
data = [ data = [
{ {
"label": "Title", "label": "Title",
"value": extractor.get("title") or "Not found", "value": self.extractor.get("title") or "Not found",
"format_func": ColorFormatter.yellow, "display_formatters": [TextFormatter.yellow],
}, },
{ {
"label": "Year", "label": "Year",
"value": extractor.get("year") or "Not found", "value": self.extractor.get("year") or "Not found",
"format_func": ColorFormatter.yellow, "display_formatters": [TextFormatter.yellow],
}, },
{ {
"label": "Source", "label": "Source",
"value": extractor.get("source") or "Not found", "value": self.extractor.get("source") or "Not found",
"format_func": ColorFormatter.yellow, "display_formatters": [TextFormatter.yellow],
}, },
{ {
"label": "Frame Class", "label": "Frame Class",
"value": extractor.get("frame_class") or "Not found", "value": self.extractor.get("frame_class") or "Not found",
"format_func": ColorFormatter.yellow, "display_formatters": [TextFormatter.yellow],
}, },
] ]
output = [ColorFormatter.bold_yellow("FILENAME EXTRACTION"), ""] output = [TextFormatter.bold_yellow("FILENAME EXTRACTION"), ""]
output.extend( for item in data:
item["format_func"](f"{item['label']}: {item['value']}") for item in data output.append(self._format_data_item(item))
)
return "\n".join(output) 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""" """Format metadata extraction data for the metadata panel"""
metadata = extractor.get("metadata") or {} metadata = self.extractor.get("metadata") or {}
data = [] data = []
if metadata.get("duration"): if metadata.get("duration"):
data.append( data.append(
{ {
"label": "Duration", "label": "Duration",
"value": f"{metadata['duration']:.1f} seconds", "value": f"{metadata['duration']:.1f} seconds",
"format_func": ColorFormatter.cyan, "display_formatters": [TextFormatter.cyan],
} }
) )
if metadata.get("title"): if metadata.get("title"):
@@ -117,7 +224,7 @@ class MediaFormatter:
{ {
"label": "Title", "label": "Title",
"value": metadata["title"], "value": metadata["title"],
"format_func": ColorFormatter.cyan, "display_formatters": [TextFormatter.cyan],
} }
) )
if metadata.get("artist"): if metadata.get("artist"):
@@ -125,67 +232,119 @@ class MediaFormatter:
{ {
"label": "Artist", "label": "Artist",
"value": metadata["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: if data:
output.extend( for item in data:
item["format_func"](f"{item['label']}: {item['value']}") output.append(self._format_data_item(item))
for item in data
)
else: else:
output.append(ColorFormatter.dim("No metadata found")) output.append(TextFormatter.dim("No metadata found"))
return "\n".join(output) 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""" """Format media info extraction data for the mediainfo panel"""
data = [ 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", "label": "Resolution",
"value": extractor.get("resolution") or "Not found", "label_formatters": [TextFormatter.bold],
"format_func": ColorFormatter.green, "value": self.extractor.get("resolution", "MediaInfo")
or "Not extracted",
"value_formatters": [ResolutionFormatter.format_resolution_dimensions],
"display_formatters": [TextFormatter.grey],
}, },
{ {
"label": "Aspect Ratio", "label": "Aspect Ratio",
"value": extractor.get("aspect_ratio") or "Not found", "label_formatters": [TextFormatter.bold],
"format_func": ColorFormatter.green, "value": self.extractor.get("aspect_ratio", "MediaInfo")
or "Not extracted",
"display_formatters": [TextFormatter.grey],
}, },
{ {
"label": "HDR", "label": "HDR",
"value": extractor.get("hdr") or "Not found", "label_formatters": [TextFormatter.bold],
"format_func": ColorFormatter.green, "value": self.extractor.get("hdr", "MediaInfo") or "Not extracted",
"display_formatters": [TextFormatter.grey],
}, },
{ {
"label": "Audio Languages", "label": "Audio Languages",
"value": extractor.get("audio_langs") or "Not found", "label_formatters": [TextFormatter.bold],
"format_func": ColorFormatter.green, "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"), ""] return [self._format_data_item(item) for item in data]
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()]
def _format_extra_metadata(self, metadata: dict) -> str: def _format_extra_metadata(self, metadata: dict) -> str:
"""Format extra metadata like duration, title, artist""" """Format extra metadata like duration, title, artist"""
@@ -198,5 +357,5 @@ class MediaFormatter:
data["Artist"] = metadata["artist"] data["Artist"] = metadata["artist"]
return "\n".join( 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()
) )

View File

@@ -1,4 +1,4 @@
from .color_formatter import ColorFormatter from .text_formatter import TextFormatter
from .date_formatter import DateFormatter from .date_formatter import DateFormatter
@@ -6,11 +6,11 @@ class ProposedNameFormatter:
"""Class for formatting proposed filenames""" """Class for formatting proposed filenames"""
def __init__(self, extractor): def __init__(self, extractor):
self.extractor = extractor """Initialize with media extractor data"""
self.__title = extractor.get("title") or "Unknown Title" self.__title = extractor.get("title") or "Unknown Title"
self.__year = DateFormatter.format_year(extractor.get("year")) 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.__frame_class = extractor.get("frame_class") or None
self.__hdr = f",{extractor.get('hdr')}" if extractor.get("hdr") else "" self.__hdr = f",{extractor.get('hdr')}" if extractor.get("hdr") else ""
self.__audio_langs = extractor.get("audio_langs") or None self.__audio_langs = extractor.get("audio_langs") or None
@@ -18,8 +18,11 @@ class ProposedNameFormatter:
def __str__(self) -> str: def __str__(self) -> str:
"""Convert the proposed name to string""" """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""" """Format the proposed name for display with color"""
return ColorFormatter.bold_yellow(str(self)) return f">> {TextFormatter.bold_yellow(str(self))} <<"

View File

@@ -54,6 +54,7 @@ class ResolutionFormatter:
return f'{height}p' return f'{height}p'
@staticmethod @staticmethod
def format_resolution_dimensions(width: int, height: int) -> str: def format_resolution_dimensions(resolution: tuple[int, int]) -> str:
"""Format resolution as WIDTHxHEIGHT""" """Format resolution as WIDTHxHEIGHT"""
width, height = resolution
return f"{width}x{height}" return f"{width}x{height}"

View File

@@ -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]"

View File

@@ -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})"

View File

@@ -30,9 +30,3 @@ class TestMediaInfoExtractor:
langs = extractor.extract_audio_langs(test_file) langs = extractor.extract_audio_langs(test_file)
# Text files don't have audio tracks # Text files don't have audio tracks
assert langs == '' 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

11
uv.lock generated
View File

@@ -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" }, { 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]] [[package]]
name = "linkify-it-py" name = "linkify-it-py"
version = "2.0.3" version = "2.0.3"
@@ -158,6 +167,7 @@ name = "renamer"
version = "0.1.1" version = "0.1.1"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "langcodes" },
{ name = "mutagen" }, { name = "mutagen" },
{ name = "pymediainfo" }, { name = "pymediainfo" },
{ name = "pytest" }, { name = "pytest" },
@@ -167,6 +177,7 @@ dependencies = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "langcodes", specifier = ">=3.5.1" },
{ name = "mutagen", specifier = ">=1.47.0" }, { name = "mutagen", specifier = ">=1.47.0" },
{ name = "pymediainfo", specifier = ">=6.0.0" }, { name = "pymediainfo", specifier = ">=6.0.0" },
{ name = "pytest", specifier = ">=7.0.0" }, { name = "pytest", specifier = ">=7.0.0" },