Refactor extractors and formatters for improved structure and functionality

- Converted static methods to instance methods in FileInfoExtractor and FilenameExtractor for better encapsulation.
- Enhanced MediaInfoExtractor to initialize with file path and extract media information upon instantiation.
- Updated MetadataExtractor to handle metadata extraction with improved error handling and added methods for meta type detection.
- Introduced ColorFormatter for consistent text formatting across the application.
- Refactored MediaFormatter to utilize the new extractor structure and improve output formatting.
- Removed redundant utility functions and replaced them with direct calls in extractors.
- Added ProposedNameFormatter for better handling of proposed filename formatting.
- Updated extension handling to use MEDIA_TYPES for descriptions instead of VIDEO_EXT_DESCRIPTIONS.
This commit is contained in:
sHa
2025-12-25 23:35:59 +00:00
parent 37efdf60d3
commit d2ec235458
14 changed files with 664 additions and 500 deletions

View File

@@ -4,12 +4,14 @@ from textual.containers import Horizontal, Container, ScrollableContainer, Verti
from pathlib import Path
import threading
import time
import concurrent.futures
from .constants import VIDEO_EXTENSIONS
from .utils import get_media_tracks
from .constants import MEDIA_TYPES
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
class RenamerApp(App):
@@ -42,7 +44,9 @@ class RenamerApp(App):
with Vertical():
yield LoadingIndicator(id="loading")
with ScrollableContainer(id="details_container"):
yield Static("Select a file to view details", id="details", markup=True)
yield Static(
"Select a file to view details", id="details", markup=True
)
yield Static("", id="proposed", markup=True)
yield Footer()
@@ -58,7 +62,6 @@ class RenamerApp(App):
return
tree = self.query_one("#file_tree", Tree)
tree.clear()
tree.root.add(".", data=self.scan_dir)
self.build_tree(self.scan_dir, tree.root)
tree.root.expand()
self.set_focus(tree)
@@ -68,9 +71,13 @@ class RenamerApp(App):
for item in sorted(path.iterdir()):
try:
if item.is_dir():
if item.name.startswith(".") or item.name == "lost+found":
continue
subnode = node.add(item.name, data=item)
self.build_tree(item, subnode)
elif item.is_file() and item.suffix.lower() in VIDEO_EXTENSIONS:
elif item.is_file() and item.suffix.lower() in {
f".{ext}" for ext in MEDIA_TYPES
}:
node.add(item.name, data=item)
except PermissionError:
pass
@@ -100,52 +107,46 @@ class RenamerApp(App):
proposed.update("")
elif node.data.is_file():
self._start_loading_animation()
threading.Thread(target=self._extract_and_show_details, args=(node.data,)).start()
threading.Thread(
target=self._extract_and_show_details, args=(node.data,)
).start()
def _extract_and_show_details(self, file_path: Path):
time.sleep(1) # Minimum delay to show loading
# Initialize extractors and formatters
extractor = MediaExtractor()
formatter = MediaFormatter()
try:
# Initialize extractors and formatters
extractor = MediaExtractor(file_path)
formatter = MediaFormatter()
name_formatter = ProposedNameFormatter(extractor)
# Extract all data
rename_data = extractor.extract_all(file_path)
# Update UI
self.call_later(
self._update_details,
formatter.format_file_info_panel(extractor),
name_formatter.format_display_string(),
)
except Exception as e:
self.call_later(
self._update_details,
ColorFormatter.red(f"Error extracting details: {str(e)}"),
"",
)
# Get media tracks info
tracks_text = get_media_tracks(file_path)
if not tracks_text:
tracks_text = "[grey]No track info available[/grey]"
# Format file info
full_info = formatter.format_file_info(file_path, rename_data)
full_info += f"\n\n{tracks_text}"
# Format proposed name
ext_name = file_path.suffix.lower().lstrip('.')
proposed_name = formatter.format_proposed_name(rename_data, ext_name)
# Format rename lines
rename_lines = formatter.format_rename_lines(rename_data, proposed_name)
full_info += f"\n\n" + "\n".join(rename_lines[:-1])
# Update UI
self.call_later(self._update_details, full_info, proposed_name)
def _update_details(self, full_info: str, proposed_name: str):
def _update_details(self, full_info: str, display_string: str):
self._stop_loading_animation()
details = self.query_one("#details", Static)
details.update(full_info)
proposed = self.query_one("#proposed", Static)
proposed.update(f"[bold yellow]Proposed filename: {proposed_name}[/bold yellow]")
proposed.update(display_string)
def action_quit(self):
async def action_quit(self):
self.exit()
def action_open(self):
async def action_open(self):
self.push_screen(OpenScreen())
def action_scan(self):
async def action_scan(self):
if self.scan_dir:
self.scan_files()
@@ -153,7 +154,12 @@ class RenamerApp(App):
if event.key == "right":
tree = self.query_one("#file_tree", Tree)
node = tree.cursor_node
if node and node.data and isinstance(node.data, Path) and node.data.is_dir():
if (
node
and node.data
and isinstance(node.data, Path)
and node.data.is_dir()
):
if not node.is_expanded:
node.expand()
tree.cursor_line = node.line + 1

View File

