feat: Add conversion and deletion confirmation screens, enhance media panel properties

- Implemented ConvertConfirmScreen for confirming AVI to MKV conversions with audio and subtitle options.
- Added DeleteConfirmScreen for confirming file deletions with detailed file information.
- Enhanced MediaPanelView to include additional MediaInfo properties such as video, audio, and subtitle tracks.
- Updated MediaPanelProperties to extract and display raw MediaInfo track data.
- Introduced HelpScreen for user guidance on application features and navigation.
- Created OpenScreen for directory path input with validation.
- Developed RenameConfirmScreen for renaming files with user confirmation and editing capabilities.
- Added SettingsScreen for configuring application settings, including cache TTL and HEVC encoding options.
- Updated imports and module exports in views to accommodate new screens.
This commit is contained in:
sha
2026-04-11 20:49:28 +03:00
parent 2e5cef4424
commit 8274cb4e9f
19 changed files with 1112 additions and 913 deletions
+2 -2
View File
@@ -44,7 +44,7 @@ class FileInfoExtractor:
self.cache = Cache() if use_cache else None # Singleton cache for @cached_method decorator
self.settings = None # Will be set by Settings singleton if needed
self._stat = file_path.stat()
self._cache: dict[str, any] = {} # Internal cache for method results
self._cache: dict = {} # Internal cache for method results
@cached_method()
def extract_size(self) -> int:
@@ -91,4 +91,4 @@ class FileInfoExtractor:
or None if no extension exists
"""
ext = self._file_path.suffix.lower().lstrip('.')
return ext if ext else None
return ext if ext else None
+320 -185
View File
@@ -5,96 +5,240 @@ from ..constants import FRAME_CLASSES, get_extension_from_format
from ..cache import cached_method, Cache
import langcodes
import logging
import functools
logger = logging.getLogger(__name__)
def requires_tracks(func):
"""Decorator that returns None if media_info has no tracks."""
@functools.wraps(func)
def wrapper(self, *args, **kwargs):
media_info = self._get_media_info()
if not media_info or not media_info.tracks:
return None
return func(self, *args, **kwargs)
return wrapper
def requires_tracks_type(track_type: str):
"""Decorator that returns None if no tracks of the specified type are available."""
def decorator(func):
@functools.wraps(func)
def wrapper(self, *args, **kwargs):
tracks = self._get_tracks(track_type=track_type)
if tracks is None:
return None
if type(tracks) is not list:
return None
if len(tracks) == 0:
return None
return func(self, *args, **kwargs)
return wrapper
return decorator
class MediaInfoExtractor:
"""Class to extract information from MediaInfo"""
def __init__(self, file_path: Path, use_cache: bool = True):
self.file_path = file_path
self.cache = Cache() if use_cache else None # Singleton cache for @cached_method decorator
self.cache = (
Cache() if use_cache else None
) # Singleton cache for @cached_method decorator
self.settings = None # Will be set by Settings singleton if needed
self._cache = {} # Internal cache for method results
# Parse media info - set to None on failure
self.media_info = MediaInfo.parse(file_path) if file_path.exists() else None
# Extract tracks
if self.media_info:
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']
else:
self.video_tracks = []
self.audio_tracks = []
self.sub_tracks = []
def _get_frame_class_from_height(self, height: int) -> str | None:
"""Get frame class from video height, finding closest match if exact not found"""
if not height:
return None
# First try exact match
for frame_class, info in FRAME_CLASSES.items():
if height == info['nominal_height']:
return frame_class
# If no exact match, find closest
closest = None
min_diff = float('inf')
for frame_class, info in FRAME_CLASSES.items():
diff = abs(height - info['nominal_height'])
if diff < min_diff:
min_diff = diff
closest = frame_class
# Only return if difference is reasonable (within 50 pixels)
if min_diff <= 50:
return closest
return None
@cached_method()
def _get_media_info(self) -> MediaInfo | None:
"""Get parsed MediaInfo object, cached. Returns None if no media."""
if not self.file_path.exists():
return None
parsed = MediaInfo.parse(self.file_path)
return parsed if parsed else None
@requires_tracks
def _get_tracks(self, track_type="General") -> list | None:
"""Return tracks of given type or specific track by ID."""
media_info = self._get_media_info()
tracks = [t for t in media_info.tracks if t.track_type == track_type]
return tracks
def _get_track(self, track_type="General", track_id: int = 0) -> object | None:
"""Return tracks of given type or specific track by ID."""
tracks = self._get_tracks(track_type=track_type)
if tracks is None:
return None
return tracks[track_id] if track_id < len(tracks) else None
@requires_tracks_type("General")
def extract_general_track(self) -> dict | None:
"""Extract general track data"""
general = self._get_track(track_type="General", track_id=0)
result = {
"format": getattr(general, "format", None) or "unknown",
"file_size": getattr(general, "file_size", None),
"duration": getattr(general, "duration", 0) / 1000
if getattr(general, "duration", None)
else None,
"overall_bit_rate": getattr(general, "overall_bit_rate", None),
"movie_name": getattr(general, "movie_name", None),
"encoded_date": getattr(general, "encoded_date", None),
}
return result
@requires_tracks_type("Video")
def extract_video_tracks(self) -> list[dict] | None:
"""Extract video track data"""
tracks = self._get_tracks(track_type="Video")
# Type assertion: decorator guarantees tracks is a list
assert isinstance(tracks, list)
result = []
for v in 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) or "",
"interlaced": getattr(v, "interlaced", None) == "Yes",
"anamorphic": getattr(v, "anamorphic", None) == "Yes",
}
result.append(track_data)
return result if result else None
@requires_tracks_type("Audio")
def extract_audio_tracks(self) -> list[dict] | None:
"""Extract audio track data"""
tracks = self._get_tracks(track_type="Audio")
# Type assertion: decorator guarantees tracks is a list
assert isinstance(tracks, list)
result = []
for a in tracks[:10]: # Up to 10 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", "und"),
"bitrate": getattr(a, "bit_rate", None),
}
result.append(track_data)
return result if result else None
@requires_tracks_type("Text")
def extract_subtitle_tracks(self) -> list[dict] | None:
"""Extract subtitle track data"""
tracks = self._get_tracks(track_type="Text")
# Type assertion: decorator guarantees tracks is a list
assert isinstance(tracks, list)
result = []
for s in tracks[:10]: # Up to 10 subs
track_data = {
"language": getattr(s, "language", "und"),
"format": getattr(s, "format", None)
or getattr(s, "codec", None)
or "unknown",
"forced": getattr(s, "forced", None) == "Yes",
"default": getattr(s, "default", None) == "Yes",
}
result.append(track_data)
return result if result else None
@requires_tracks
@requires_tracks_type("General")
def extract_duration(self) -> float | None:
"""Extract duration from media info in seconds"""
if self.media_info:
for track in self.media_info.tracks:
if track.track_type == 'General':
return getattr(track, 'duration', 0) / 1000 if getattr(track, 'duration', None) else None
tracks = self._get_tracks(track_type="General")
# Type assertion: decorators guarantee tracks is a list
assert isinstance(tracks, list)
for track in tracks:
return (
getattr(track, "duration", 0) / 1000
if getattr(track, "duration", None)
else None
)
return None
@requires_tracks_type("Video")
def extract_resolution(self) -> tuple[int, int] | None:
"""Extract actual video resolution as (width, height) tuple from media info"""
track = self._get_track(track_type="Video", track_id=0)
width = getattr(track, "width", None)
height = getattr(track, "height", None)
if width is not None and height is not None:
try:
return int(width), int(height)
except (ValueError, TypeError):
return None
return None
@requires_tracks_type("Video")
def extract_frame_class(self) -> str | None:
"""Extract frame class from media info (480p, 720p, 1080p, etc.)"""
if not self.video_tracks:
return None
height = getattr(self.video_tracks[0], 'height', None)
width = getattr(self.video_tracks[0], 'width', None)
if not height or not width:
return None
track = self._get_track(track_type="Video", track_id=0)
# Check if interlaced - try multiple attributes
# PyMediaInfo may use different attribute names depending on version
scan_type_attr = getattr(self.video_tracks[0], 'scan_type', None)
interlaced = getattr(self.video_tracks[0], 'interlaced', None)
scan_type_attr = getattr(track, "scan_type", None)
logger.debug(f"[{self.file_path.name}] Frame class detection - Resolution: {width}x{height}")
logger.debug(f"[{self.file_path.name}] scan_type attribute: {scan_type_attr!r} (type: {type(scan_type_attr).__name__})")
logger.debug(f"[{self.file_path.name}] interlaced attribute: {interlaced!r} (type: {type(interlaced).__name__})")
interlaced = self.extract_interlaced()
scan_order = getattr(track, "scan_order", None)
resolution = self.extract_resolution()
if not resolution:
return None
height, width = resolution
logger.debug(
f"[{self.file_path.name}] Frame class detection - Resolution: {width}x{height}"
)
logger.debug(
f"[{self.file_path.name}] scan_type attribute: {scan_type_attr!r} (type: {type(scan_type_attr).__name__})"
)
logger.debug(
f"[{self.file_path.name}] interlaced attribute: {interlaced!r} (type: {type(interlaced).__name__})"
)
logger.debug(
f"[{self.file_path.name}] scan_order attribute: {scan_order!r} (type: {type(scan_order).__name__})"
)
# Determine scan type from available attributes
# Check scan_type first (e.g., "Interlaced", "Progressive", "MBAFF")
if scan_type_attr and isinstance(scan_type_attr, str):
scan_type = 'i' if 'interlaced' in scan_type_attr.lower() else 'p'
logger.debug(f"[{self.file_path.name}] Using scan_type: {scan_type_attr!r} -> scan_type={scan_type!r}")
# Then check interlaced flag (e.g., "Yes", "No")
elif interlaced and isinstance(interlaced, str):
scan_type = 'i' if interlaced.lower() in ['yes', 'true', '1'] else 'p'
logger.debug(f"[{self.file_path.name}] Using interlaced: {interlaced!r} -> scan_type={scan_type!r}")
scan_type = "i" if "interlaced" in scan_type_attr.lower() else "p"
logger.debug(
f"[{self.file_path.name}] Using scan_type: {scan_type_attr!r} -> scan_type={scan_type!r}"
)
# Check scan_order (e.g., "TFF", "BFF" for interlaced, "Progressive" for progressive)
elif scan_order and isinstance(scan_order, str):
scan_type = "i" if scan_order.upper() in ["TFF", "BFF"] else "p"
logger.debug(
f"[{self.file_path.name}] Using scan_order: {scan_order!r} -> scan_type={scan_type!r}"
)
# Then check interlaced flag from extract_interlaced() method
elif interlaced is True:
scan_type = "i"
logger.debug(
f"[{self.file_path.name}] Using interlaced: True -> scan_type=i"
)
elif interlaced is False:
scan_type = "p"
logger.debug(
f"[{self.file_path.name}] Using interlaced: False -> scan_type=p"
)
else:
# Default to progressive if no information available
scan_type = 'p'
logger.debug(f"[{self.file_path.name}] No scan type info, defaulting to progressive")
scan_type = "p"
logger.debug(
f"[{self.file_path.name}] No scan type info, defaulting to progressive"
)
# Calculate effective height for frame class determination
aspect_ratio = 16 / 9
@@ -107,9 +251,9 @@ class MediaInfoExtractor:
# Use a larger tolerance (10 pixels) to handle cinema/ultrawide aspect ratios
width_matches = []
for frame_class, info in FRAME_CLASSES.items():
for tw in info['typical_widths']:
for tw in info["typical_widths"]:
if abs(width - tw) <= 10 and frame_class.endswith(scan_type):
diff = abs(height - info['nominal_height'])
diff = abs(height - info["nominal_height"])
width_matches.append((frame_class, diff))
if width_matches:
@@ -123,67 +267,68 @@ class MediaInfoExtractor:
# First try exact match with standard frame classes
frame_class = f"{int(round(effective_height))}{scan_type}"
if frame_class in FRAME_CLASSES:
logger.debug(f"[{self.file_path.name}] Result (exact height match): {frame_class!r}")
logger.debug(
f"[{self.file_path.name}] Result (exact height match): {frame_class!r}"
)
return frame_class
# Find closest standard height match
closest_class = None
min_diff = float('inf')
min_diff = float("inf")
for fc, info in FRAME_CLASSES.items():
if fc.endswith(scan_type):
diff = abs(effective_height - info['nominal_height'])
diff = abs(effective_height - info["nominal_height"])
if diff < min_diff:
min_diff = diff
closest_class = fc
# Return closest standard match if within reasonable distance (20 pixels)
if closest_class and min_diff <= 20:
logger.debug(f"[{self.file_path.name}] Result (closest match, diff={min_diff}): {closest_class!r}")
logger.debug(
f"[{self.file_path.name}] Result (closest match, diff={min_diff}): {closest_class!r}"
)
return closest_class
# For non-standard resolutions, create a custom frame class
logger.debug(f"[{self.file_path.name}] Result (custom/non-standard): {frame_class!r}")
logger.debug(
f"[{self.file_path.name}] Result (custom/non-standard): {frame_class!r}"
)
return frame_class
@cached_method()
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 width, height
return None
@cached_method()
@requires_tracks_type("Video")
def extract_aspect_ratio(self) -> str | None:
"""Extract video aspect ratio from media info"""
if not self.video_tracks:
return None
aspect_ratio = getattr(self.video_tracks[0], 'display_aspect_ratio', None)
tracks = self._get_tracks(track_type="Video")
# Type assertion: decorator guarantees tracks is a list
assert isinstance(tracks, list)
track = tracks[0]
aspect_ratio = getattr(track, "display_aspect_ratio", None)
if aspect_ratio:
return str(aspect_ratio)
return None
@cached_method()
@requires_tracks_type("Video")
def extract_hdr(self) -> str | None:
"""Extract HDR info from media info"""
if not self.video_tracks:
return None
profile = getattr(self.video_tracks[0], 'format_profile', '') or ''
if 'HDR' in profile.upper():
return 'HDR'
tracks = self._get_tracks(track_type="Video")
# Type assertion: decorator guarantees tracks is a list
assert isinstance(tracks, list)
track = tracks[0]
profile = getattr(track, "format_profile", "") or ""
if "HDR" in profile.upper():
return "HDR"
return None
@cached_method()
@requires_tracks
@requires_tracks_type("Audio")
def extract_audio_langs(self) -> str | None:
"""Extract audio languages from media info"""
if not self.audio_tracks:
tracks = self._get_tracks(track_type="Audio")
if not isinstance(tracks, list):
return None
langs = []
for a in self.audio_tracks:
lang_code = getattr(a, 'language', 'und') or 'und'
for a in tracks:
lang_code = getattr(a, "language", "und") or "und"
try:
# Try to get the 3-letter code
lang_obj = langcodes.Language.get(lang_code.lower())
@@ -195,75 +340,26 @@ class MediaInfoExtractor:
langs.append(lang_code.lower()[: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)
audio_langs = [
f"{count}{lang}" if count > 1 else lang
for lang, count in lang_counts.items()
]
return ",".join(audio_langs)
@cached_method()
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) or '',
}
tracks.append(track_data)
return tracks
@cached_method()
def extract_audio_tracks(self) -> list[dict]:
"""Extract audio track data"""
tracks = []
for a in self.audio_tracks[:10]: # Up to 10 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
@cached_method()
def extract_subtitle_tracks(self) -> list[dict]:
"""Extract subtitle track data"""
tracks = []
for s in self.sub_tracks[:10]: # Up to 10 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
@cached_method()
def is_3d(self) -> bool:
"""Check if the video is 3D"""
if not self.video_tracks:
track = self._get_track("Video", 0)
if not track:
return False
multi_view = getattr(self.video_tracks[0], 'multi_view_count', None)
multi_view = getattr(track, "multi_view_count", None)
if multi_view and int(multi_view) > 1:
return True
stereoscopic = getattr(self.video_tracks[0], 'stereoscopic', None)
if stereoscopic == 'Yes':
stereoscopic = getattr(track, "stereoscopic", None)
if stereoscopic == "Yes":
return True
return False
@cached_method()
def extract_anamorphic(self) -> str | None:
"""Extract anamorphic info for 3D videos"""
if not self.video_tracks:
return None
anamorphic = getattr(self.video_tracks[0], 'anamorphic', None)
if anamorphic == 'Yes' and self.is_3d():
return 'Anamorphic:Yes'
return None
@cached_method()
@requires_tracks_type("General")
def extract_extension(self) -> str | None:
"""Extract file extension based on container format.
@@ -273,12 +369,9 @@ class MediaInfoExtractor:
Returns:
File extension (e.g., "mp4", "mkv") or None if format is unknown
"""
if not self.media_info:
return None
general_track = next((t for t in self.media_info.tracks if t.track_type == 'General'), None)
if not general_track:
return None
format_ = getattr(general_track, 'format', None)
general_track = self._get_track(track_type="General", track_id=0)
format_ = getattr(general_track, "format", None)
if not format_:
return None
@@ -286,20 +379,24 @@ class MediaInfoExtractor:
ext = get_extension_from_format(format_)
# Special case: Matroska 3D uses mk3d extension
if ext == 'mkv' and self.is_3d():
return 'mk3d'
if ext == "mkv" and self.is_3d():
return "mk3d"
return ext
@cached_method()
@requires_tracks_type("Video")
def extract_3d_layout(self) -> str | None:
"""Extract 3D stereoscopic layout from MediaInfo"""
if not self.is_3d():
return None
stereoscopic = getattr(self.video_tracks[0], 'stereoscopic', None)
tracks = self._get_tracks(track_type="Video")
# Type assertion: decorator guarantees tracks is a list
assert isinstance(tracks, list)
track = tracks[0]
stereoscopic = getattr(track, "stereoscopic", None)
return stereoscopic if stereoscopic else None
@cached_method()
@requires_tracks_type("Video")
def extract_interlaced(self) -> bool | None:
"""Determine if the video is interlaced.
@@ -308,39 +405,77 @@ class MediaInfoExtractor:
False: Video is progressive (explicitly set)
None: Information not available in MediaInfo
"""
if not self.video_tracks:
logger.debug(f"[{self.file_path.name}] Interlaced detection: No video tracks")
return None
scan_type_attr = getattr(self.video_tracks[0], 'scan_type', None)
interlaced = getattr(self.video_tracks[0], 'interlaced', None)
tracks = self._get_tracks(track_type="Video")
# Type assertion: decorator guarantees tracks is a list
assert isinstance(tracks, list)
track = tracks[0]
scan_type_attr = getattr(track, "scan_type", None)
interlaced = getattr(track, "interlaced", None)
scan_order = getattr(track, "scan_order", None)
logger.debug(f"[{self.file_path.name}] Interlaced detection:")
logger.debug(f"[{self.file_path.name}] scan_type: {scan_type_attr!r} (type: {type(scan_type_attr).__name__})")
logger.debug(f"[{self.file_path.name}] interlaced: {interlaced!r} (type: {type(interlaced).__name__})")
logger.debug(
f"[{self.file_path.name}] scan_type: {scan_type_attr!r} (type: {type(scan_type_attr).__name__})"
)
logger.debug(
f"[{self.file_path.name}] interlaced: {interlaced!r} (type: {type(interlaced).__name__})"
)
logger.debug(
f"[{self.file_path.name}] scan_order: {scan_order!r} (type: {type(scan_order).__name__})"
)
# Check scan_type attribute first (e.g., "Interlaced", "Progressive", "MBAFF")
if scan_type_attr and isinstance(scan_type_attr, str):
scan_lower = scan_type_attr.lower()
if 'interlaced' in scan_lower or 'mbaff' in scan_lower:
logger.debug(f"[{self.file_path.name}] Result: True (from scan_type={scan_type_attr!r})")
if "interlaced" in scan_lower or "mbaff" in scan_lower:
logger.debug(
f"[{self.file_path.name}] Result: True (from scan_type={scan_type_attr!r})"
)
return True
elif 'progressive' in scan_lower:
logger.debug(f"[{self.file_path.name}] Result: False (from scan_type={scan_type_attr!r})")
elif "progressive" in scan_lower:
logger.debug(
f"[{self.file_path.name}] Result: False (from scan_type={scan_type_attr!r})"
)
return False
# If scan_type has some other value, fall through to check interlaced
logger.debug(f"[{self.file_path.name}] scan_type unrecognized, checking interlaced attribute")
# If scan_type has some other value, fall through to check other attributes
logger.debug(
f"[{self.file_path.name}] scan_type unrecognized, checking other attributes"
)
# Check scan_order attribute (e.g., "TFF", "BFF" for interlaced, "Progressive" for progressive)
if scan_order and isinstance(scan_order, str):
scan_order_upper = scan_order.upper()
if scan_order_upper in ["TFF", "BFF"]:
logger.debug(
f"[{self.file_path.name}] Result: True (from scan_order={scan_order!r})"
)
return True
elif scan_order_upper == "PROGRESSIVE":
logger.debug(
f"[{self.file_path.name}] Result: False (from scan_order={scan_order!r})"
)
return False
# If scan_order has some other value, fall through
logger.debug(
f"[{self.file_path.name}] scan_order unrecognized, checking interlaced attribute"
)
# Check interlaced attribute (e.g., "Yes", "No")
if interlaced and isinstance(interlaced, str):
interlaced_lower = interlaced.lower()
if interlaced_lower in ['yes', 'true', '1']:
logger.debug(f"[{self.file_path.name}] Result: True (from interlaced={interlaced!r})")
if interlaced_lower in ["yes", "true", "1"]:
logger.debug(
f"[{self.file_path.name}] Result: True (from interlaced={interlaced!r})"
)
return True
elif interlaced_lower in ['no', 'false', '0']:
logger.debug(f"[{self.file_path.name}] Result: False (from interlaced={interlaced!r})")
elif interlaced_lower in ["no", "false", "0"]:
logger.debug(
f"[{self.file_path.name}] Result: False (from interlaced={interlaced!r})"
)
return False
# No information available
logger.debug(f"[{self.file_path.name}] Result: None (no information available)")
logger.debug(
f"[{self.file_path.name}] Result: None (no information available)"
)
return None
+2 -2
View File
@@ -42,7 +42,7 @@ class MetadataExtractor:
self.file_path = file_path
self.cache = Cache() if use_cache else None # Singleton cache for @cached_method decorator
self.settings = None # Will be set by Settings singleton if needed
self._cache: dict[str, any] = {} # Internal cache for method results
self._cache: dict = {} # Internal cache for method results
try:
self.info = mutagen.File(file_path) # type: ignore
except Exception as e:
@@ -114,4 +114,4 @@ class MetadataExtractor:
return 'Unknown'
except Exception as e:
logger.debug(f"Failed to detect MIME type for {self.file_path}: {e}")
return 'Unknown'
return 'Unknown'