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",
"pymediainfo>=6.0.0",
"pytest>=7.0.0",
"langcodes>=3.5.1",
]
[project.scripts]

View File

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

View File

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

View File

@@ -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,14 +37,14 @@ 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:
@@ -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)
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
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})"
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
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 ""
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(),
}

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 ..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!')}")
return f"{ext_name} - {TextFormatter.grey('Unknown extension')}"

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

View File

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

View File

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

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

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" },
]
[[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" },