@@ -1,72 +1,83 @@
VIDEO_EXTENSIONS = {'.mkv', '.avi', '.mov', '.mp4', '.wmv', '.flv', '.webm', '.m4v', '.3gp', '.ogv'}
VIDEO_EXT_DESCRIPTIONS = {
'mkv': 'Matroska multimedia container',
'avi': 'Audio Video Interleave',
'mov': 'QuickTime movie',
'mp4': 'MPEG-4 video container',
'wmv': 'Windows Media Video',
'flv': 'Flash Video',
'webm': 'WebM multimedia',
'm4v': 'MPEG-4 video',
'3gp': '3GPP multimedia',
'ogv': 'Ogg Video',
}
META_DESCRIPTIONS = {
'MP4': 'MPEG-4 video container',
'Matroska': 'Matroska multimedia container',
'AVI': 'Audio Video Interleave',
'QuickTime': 'QuickTime movie',
'ASF': 'Windows Media',
'FLV': 'Flash Video',
'WebM': 'WebM multimedia',
'Ogg': 'Ogg multimedia',
MEDIA_TYPES = {
"mkv": {
"description": "Matroska multimedia container",
"meta_type": "Matroska",
"mime": "video/x-matroska",
},
"avi": {
"description": "Audio Video Interleave",
"meta_type": "AVI",
"mime": "video/x-msvideo",
},
"mov": {
"description": "QuickTime movie",
"meta_type": "QuickTime",
"mime": "video/quicktime",
},
"mp4": {
"description": "MPEG-4 video container",
"meta_type": "MP4",
"mime": "video/mp4",
},
"wmv": {
"description": "Windows Media Video",
"meta_type": "ASF",
"mime": "video/x-ms-wmv",
},
"flv": {"description": "Flash Video", "meta_type": "FLV", "mime": "video/x-flv"},
"webm": {
"description": "WebM multimedia",
"meta_type": "WebM",
"mime": "video/webm",
},
"m4v": {"description": "MPEG-4 video", "meta_type": "MP4", "mime": "video/mp4"},
"3gp": {"description": "3GPP multimedia", "meta_type": "MP4", "mime": "video/3gpp"},
"ogv": {"description": "Ogg Video", "meta_type": "Ogg", "mime": "video/ogg"},
}
SOURCE_DICT = {
'WEB-DL': ['WEB-DL', 'WEBRip', 'WEB-Rip', 'WEB'],
'BDRip': ['BDRip', 'BD-Rip', 'BDRIP'],
'BDRemux': ['BDRemux', 'BD-Remux', 'BDREMUX'],
'DVDRip': ['DVDRip', 'DVD-Rip', 'DVDRIP'],
'HDTV': ['HDTV'],
'BluRay': ['BluRay', 'BLURAY', 'Blu-ray'],
"WEB-DL": ["WEB-DL", "WEBRip", "WEB-Rip", "WEB"],
"BDRip": ["BDRip", "BD-Rip", "BDRIP"],
"BDRemux": ["BDRemux", "BD-Remux", "BDREMUX"],
"DVDRip": ["DVDRip", "DVD-Rip", "DVDRIP"],
"HDTV": ["HDTV"],
"BluRay": ["BluRay", "BLURAY", "Blu-ray"],
}
FRAME_CLASSES = {
'480p': {
'nominal_height': 480,
'typical_widths': [640, 704, 720],
'description': 'Standard Definition (SD) - DVD quality'
"480p": {
"nominal_height": 480,
"typical_widths": [640, 704, 720],
"description": "Standard Definition (SD) - DVD quality",
},
'576p': {
'nominal_height': 576,
'typical_widths': [720, 768],
'description': 'PAL Standard Definition (SD) - European DVD quality'
"576p": {
"nominal_height": 576,
"typical_widths": [720, 768],
"description": "PAL Standard Definition (SD) - European DVD quality",
},
'720p': {
'nominal_height': 720,
'typical_widths': [1280],
'description': 'High Definition (HD) - 720p HD'
"720p": {
"nominal_height": 720,
"typical_widths": [1280],
"description": "High Definition (HD) - 720p HD",
},
'1080p': {
'nominal_height': 1080,
'typical_widths': [1920],
'description': 'Full High Definition (FHD) - 1080p HD'
"1080p": {
"nominal_height": 1080,
"typical_widths": [1920],
"description": "Full High Definition (FHD) - 1080p HD",
},
'1440p': {
'nominal_height': 1440,
'typical_widths': [2560],
'description': 'Quad High Definition (QHD) - 1440p 2K'
"1440p": {
"nominal_height": 1440,
"typical_widths": [2560],
"description": "Quad High Definition (QHD) - 1440p 2K",
},
'2160p': {
'nominal_height': 2160,
'typical_widths': [3840],
'description': 'Ultra High Definition (UHD) - 2160p 4K'
"2160p": {
"nominal_height": 2160,
"typical_widths": [3840],
"description": "Ultra High Definition (UHD) - 2160p 4K",
},
"4320p": {
"nominal_height": 4320,
"typical_widths": [7680],
"description": "Ultra High Definition (UHD) - 4320p 8K",
},
'4320p': {
'nominal_height': 4320,
'typical_widths': [7680],
'description': 'Ultra High Definition (UHD) - 4320p 8K'
}
}

View File

@@ -2,70 +2,107 @@ from pathlib import Path
from .extractors.filename_extractor import FilenameExtractor
from .extractors.metadata_extractor import MetadataExtractor
from .extractors.mediainfo_extractor import MediaInfoExtractor
from .extractors.fileinfo_extractor import FileInfoExtractor
class MediaExtractor:
"""Class to extract various metadata from media files using specialized extractors"""
def __init__(self):
self.mediainfo_extractor = MediaInfoExtractor()
def __init__(self, file_path: Path):
self.file_path = file_path
self.filename_extractor = FilenameExtractor(file_path)
self.metadata_extractor = MetadataExtractor(file_path)
self.mediainfo_extractor = MediaInfoExtractor(file_path)
self.fileinfo_extractor = FileInfoExtractor(file_path)
def extract_title(self, file_path: Path) -> str | None:
"""Extract movie title from metadata or filename"""
# Try metadata first
title = MetadataExtractor.extract_title(file_path)
if title:
return title
# Fallback to filename
return FilenameExtractor.extract_title(file_path)
def extract_year(self, file_path: Path) -> str | None:
"""Extract year from filename"""
return FilenameExtractor.extract_year(file_path)
def extract_source(self, file_path: Path) -> str | None:
"""Extract video source from filename"""
return FilenameExtractor.extract_source(file_path)
def extract_frame_class(self, file_path: Path) -> str | None:
"""Extract frame class from media info or filename"""
# Try media info first
frame_class = self.mediainfo_extractor.extract_frame_class(file_path)
if frame_class:
return frame_class
# Fallback to filename
return FilenameExtractor.extract_frame_class(file_path)
def extract_resolution(self, file_path: Path) -> str | None:
"""Extract actual video resolution (WIDTHxHEIGHT) from media info"""
return self.mediainfo_extractor.extract_resolution(file_path)
def extract_aspect_ratio(self, file_path: Path) -> str | None:
"""Extract video aspect ratio from media info"""
return self.mediainfo_extractor.extract_aspect_ratio(file_path)
def extract_hdr(self, file_path: Path) -> str | None:
"""Extract HDR info from media info"""
return self.mediainfo_extractor.extract_hdr(file_path)
def extract_audio_langs(self, file_path: Path) -> str:
"""Extract audio languages from media info"""
return self.mediainfo_extractor.extract_audio_langs(file_path)
def extract_metadata(self, file_path: Path) -> dict:
"""Extract general metadata"""
return MetadataExtractor.extract_all_metadata(file_path)
def extract_all(self, file_path: Path) -> dict:
"""Extract all rename-related data"""
return {
'title': self.extract_title(file_path),
'year': self.extract_year(file_path),
'source': self.extract_source(file_path),
'frame_class': self.extract_frame_class(file_path),
'resolution': self.extract_resolution(file_path),
'aspect_ratio': self.extract_aspect_ratio(file_path),
'hdr': self.extract_hdr(file_path),
'audio_langs': self.extract_audio_langs(file_path),
'metadata': self.extract_metadata(file_path)
# Define sources for each data type
self._sources = {
'title': [
('metadata', lambda: self.metadata_extractor.extract_title()),
('filename', lambda: self.filename_extractor.extract_title())
],
'year': [
('filename', lambda: self.filename_extractor.extract_year())
],
'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())
],
'resolution': [
('mediainfo', lambda: self.mediainfo_extractor.extract_resolution())
],
'aspect_ratio': [
('mediainfo', lambda: self.mediainfo_extractor.extract_aspect_ratio())
],
'hdr': [
('mediainfo', lambda: self.mediainfo_extractor.extract_hdr())
],
'audio_langs': [
('mediainfo', lambda: self.mediainfo_extractor.extract_audio_langs())
],
'metadata': [
('metadata', lambda: self.metadata_extractor.extract_all_metadata())
],
'meta_type': [
('metadata', lambda: self.metadata_extractor.extract_meta_type())
],
'meta_description': [
('metadata', lambda: self.metadata_extractor.extract_meta_description())
],
'file_size': [
('fileinfo', lambda: self.fileinfo_extractor.extract_size())
],
'modification_time': [
('fileinfo', lambda: self.fileinfo_extractor.extract_modification_time())
],
'file_name': [
('fileinfo', lambda: self.fileinfo_extractor.extract_file_name())
],
'file_path': [
('fileinfo', lambda: self.fileinfo_extractor.extract_file_path())
],
'extension': [
('fileinfo', lambda: self.fileinfo_extractor.extract_extension())
],
'tracks': [
('mediainfo', lambda: self.mediainfo_extractor.extract_tracks())
]
}
# Conditions for when a value is considered valid
self._conditions = {
'title': lambda x: x is not None,
'year': lambda x: x is not None,
'source': lambda x: x is not None,
'frame_class': lambda x: x and x != 'Unclassified',
'resolution': lambda x: x is not None,
'aspect_ratio': lambda x: x is not None,
'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 != ""
}
def get(self, key: str, source: str | None = None):
"""Get extracted data by key, optionally from specific source"""
if key not in self._sources:
raise ValueError(f"Unknown key: {key}")
condition = self._conditions.get(key, lambda x: x is not None)
if source:
for src, func in self._sources[key]:
if src == source:
val = func()
return val if condition(val) else None
raise ValueError(f"No such source '{source}' for key '{key}'")
else:
# Use fallback: return first valid value
for src, func in self._sources[key]:
val = func()
if condition(val):
return val
return None

View File

@@ -4,22 +4,29 @@ from pathlib import Path
class FileInfoExtractor:
"""Class to extract file information"""
@staticmethod
def extract_size(file_path: Path) -> int:
def __init__(self, file_path: Path):
self.file_path = file_path
self._size = file_path.stat().st_size
self._modification_time = file_path.stat().st_mtime
self._file_name = file_path.name
self._file_path = str(file_path)
def extract_size(self) -> int:
"""Extract file size in bytes"""
return file_path.stat().st_size
return self._size
@staticmethod
def extract_modification_time(file_path: Path) -> float:
def extract_modification_time(self) -> float:
"""Extract file modification time"""
return file_path.stat().st_mtime
return self._modification_time
@staticmethod
def extract_file_name(file_path: Path) -> str:
def extract_file_name(self) -> str:
"""Extract file name"""
return file_path.name
return self._file_name
@staticmethod
def extract_file_path(file_path: Path) -> str:
def extract_file_path(self) -> str:
"""Extract full file path as string"""
return str(file_path)
return self._file_path
def extract_extension(self) -> str:
"""Extract file extension without the dot"""
return self.file_path.suffix.lower().lstrip('.')

View File

@@ -6,40 +6,37 @@ from ..constants import SOURCE_DICT, FRAME_CLASSES
class FilenameExtractor:
"""Class to extract information from filename"""
@staticmethod
def _get_frame_class_from_height(height: int) -> str:
def __init__(self, file_path: Path):
self.file_path = file_path
self.file_name = file_path.name
def _get_frame_class_from_height(self, height: int) -> str:
"""Get frame class from video height using FRAME_CLASSES constant"""
for frame_class, info in FRAME_CLASSES.items():
if height == info['nominal_height']:
return frame_class
return 'Unclassified'
@staticmethod
def extract_title(file_path: Path) -> str | None:
def extract_title(self) -> str | None:
"""Extract movie title from filename"""
file_name = file_path.name
temp_name = re.sub(r'\s*\(\d{4}\)\s*|\s*\d{4}\s*|\.\d{4}\.', '', file_name)
temp_name = re.sub(r'\s*\(\d{4}\)\s*|\s*\d{4}\s*|\.\d{4}\.', '', self.file_name)
# Find and remove source
source = FilenameExtractor.extract_source(file_path)
source = self.extract_source()
if source:
for alias in SOURCE_DICT[source]:
temp_name = re.sub(r'\b' + re.escape(alias) + r'\b', '', temp_name, flags=re.IGNORECASE)
return temp_name.rsplit('.', 1)[0].strip()
@staticmethod
def extract_year(file_path: Path) -> str | None:
def extract_year(self) -> str | None:
"""Extract year from filename"""
file_name = file_path.name
year_match = re.search(r'\((\d{4})\)|(\d{4})', file_name)
year_match = re.search(r'\((\d{4})\)|(\d{4})', self.file_name)
return (year_match.group(1) or year_match.group(2)) if year_match else None
@staticmethod
def extract_source(file_path: Path) -> str | None:
def extract_source(self) -> str | None:
"""Extract video source from filename"""
file_name = file_path.name
temp_name = re.sub(r'\s*\(\d{4}\)\s*|\s*\d{4}\s*|\.\d{4}\.', '', file_name)
temp_name = re.sub(r'\s*\(\d{4}\)\s*|\s*\d{4}\s*|\.\d{4}\.', '', self.file_name)
for src, aliases in SOURCE_DICT.items():
for alias in aliases:
@@ -47,12 +44,10 @@ class FilenameExtractor:
return src
return None
@staticmethod
def extract_frame_class(file_path: Path) -> str | None:
def extract_frame_class(self) -> str | None:
"""Extract frame class from filename (480p, 720p, 1080p, 2160p, etc.)"""
file_name = file_path.name
match = re.search(r'(\d{3,4})[pi]', file_name, re.IGNORECASE)
match = re.search(r'(\d{3,4})[pi]', self.file_name, re.IGNORECASE)
if match:
height = int(match.group(1))
return FilenameExtractor._get_frame_class_from_height(height)
return self._get_frame_class_from_height(height)
return 'Unclassified'

View File

@@ -2,17 +2,24 @@ from pathlib import Path
from pymediainfo import MediaInfo
from collections import Counter
from ..constants import FRAME_CLASSES
from ..formatters.color_formatter import ColorFormatter
class MediaInfoExtractor:
"""Class to extract information from MediaInfo"""
def __init__(self):
self.lang_map = {
'en': 'eng', 'fr': 'fre', 'de': 'ger', 'uk': 'ukr', 'ru': 'rus',
'es': 'spa', 'it': 'ita', 'pt': 'por', 'ja': 'jpn', 'ko': 'kor',
'zh': 'chi', 'und': 'und'
}
def __init__(self, file_path: Path):
self.file_path = file_path
try:
self.media_info = MediaInfo.parse(file_path)
self.video_tracks = [t for t in self.media_info.tracks if t.track_type == 'Video']
self.audio_tracks = [t for t in self.media_info.tracks if t.track_type == 'Audio']
self.sub_tracks = [t for t in self.media_info.tracks if t.track_type == 'Text']
except Exception:
self.media_info = None
self.video_tracks = []
self.audio_tracks = []
self.sub_tracks = []
def _get_frame_class_from_height(self, height: int) -> str:
"""Get frame class from video height using FRAME_CLASSES constant"""
@@ -21,82 +28,113 @@ class MediaInfoExtractor:
return frame_class
return 'Unclassified'
def extract_frame_class(self, file_path: Path) -> str | None:
def extract_frame_class(self) -> str | None:
"""Extract frame class from media info (480p, 720p, 1080p, etc.)"""
try:
media_info = MediaInfo.parse(file_path)
video_tracks = [t for t in media_info.tracks if t.track_type == 'Video']
if video_tracks:
height = getattr(video_tracks[0], 'height', None)
if height:
return self._get_frame_class_from_height(height)
except:
pass
if not self.video_tracks:
return 'Unclassified'
height = getattr(self.video_tracks[0], 'height', None)
if height:
return self._get_frame_class_from_height(height)
return 'Unclassified'
def extract_resolution(self, file_path: Path) -> str | None:
def extract_resolution(self) -> str | None:
"""Extract actual video resolution (WIDTHxHEIGHT) from media info"""
try:
media_info = MediaInfo.parse(file_path)
video_tracks = [t for t in media_info.tracks if t.track_type == 'Video']
if video_tracks:
width = getattr(video_tracks[0], 'width', None)
height = getattr(video_tracks[0], 'height', None)
if width and height:
return f"{width}x{height}"
except:
pass
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 None
def extract_aspect_ratio(self, file_path: Path) -> str | None:
def extract_aspect_ratio(self) -> str | None:
"""Extract video aspect ratio from media info"""
try:
media_info = MediaInfo.parse(file_path)
video_tracks = [t for t in media_info.tracks if t.track_type == 'Video']
if video_tracks:
aspect_ratio = getattr(video_tracks[0], 'display_aspect_ratio', None)
if aspect_ratio:
return str(aspect_ratio)
except:
pass
if not self.video_tracks:
return None
aspect_ratio = getattr(self.video_tracks[0], 'display_aspect_ratio', None)
if aspect_ratio:
return str(aspect_ratio)
return None
def extract_hdr(self, file_path: Path) -> str | None:
def extract_hdr(self) -> str | None:
"""Extract HDR info from media info"""
try:
media_info = MediaInfo.parse(file_path)
video_tracks = [t for t in media_info.tracks if t.track_type == 'Video']
if video_tracks:
profile = getattr(video_tracks[0], 'format_profile', '')
if 'HDR' in profile.upper():
return 'HDR'
except:
pass
if not self.video_tracks:
return None
profile = getattr(self.video_tracks[0], 'format_profile', '')
if 'HDR' in profile.upper():
return 'HDR'
return None
def extract_audio_langs(self, file_path: Path) -> str:
def extract_audio_langs(self) -> str:
"""Extract audio languages from media info"""
try:
media_info = MediaInfo.parse(file_path)
audio_tracks = [t for t in media_info.tracks if t.track_type == 'Audio']
langs = [getattr(a, 'language', 'und').lower()[:3] for a in audio_tracks]
langs = [self.lang_map.get(lang, lang) for lang in langs]
lang_counts = Counter(langs)
audio_langs = [f"{count}{lang}" if count > 1 else lang for lang, count in lang_counts.items()]
return ','.join(audio_langs)
except:
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]
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, file_path: Path) -> tuple[int, int] | None:
def extract_video_dimensions(self) -> tuple[int, int] | None:
"""Extract video width and height"""
try:
media_info = MediaInfo.parse(file_path)
video_tracks = [t for t in media_info.tracks if t.track_type == 'Video']
if video_tracks:
width = getattr(video_tracks[0], 'width', None)
height = getattr(video_tracks[0], 'height', None)
if width and height:
return width, height
except:
pass
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_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 ""

View File

@@ -1,48 +1,63 @@
import mutagen
from pathlib import Path
from ..constants import MEDIA_TYPES
class MetadataExtractor:
"""Class to extract information from file metadata"""
@staticmethod
def extract_title(file_path: Path) -> str | None:
def __init__(self, file_path: Path):
self.file_path = file_path
try:
self.info = mutagen.File(file_path) # type: ignore
except Exception:
self.info = None
def extract_title(self) -> str | None:
"""Extract title from metadata"""
try:
info = mutagen.File(file_path)
if info:
return getattr(info, 'title', None) or getattr(info, 'get', lambda x, default=None: default)('title', [None])[0]
except:
pass
if self.info:
return getattr(self.info, 'title', None) or getattr(self.info, 'get', lambda x, default=None: default)('title', [None])[0] # type: ignore
return None
@staticmethod
def extract_duration(file_path: Path) -> float | None:
def extract_duration(self) -> float | None:
"""Extract duration from metadata"""
try:
info = mutagen.File(file_path)
if info:
return getattr(info, 'length', None)
except:
pass
if self.info:
return getattr(self.info, 'length', None)
return None
@staticmethod
def extract_artist(file_path: Path) -> str | None:
def extract_artist(self) -> str | None:
"""Extract artist from metadata"""
try:
info = mutagen.File(file_path)
if info:
return getattr(info, 'artist', None) or getattr(info, 'get', lambda x, default=None: default)('artist', [None])[0]
except:
pass
if self.info:
return getattr(self.info, 'artist', None) or getattr(self.info, 'get', lambda x, default=None: default)('artist', [None])[0] # type: ignore
return None
@staticmethod
def extract_all_metadata(file_path: Path) -> dict:
def extract_all_metadata(self) -> dict:
"""Extract all metadata"""
return {
'title': MetadataExtractor.extract_title(file_path),
'duration': MetadataExtractor.extract_duration(file_path),
'artist': MetadataExtractor.extract_artist(file_path)
'title': self.extract_title(),
'duration': self.extract_duration(),
'artist': self.extract_artist()
}
def extract_meta_type(self) -> str:
"""Extract meta type from metadata"""
if self.info:
return type(self.info).__name__
return self._detect_by_mime()
def extract_meta_description(self) -> str:
"""Extract meta description"""
meta_type = self.extract_meta_type()
return {info['meta_type']: info['description'] for info in MEDIA_TYPES.values()}.get(meta_type, f'Unknown type {meta_type}')
def _detect_by_mime(self) -> str:
"""Detect meta type by MIME"""
try:
import magic
mime = magic.from_file(str(self.file_path), mime=True)
for ext, info in MEDIA_TYPES.items():
if info['mime'] == mime:
return info['meta_type']
return 'Unknown'
except Exception:
return 'Unknown'

View File

@@ -0,0 +1,54 @@
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

@@ -8,3 +8,8 @@ class DateFormatter:
def format_modification_date(mtime: float) -> str:
"""Format file modification time"""
return datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M:%S")
@staticmethod
def format_year(year: float | None) -> str:
"""Format year from float to string"""
return f"({year})" if year else ""

View File

@@ -1,5 +1,5 @@
from pathlib import Path
from ..constants import VIDEO_EXT_DESCRIPTIONS
from ..constants import MEDIA_TYPES
class ExtensionExtractor:
@@ -13,4 +13,4 @@ class ExtensionExtractor:
@staticmethod
def get_extension_description(ext_name: str) -> str:
"""Get description for extension"""
return VIDEO_EXT_DESCRIPTIONS.get(ext_name, f'Unknown extension .{ext_name}')
return MEDIA_TYPES.get(ext_name, {}).get('description', f'Unknown extension .{ext_name}')

View File

@@ -1,6 +1,6 @@
from pathlib import Path
from ..constants import VIDEO_EXT_DESCRIPTIONS
from ..utils import detect_file_type
from ..constants import MEDIA_TYPES
from .color_formatter import ColorFormatter
class ExtensionFormatter:
@@ -9,21 +9,7 @@ class ExtensionFormatter:
@staticmethod
def check_extension_match(ext_name: str, meta_type: str) -> bool:
"""Check if file extension matches detected type"""
if ext_name.upper() == meta_type:
return True
elif ext_name == 'mkv' and meta_type == 'Matroska':
return True
elif ext_name == 'avi' and meta_type == 'AVI':
return True
elif ext_name == 'mov' and meta_type == 'QuickTime':
return True
elif ext_name == 'wmv' and meta_type == 'ASF':
return True
elif ext_name == 'flv' and meta_type == 'FLV':
return True
elif ext_name == 'webm' and meta_type == 'WebM':
return True
elif ext_name == 'ogv' and meta_type == 'Ogg':
if ext_name in MEDIA_TYPES and MEDIA_TYPES[ext_name]['meta_type'] == meta_type:
return True
return False
@@ -31,8 +17,8 @@ class ExtensionFormatter:
def format_extension_info(ext_name: str, ext_desc: str, meta_type: str, meta_desc: str, match: bool) -> str:
"""Format extension information with match status"""
if match:
return f"[bold green]Extension:[/bold green] {ext_name} - [grey]{ext_desc}[/grey]"
return f"{ColorFormatter.bold_green('Extension:')} {ext_name} - {ColorFormatter.grey(ext_desc)}"
else:
return (f"[bold yellow]Extension:[/bold yellow] {ext_name} - [grey]{ext_desc}[/grey]\n"
f"[bold red]Meta extension:[/bold red] {meta_type} - [grey]{meta_desc}[/grey]\n"
"[bold red]Warning: Extensions do not match![/bold red]")
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!')}")

View File

@@ -3,122 +3,200 @@ from .size_formatter import SizeFormatter
from .date_formatter import DateFormatter
from .extension_extractor import ExtensionExtractor
from .extension_formatter import ExtensionFormatter
from ..utils import detect_file_type
from .color_formatter import ColorFormatter
class MediaFormatter:
"""Class to format media data for display"""
def format_file_info_panel(self, file_path: Path, rename_data: dict) -> str:
def format_file_info_panel(self, extractor) -> str:
"""Format file information for the file info panel"""
output = []
# Panel title
output.append("[bold blue]FILE INFO[/bold blue]")
output.append("") # Empty line for spacing
# Get file stats
size_full = SizeFormatter.format_size_full(file_path.stat().st_size)
date_formatted = DateFormatter.format_modification_date(file_path.stat().st_mtime)
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
ext_name = ExtensionExtractor.get_extension_name(file_path)
ext_name = ExtensionExtractor.get_extension_name(
Path(extractor.get("file_path"))
)
ext_desc = ExtensionExtractor.get_extension_description(ext_name)
meta_type, meta_desc = detect_file_type(file_path)
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)
ext_info = ExtensionFormatter.format_extension_info(
ext_name, ext_desc, meta_type, meta_desc, match
)
file_name = file_path.name
output = [ColorFormatter.bold_blue("FILE INFO"), ""]
output.extend(
item["format_func"](f"{item['label']}: {item['value']}") for item in data
)
output.append(ext_info)
output.append(f"[bold blue]Path:[/bold blue] {str(file_path)}")
output.append(f"[bold green]Size:[/bold green] {size_full}")
output.append(f"[bold cyan]File:[/bold cyan] {file_name}")
output.append(f"[bold magenta]Modified:[/bold magenta] {date_formatted}")
output.append(f"{ext_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)
# Add rename lines
rename_lines = self.format_rename_lines(extractor)
output.append("")
output.extend(rename_lines)
return "\n".join(output)
def format_filename_extraction_panel(self, rename_data: dict) -> str:
def format_filename_extraction_panel(self, extractor) -> str:
"""Format filename extraction data for the filename panel"""
output = []
output.append("[bold yellow]FILENAME EXTRACTION[/bold yellow]")
output.append("") # Empty line for spacing
output.append(f"[yellow]Title:[/yellow] {rename_data.get('title', 'Not found')}")
output.append(f"[yellow]Year:[/yellow] {rename_data.get('year', 'Not found')}")
output.append(f"[yellow]Source:[/yellow] {rename_data.get('source', 'Not found')}")
output.append(f"[yellow]Frame Class:[/yellow] {rename_data.get('frame_class', 'Not found')}")
data = [
{
"label": "Title",
"value": extractor.get("title") or "Not found",
"format_func": ColorFormatter.yellow,
},
{
"label": "Year",
"value": extractor.get("year") or "Not found",
"format_func": ColorFormatter.yellow,
},
{
"label": "Source",
"value": extractor.get("source") or "Not found",
"format_func": ColorFormatter.yellow,
},
{
"label": "Frame Class",
"value": extractor.get("frame_class") or "Not found",
"format_func": ColorFormatter.yellow,
},
]
output = [ColorFormatter.bold_yellow("FILENAME EXTRACTION"), ""]
output.extend(
item["format_func"](f"{item['label']}: {item['value']}") for item in data
)
return "\n".join(output)
def format_metadata_extraction_panel(self, rename_data: dict) -> str:
def format_metadata_extraction_panel(self, extractor) -> str:
"""Format metadata extraction data for the metadata panel"""
output = []
output.append("[bold cyan]METADATA EXTRACTION[/bold cyan]")
output.append("") # Empty line for spacing
metadata = rename_data.get('metadata', {})
if metadata.get('duration'):
output.append(f"[cyan]Duration:[/cyan] {metadata['duration']:.1f} seconds")
if metadata.get('title'):
output.append(f"[cyan]Title:[/cyan] {metadata['title']}")
if metadata.get('artist'):
output.append(f"[cyan]Artist:[/cyan] {metadata['artist']}")
if not any(key in metadata for key in ['duration', 'title', 'artist']):
output.append("[dim]No metadata found[/dim]")
return "\n".join(output) if output else "[dim]No metadata found[/dim]"
metadata = extractor.get("metadata") or {}
data = []
if metadata.get("duration"):
data.append(
{
"label": "Duration",
"value": f"{metadata['duration']:.1f} seconds",
"format_func": ColorFormatter.cyan,
}
)
if metadata.get("title"):
data.append(
{
"label": "Title",
"value": metadata["title"],
"format_func": ColorFormatter.cyan,
}
)
if metadata.get("artist"):
data.append(
{
"label": "Artist",
"value": metadata["artist"],
"format_func": ColorFormatter.cyan,
}
)
output = [ColorFormatter.bold_cyan("METADATA EXTRACTION"), ""]
if data:
output.extend(
item["format_func"](f"{item['label']}: {item['value']}")
for item in data
)
else:
output.append(ColorFormatter.dim("No metadata found"))
def format_mediainfo_extraction_panel(self, rename_data: dict) -> str:
"""Format media info extraction data for the mediainfo panel"""
output = []
output.append("[bold green]MEDIA INFO EXTRACTION[/bold green]")
output.append("") # Empty line for spacing
output.append(f"[green]Resolution:[/green] {rename_data.get('resolution', 'Not found')}")
output.append(f"[green]Aspect Ratio:[/green] {rename_data.get('aspect_ratio', 'Not found')}")
output.append(f"[green]HDR:[/green] {rename_data.get('hdr', 'Not found')}")
output.append(f"[green]Audio Languages:[/green] {rename_data.get('audio_langs', 'Not found')}")
return "\n".join(output)
def format_proposed_name(self, rename_data: dict, ext_name: str) -> str:
"""Format the proposed filename"""
proposed_parts = []
if rename_data['title']:
proposed_parts.append(rename_data['title'])
if rename_data['year']:
proposed_parts.append(f"({rename_data['year']})")
if rename_data['source']:
proposed_parts.append(rename_data['source'])
def format_mediainfo_extraction_panel(self, extractor) -> str:
"""Format media info extraction data for the mediainfo panel"""
data = [
{
"label": "Resolution",
"value": extractor.get("resolution") or "Not found",
"format_func": ColorFormatter.green,
},
{
"label": "Aspect Ratio",
"value": extractor.get("aspect_ratio") or "Not found",
"format_func": ColorFormatter.green,
},
{
"label": "HDR",
"value": extractor.get("hdr") or "Not found",
"format_func": ColorFormatter.green,
},
{
"label": "Audio Languages",
"value": extractor.get("audio_langs") or "Not found",
"format_func": ColorFormatter.green,
},
]
tags = []
if rename_data['frame_class'] and rename_data['frame_class'] != 'Unclassified':
tags.append(rename_data['frame_class'])
if rename_data['hdr']:
tags.append(rename_data['hdr'])
if rename_data['audio_langs']:
tags.append(rename_data['audio_langs'])
if tags:
proposed_parts.append(f"[{','.join(tags)}]")
output = [ColorFormatter.bold_green("MEDIA INFO EXTRACTION"), ""]
output.extend(
item["format_func"](f"{item['label']}: {item['value']}") for item in data
)
return ' '.join(proposed_parts) + f".{ext_name}"
return "\n".join(output)
def format_rename_lines(self, rename_data: dict, proposed_name: str) -> list[str]:
def format_rename_lines(self, extractor) -> list[str]:
"""Format the rename information lines"""
lines = []
lines.append(f"Movie title: {rename_data['title'] or 'Unknown'}")
lines.append(f"Year: {rename_data['year'] or 'Unknown'}")
lines.append(f"Video source: {rename_data['source'] or 'Unknown'}")
lines.append(f"Frame class: {rename_data['frame_class'] or 'Unknown'}")
lines.append(f"Resolution: {rename_data['resolution'] or 'Unknown'}")
lines.append(f"Aspect ratio: {rename_data['aspect_ratio'] or 'Unknown'}")
lines.append(f"HDR: {rename_data['hdr'] or 'No'}")
lines.append(f"Audio langs: {rename_data['audio_langs'] or 'None'}")
lines.append(f"Proposed filename: {proposed_name}")
return lines
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:
"""Format extra metadata like duration, title, artist"""
extra_info = []
if metadata.get('duration'):
extra_info.append(f"[cyan]Duration:[/cyan] {metadata['duration']:.1f} seconds")
if metadata.get('title'):
extra_info.append(f"[cyan]Title:[/cyan] {metadata['title']}")
if metadata.get('artist'):
extra_info.append(f"[cyan]Artist:[/cyan] {metadata['artist']}")
return "\n".join(extra_info) if extra_info else ""
data = {}
if metadata.get("duration"):
data["Duration"] = f"{metadata['duration']:.1f} seconds"
if metadata.get("title"):
data["Title"] = metadata["title"]
if metadata.get("artist"):
data["Artist"] = metadata["artist"]
return "\n".join(
ColorFormatter.cyan(f"{key}: {value}") for key, value in data.items()
)

View File

@@ -0,0 +1,25 @@
from .color_formatter import ColorFormatter
from .date_formatter import DateFormatter
class ProposedNameFormatter:
"""Class for formatting proposed filenames"""
def __init__(self, extractor):
self.extractor = extractor
self.__title = extractor.get("title") or "Unknown Title"
self.__year = DateFormatter.format_year(extractor.get("year"))
self.__source = extractor.get("source") or None
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
self.__extension = extractor.get("extension") or "ext"
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}"
def format_display_string(self) -> str:
"""Format the proposed name for display with color"""
return ColorFormatter.bold_yellow(str(self))

View File

@@ -1,93 +0,0 @@
from pymediainfo import MediaInfo
from .constants import META_DESCRIPTIONS
import magic
import mutagen
from collections import Counter
def get_media_tracks(file_path):
"""Extract compact media track information"""
tracks_info = []
try:
media_info = MediaInfo.parse(file_path)
video_tracks = [t for t in media_info.tracks if t.track_type == 'Video']
audio_tracks = [t for t in media_info.tracks if t.track_type == 'Audio']
sub_tracks = [t for t in media_info.tracks if t.track_type == 'Text']
# Video tracks
for i, v in enumerate(video_tracks[:2]): # Up to 2 videos
codec = getattr(v, 'format', None) or getattr(v, 'codec', None) or 'unknown'
width = getattr(v, 'width', None) or '?'
height = getattr(v, 'height', None) or '?'
bitrate = getattr(v, 'bit_rate', None)
fps = getattr(v, 'frame_rate', None)
profile = getattr(v, 'format_profile', None)
video_str = f"{codec} {width}x{height}"
if bitrate:
video_str += f" {bitrate}bps"
if fps:
video_str += f" {fps}fps"
if profile:
video_str += f" ({profile})"
tracks_info.append(f"[green]Video {i+1}:[/green] {video_str}")
# Audio tracks
for i, a in enumerate(audio_tracks[:3]): # Up to 3 audios
codec = getattr(a, 'format', None) or getattr(a, 'codec', None) or 'unknown'
channels = getattr(a, 'channel_s', None) or '?'
lang = getattr(a, 'language', None) or 'und'
bitrate = getattr(a, 'bit_rate', None)
audio_str = f"{codec} {channels}ch {lang}"
if bitrate:
audio_str += f" {bitrate}bps"
tracks_info.append(f"[yellow]Audio {i+1}:[/yellow] {audio_str}")
# Subtitle tracks
for i, s in enumerate(sub_tracks[:3]): # Up to 3 subs
lang = getattr(s, 'language', None) or 'und'
format = getattr(s, 'format', None) or getattr(s, 'codec', None) or 'unknown'
sub_str = f"{lang} ({format})"
tracks_info.append(f"[magenta]Sub {i+1}:[/magenta] {sub_str}")
except Exception as e:
tracks_info.append(f"[red]Track info error: {str(e)}[/red]")
return "\n".join(tracks_info) if tracks_info else ""
def detect_file_type(file_path):
"""Detect file type and return meta_type and desc"""
try:
info = mutagen.File(file_path)
if info is None:
# Fallback to magic
mime = magic.from_file(str(file_path), mime=True)
if mime == 'video/x-matroska':
return 'Matroska', 'Matroska multimedia container'
elif mime == 'video/mp4':
return 'MP4', 'MPEG-4 video container'
elif mime == 'video/x-msvideo':
return 'AVI', 'Audio Video Interleave'
elif mime == 'video/quicktime':
return 'QuickTime', 'QuickTime movie'
elif mime == 'video/x-ms-wmv':
return 'ASF', 'Windows Media'
elif mime == 'video/x-flv':
return 'FLV', 'Flash Video'
elif mime == 'video/webm':
return 'WebM', 'WebM multimedia'
elif mime == 'video/ogg':
return 'Ogg', 'Ogg multimedia'
else:
return 'Unknown', f'Unknown MIME: {mime}'
else:
meta_type = type(info).__name__
meta_desc = META_DESCRIPTIONS.get(meta_type, f'Unknown type {meta_type}')
return meta_type, meta_desc
except Exception as e:
return f'Error: {str(e)}', f'Error detecting type'