mirror of
https://github.com/shadoll/moma.git
synced 2026-08-28 11:33:25 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dfe3f9fe11 | ||
|
|
27fae023bf | ||
|
|
d3d5a8c5c1 | ||
|
|
4b90abb457 | ||
|
|
b3c8580789 | ||
|
|
d26cbacc03 | ||
|
|
1f87337bb4 |
@@ -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
@@ -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.8/moma-0.9.8-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.8/moma-0.9.8-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.8/moma-0.9.8-py3-none-any.whl
|
||||
```
|
||||
|
||||
### Method 3: Development Installation
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "moma"
|
||||
version = "0.9.4"
|
||||
version = "0.9.8"
|
||||
description = "Terminal-based media file renamer and metadata viewer"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
|
||||
+53
-15
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user