9 Commits
11 changed files with 130 additions and 32 deletions
+6 -1
View File
@@ -647,7 +647,12 @@ except (LookupError, ValueError, AttributeError) as e:
1. **Read Before Modify**: Always read files before suggesting modifications
2. **Follow Existing Patterns**: Understand established architecture before changes
3. **Test Everything**: Run `uv run pytest` after all changes
3. **Run CI Checks After Every Code Change**: After any code modification, run the same checks as GitHub CI:
```bash
uv run pytest # all tests must pass
uv run mypy src/ --ignore-missing-imports # no type errors
```
Do NOT consider work complete until both commands succeed.
4. **Simplicity First**: Avoid over-engineering solutions
5. **Document Changes**: Update relevant documentation
+3 -3
View File
@@ -28,7 +28,7 @@ powershell -c "irm https://astral.sh/uv/install.sh | iex"
uv tool install https://github.com/shadoll/moma/releases/latest/download/moma-latest.tar.gz
# Specific version
uv tool install https://github.com/shadoll/moma/releases/download/v0.9.4/moma-0.9.4-py3-none-any.whl
uv tool install https://github.com/shadoll/moma/releases/download/v0.9.9/moma-0.9.9-py3-none-any.whl
# From PyPI (when published)
uv tool install moma
@@ -40,7 +40,7 @@ uv tool install moma
uv tool install --force https://github.com/shadoll/moma/releases/latest/download/moma-latest.tar.gz
# Upgrade to a newer specific version
uv tool install --force https://github.com/shadoll/moma/releases/download/v0.9.4/moma-0.9.4-py3-none-any.whl
uv tool install --force https://github.com/shadoll/moma/releases/download/v0.9.9/moma-0.9.9-py3-none-any.whl
```
#### Usage
@@ -56,7 +56,7 @@ moma /path/to/directory # Scan specific directory
pip install https://github.com/shadoll/moma/releases/latest/download/moma-latest.tar.gz
# Specific version
pip install https://github.com/shadoll/moma/releases/download/v0.9.4/moma-0.9.4-py3-none-any.whl
pip install https://github.com/shadoll/moma/releases/download/v0.9.9/moma-0.9.9-py3-none-any.whl
```
### Method 3: Development Installation
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "moma"
version = "0.9.4"
version = "0.9.9"
description = "Terminal-based media file renamer and metadata viewer"
readme = "README.md"
requires-python = ">=3.12"
+53 -15
View File
@@ -4,6 +4,7 @@ from textual.containers import Horizontal, Container, ScrollableContainer, Verti
from textual.widget import Widget
from textual.command import Provider, Hit
from rich.markup import escape
from rich.text import Text
from pathlib import Path
from functools import partial
from typing import TYPE_CHECKING, cast, Any
@@ -14,6 +15,7 @@ from .logging_config import LoggerConfig # Initialize logging singleton
from .constants import MEDIA_TYPES
from .views import OpenScreen, HelpScreen, RenameConfirmScreen, SettingsScreen, ConvertConfirmScreen, DeleteConfirmScreen
from .extractors.extractor import MediaExtractor
from .extractors.filename_extractor import FilenameExtractor
from .views import MediaPanelView, ProposedFilenameView
from .formatters.text_formatter import TextFormatter
from .formatters.catalog_formatter import CatalogFormatter
@@ -211,18 +213,56 @@ class MomaApp(App):
icons = {
'mkv': '🎥', # Video camera for MKV
'mk3d': '🕹️', # Clapper board for 3D
'mp4': '🎥', # Video camera
'mov': '🎥', # Video camera
'webm': '🎥', # Video camera
'avi': '💿', # Film frames for AVI
'wmv': '📀', # Video camera
'mp4': '🌐', # Web
'mov': '📽️', # Video camera
'webm': '🌐', # Web
'avi': '💿', # Silver compact disk
'wmv': '📀', # Gold compact disk
'm4v': '📹', # Video camera
'mpg': '📼', # Video camera
'mpeg': '📼', # Video camera
'mpg': '📼', # Video cassette
'mpeg': '📼', # Video cassette
}
return icons.get(ext, '📄') # Default to document icon
@staticmethod
def _frame_class_color(frame_class: str | None) -> str | None:
"""Return a Rich colour name for a frame class, or None if no highlight needed."""
if not frame_class:
return None
fc = frame_class.lower()
if fc in ("4320p",):
return "bright_green"
if fc in ("2160p",):
return "green"
if fc in ("1440p", "1080p", "1080i"):
return "yellow1"
if fc in ("720p",):
return "orange1"
# 576p, 480p, 480i, 360p and anything lower
return "red"
def _make_file_label(self, file_path: Path) -> Text:
"""Build a Rich Text label with resolution highlighted by quality tier."""
import re
icon = self._get_file_icon(file_path)
name = file_path.name
frame_class = FilenameExtractor(file_path, use_cache=False).extract_frame_class()
color = self._frame_class_color(frame_class)
label = Text(f"{icon} ")
if color and frame_class:
# Highlight the exact resolution token (e.g. "1080p") in the filename
m = re.search(re.escape(frame_class), name, re.IGNORECASE)
if m:
label.append(name[:m.start()])
label.append(name[m.start():m.end()], style=color)
label.append(name[m.end():])
return label
label.append(name)
return label
def build_tree(self, path: Path, node):
try:
for item in sorted(path.iterdir()):
@@ -231,16 +271,14 @@ class MomaApp(App):
if item.name.startswith(".") or item.name == "lost+found":
continue
# Add folder icon before directory name
label = f"󰉋 {escape(item.name)}"
subnode = node.add(label, data=item)
dir_label: str = f"󰉋 {escape(item.name)}"
subnode = node.add(dir_label, data=item)
self.build_tree(item, subnode)
elif item.is_file() and item.suffix.lower() in {
f".{ext}" for ext in MEDIA_TYPES
}:
# Add file type icon before filename
icon = self._get_file_icon(item)
label = f"{icon} {escape(item.name)}"
node.add(label, data=item)
# Add file type icon before filename with resolution colour
node.add(self._make_file_label(item), data=item)
except PermissionError:
pass
except PermissionError:
@@ -645,7 +683,7 @@ By Category:"""
logging.info(f"Found node for {old_path}, updating to {new_path.name}")
# Update label with icon
icon = self._get_file_icon(new_path)
node.label = f"{icon} {escape(new_path.name)}"
node.label = self._make_file_label(new_path) # type: ignore[assignment]
node.data = new_path
logging.info(f"After update: node.data = {node.data}, node.label = {node.label}")
# Ensure cursor stays on the renamed file
@@ -708,7 +746,7 @@ By Category:"""
# Get icon for the file
icon = self._get_file_icon(file_path)
label = f"{icon} {escape(file_path.name)}"
label = self._make_file_label(file_path)
# Add the new file node in alphabetically sorted position
new_node = None
+1
View File
@@ -123,6 +123,7 @@ class MediaExtractor:
"3d_layout": {
"sources": [
("MediaInfo", "extract_3d_layout"),
("Filename", "extract_3d_layout"),
("Default", "extract_3d_layout"),
],
},
+30 -4
View File
@@ -227,10 +227,9 @@ class FilenameExtractor:
# Check for bare resolution numbers inside brackets (e.g., [720,ukr,eng])
bare_match = re.search(r'[\[,](\d{3,4})(?=[,\]])', normalized_name, re.IGNORECASE)
if bare_match:
height = int(bare_match.group(1))
frame_class = self._get_frame_class_from_height(height)
if frame_class:
return frame_class
bare_fc = self._get_frame_class_from_height(int(bare_match.group(1)))
if bare_fc:
return bare_fc
# If no specific resolution found, check for non-standard quality indicators
for indicator in NON_STANDARD_QUALITY_INDICATORS:
@@ -252,6 +251,33 @@ class FilenameExtractor:
return None
@cached_method()
def extract_3d_layout(self) -> str | None:
"""Extract 3D stereoscopic layout from filename or extension"""
# Ordered by specificity: most specific patterns first
patterns = [
(r'\b3D-SBS\b', '3D-SBS'),
(r'\b3D-OU\b', '3D-OU'),
(r'\b3D-HSBS\b', '3D-HSBS'),
(r'\b3D-HOU\b', '3D-HOU'),
(r'\bHalf[-.]?SBS\b', '3D-HSBS'),
(r'\bHalf[-.]?OU\b', '3D-HOU'),
(r'\bHSBS\b', '3D-HSBS'),
(r'\bHOU\b', '3D-HOU'),
(r'\bSBS\b', '3D-SBS'),
(r'\bOU\b', '3D-OU'),
(r'\b3D\b', '3D'),
]
for pattern, result in patterns:
if re.search(pattern, self.file_name, re.IGNORECASE):
return result
# Fall back to mk3d extension as a 3D indicator
if self.file_path.suffix.lower() == '.mk3d':
return '3D'
return None
@cached_method()
def extract_movie_db(self) -> list[str] | None:
"""Extract movie database identifier from filename"""
+20 -5
View File
@@ -195,7 +195,7 @@ class MediaInfoExtractor:
resolution = self.extract_resolution()
if not resolution:
return None
height, width = resolution
width, height = resolution
logger.debug(
f"[{self.file_path.name}] Frame class detection - Resolution: {width}x{height}"
@@ -213,7 +213,8 @@ class MediaInfoExtractor:
# 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"
scan_lower = scan_type_attr.lower()
scan_type = "i" if ("interlaced" in scan_lower or "mbaff" in scan_lower) else "p"
logger.debug(
f"[{self.file_path.name}] Using scan_type: {scan_type_attr!r} -> scan_type={scan_type!r}"
)
@@ -249,11 +250,14 @@ class MediaInfoExtractor:
effective_height = height
# First, try to match width to typical widths
# Use a larger tolerance (10 pixels) to handle cinema/ultrawide aspect ratios
# Use proportional tolerance (2% of typical width, min 10px) to handle
# cinema/ultrawide aspect ratios where encoded width may differ slightly
# (e.g. 3820×1592 scope 4K → 2160p, not a non-standard 1592p)
width_matches = []
for frame_class, info in FRAME_CLASSES.items():
for tw in info["typical_widths"]:
if abs(width - tw) <= 10 and frame_class.endswith(scan_type):
width_tolerance = max(10, int(tw * 0.02))
if abs(width - tw) <= width_tolerance and frame_class.endswith(scan_type):
diff = abs(height - info["nominal_height"])
width_matches.append((frame_class, diff))
@@ -329,7 +333,10 @@ class MediaInfoExtractor:
return None
langs = []
for a in tracks:
lang_code = getattr(a, "language", "und") or "und"
lang_code = getattr(a, "language", None)
# Skip tracks with no language tag or 'und' (undetermined)
if not lang_code or lang_code.lower() in ("und", "undefined"):
continue
try:
# Try to get the 3-letter code
lang_obj = langcodes.Language.get(lang_code.lower())
@@ -340,6 +347,9 @@ class MediaInfoExtractor:
logger.debug(f"Invalid language code '{lang_code}': {e}")
langs.append(lang_code.lower()[:3])
if not langs:
return None # No meaningful language info — let Filename extractor try
lang_counts = Counter(langs)
audio_langs = [
f"{count}{lang}" if count > 1 else lang
@@ -379,6 +389,11 @@ class MediaInfoExtractor:
# Use the constants function to get extension from format
ext = get_extension_from_format(format_)
# Preserve mk3d extension when the source file already has it,
# even if MediaInfo doesn't report stereoscopic metadata
if self.file_path.suffix.lower() == '.mk3d':
return 'mk3d'
# Special case: Matroska 3D uses mk3d extension
if ext == "mkv" and self.is_3d():
return "mk3d"
@@ -154,5 +154,12 @@
"interlaced": false,
"expected_frame_class": "1080p",
"testname": "test-mistakenly-high-height-2"
},
{
"testname": "test-2160p-scope-240",
"resolution": [3820, 1592],
"interlaced": false,
"expected_frame_class": "2160p",
"description": "4K cinema scope 2.40:1 - width slightly under 3840, height non-standard 1592"
}
]
+1 -1
View File
@@ -39,7 +39,7 @@ def test_frame_class_detection(test_case):
extractor.video_tracks = [mock_track]
extractor._get_tracks.return_value = [mock_track] # satisfies @requires_tracks_type decorator
extractor._get_track.return_value = mock_track
extractor.extract_resolution.return_value = (height, width)
extractor.extract_resolution.return_value = (width, height)
extractor.extract_interlaced.return_value = interlaced
# Test the method
+7 -1
View File
@@ -48,6 +48,12 @@ class ProposedFilenameView:
"""Get the frame class"""
return self._extractor.get("frame_class") or ""
@property
@conditional_decorators.wrap(",")
def _3d_layout(self) -> str:
"""Get the 3D layout formatted with a leading comma if present"""
return self._extractor.get("3d_layout")
@property
@conditional_decorators.wrap(",")
def _hdr(self) -> str:
@@ -81,7 +87,7 @@ class ProposedFilenameView:
@property
def rename_line(self) -> str:
"""Generate the proposed filename."""
result = f"{self._order}{self._title}{self._year}{self._special_info}{self._source} [{self._frame_class}{self._hdr},{self._audio_langs}]{self._db_info}.{self._extension}"
result = f"{self._order}{self._title}{self._year}{self._special_info}{self._source} [{self._frame_class}{self._3d_layout}{self._hdr},{self._audio_langs}]{self._db_info}.{self._extension}"
return result.replace("/", "-").replace("\\", "-")
def rename_line_formatted(self, file_path) -> str:
Generated
+1 -1
View File
@@ -208,7 +208,7 @@ wheels = [
[[package]]
name = "moma"
version = "0.9.3"
version = "0.9.9"
source = { editable = "." }
dependencies = [
{ name = "langcodes" },