12 Commits
14 changed files with 294 additions and 57 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 1. **Read Before Modify**: Always read files before suggesting modifications
2. **Follow Existing Patterns**: Understand established architecture before changes 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 4. **Simplicity First**: Avoid over-engineering solutions
5. **Document Changes**: Update relevant documentation 5. **Document Changes**: Update relevant documentation
+1 -1
View File
@@ -11,7 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- **GitHub Actions CI** (`ci.yml`): runs tests + mypy on Python 3.12 for every push to `main` and all PRs - **GitHub Actions CI** (`ci.yml`): runs tests + mypy on Python 3.12 for every push to `main` and all PRs
- **GitHub Actions Release** (`release.yml`): triggered by `v*.*.*` tags — runs tests, builds wheel + tarball, publishes packages as GitHub Release assets (`moma-X.Y.Z.whl`, `moma-latest.whl`, etc.) - **GitHub Actions Release** (`release.yml`): triggered by `v*.*.*` tags — runs tests, builds wheel + tarball, publishes packages as GitHub Release assets (`moma-X.Y.Z-py3-none-any.whl`, `moma-X.Y.Z.tar.gz`, `moma-latest.tar.gz`)
- **TMDB credentials in Settings**: `tmdb_api_key` and `tmdb_access_token` are now stored in `~/.config/moma/config.json` and editable via the Settings screen (`p`) - **TMDB credentials in Settings**: `tmdb_api_key` and `tmdb_access_token` are now stored in `~/.config/moma/config.json` and editable via the Settings screen (`p`)
### Changed ### Changed
+16 -10
View File
@@ -24,19 +24,25 @@ powershell -c "irm https://astral.sh/uv/install.sh | iex"
#### Install moma #### Install moma
```bash ```bash
# Always-latest stable URL (recommended) # Always-latest (recommended, no version to update)
uv tool install https://github.com/shadoll/moma/releases/latest/download/moma-latest.whl uv tool install https://github.com/shadoll/moma/releases/latest/download/moma-latest.tar.gz
# Specific version # Specific version
uv tool install https://github.com/shadoll/moma/releases/download/v0.8.11/moma-0.8.11.whl uv tool install https://github.com/shadoll/moma/releases/download/v0.9.5/moma-0.9.5-py3-none-any.whl
# From a locally downloaded wheel
uv tool install moma-latest.whl
# From PyPI (when published) # From PyPI (when published)
uv tool install moma uv tool install moma
``` ```
#### Reinstall / Upgrade moma
```bash
# Reinstall (same URL — force overwrite)
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.5/moma-0.9.5-py3-none-any.whl
```
#### Usage #### Usage
```bash ```bash
moma # Scan current directory moma # Scan current directory
@@ -46,11 +52,11 @@ moma /path/to/directory # Scan specific directory
### Method 2: pip Install from Wheel ### Method 2: pip Install from Wheel
```bash ```bash
# Always-latest stable URL # Always-latest (recommended, no version to update)
pip install https://github.com/shadoll/moma/releases/latest/download/moma-latest.whl pip install https://github.com/shadoll/moma/releases/latest/download/moma-latest.tar.gz
# From a locally downloaded wheel # Specific version
pip install moma-latest.whl pip install https://github.com/shadoll/moma/releases/download/v0.9.5/moma-0.9.5-py3-none-any.whl
``` ```
### Method 3: Development Installation ### Method 3: Development Installation
+106
View File
@@ -0,0 +1,106 @@
# Bump patch version in pyproject.toml and INSTALL.md
bump:
#!/usr/bin/env python3
import re
with open('pyproject.toml', 'r') as f:
content = f.read()
m = re.search(r'version = "(\d+)\.(\d+)\.(\d+)"', content)
if m:
old = m.group(1) + '.' + m.group(2) + '.' + m.group(3)
major, minor, patch = map(int, m.groups())
new = f'{major}.{minor}.{patch + 1}'
with open('pyproject.toml', 'w') as f:
f.write(content.replace(m.group(0), f'version = "{new}"'))
with open('INSTALL.md', 'r') as f:
install = f.read()
with open('INSTALL.md', 'w') as f:
f.write(install.replace(old, new))
print(f'Version bumped to {new}')
else:
print('Version not found')
# Bump version, sync dependencies, and build package
release: bump
uv sync
uv build
@echo "Release process completed successfully!"
# Run tests
test:
uv run pytest
# Tag the current version from pyproject.toml and push to origin
git-release:
#!/usr/bin/env python3
import re
import subprocess
import sys
def read_version():
with open('pyproject.toml', 'r') as f:
content = f.read()
m = re.search(r'version = "(\d+\.\d+\.\d+)"', content)
return m.group(1) if m else None
def tag_exists(tag):
local = subprocess.run(['git', 'tag', '-l', tag], capture_output=True, text=True)
if local.stdout.strip():
return True
remote = subprocess.run(['git', 'ls-remote', '--tags', 'origin', f'refs/tags/{tag}'], capture_output=True, text=True)
return bool(remote.stdout.strip())
def do_bump():
with open('pyproject.toml', 'r') as f:
content = f.read()
m = re.search(r'version = "(\d+)\.(\d+)\.(\d+)"', content)
if not m:
print('Version not found in pyproject.toml')
sys.exit(1)
old = m.group(0).split('"')[1]
major, minor, patch = map(int, m.groups())
new = f'{major}.{minor}.{patch + 1}'
with open('pyproject.toml', 'w') as f:
f.write(content.replace(m.group(0), f'version = "{new}"'))
with open('INSTALL.md', 'r') as f:
install = f.read()
with open('INSTALL.md', 'w') as f:
f.write(install.replace(old, new))
print(f'Version bumped to {new}')
return new
version = read_version()
if not version:
print('Could not read version from pyproject.toml')
sys.exit(1)
tag = f'v{version}'
if tag_exists(tag):
print(f'Tag {tag} already exists.')
print(' [c] Cancel')
print(' [b] Bump version and continue')
print(' [r] Replace tag (delete and re-create)')
choice = input('Your choice (c/b/r): ').strip().lower()
if choice == 'c' or choice == '':
print('Cancelled.')
sys.exit(0)
elif choice == 'b':
version = do_bump()
tag = f'v{version}'
elif choice == 'r':
print(f'Deleting tag {tag} locally and on origin...')
subprocess.run(['git', 'tag', '-d', tag])
subprocess.run(['git', 'push', 'origin', f':refs/tags/{tag}'])
else:
print('Unknown choice, cancelling.')
sys.exit(1)
print(f'Creating tag {tag}...')
subprocess.run(['git', 'tag', tag], check=True)
print(f'Pushing tag {tag} to origin...')
subprocess.run(['git', 'push', 'origin', tag], check=True)
print(f'Done! Tag {tag} pushed.')
# Run the application
run *ARGS:
uv run moma {{ARGS}}
+10 -4
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "moma" name = "moma"
version = "0.9.0" version = "0.9.5"
description = "Terminal-based media file renamer and metadata viewer" description = "Terminal-based media file renamer and metadata viewer"
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
@@ -22,9 +22,15 @@ dev = [
] ]
[project.scripts] [project.scripts]
moma = "main:main" moma = "src.main:main"
bump-version = "bump:main"
release = "release:main" [build-system]
requires = ["setuptools>=61"]
build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
where = ["."]
include = ["src*"]
[tool.uv] [tool.uv]
package = true package = true
+3 -3
View File
@@ -211,9 +211,9 @@ class MomaApp(App):
icons = { icons = {
'mkv': '🎥', # Video camera for MKV 'mkv': '🎥', # Video camera for MKV
'mk3d': '🕹️', # Clapper board for 3D 'mk3d': '🕹️', # Clapper board for 3D
'mp4': '🎥', # Video camera 'mp4': '🌐', # Web
'mov': '🎥', # Video camera 'mov': '📽️', # Video camera
'webm': '🎥', # Video camera 'webm': '🌐', # Web
'avi': '💿', # Film frames for AVI 'avi': '💿', # Film frames for AVI
'wmv': '📀', # Video camera 'wmv': '📀', # Video camera
'm4v': '📹', # Video camera 'm4v': '📹', # Video camera
+7
View File
@@ -4,12 +4,19 @@ def main():
content = f.read() content = f.read()
match = re.search(r'version = "(\d+)\.(\d+)\.(\d+)"', content) match = re.search(r'version = "(\d+)\.(\d+)\.(\d+)"', content)
if match: if match:
old_version = match.group(1) + '.' + match.group(2) + '.' + match.group(3)
major, minor, patch = map(int, match.groups()) major, minor, patch = map(int, match.groups())
patch += 1 patch += 1
new_version = f'{major}.{minor}.{patch}' new_version = f'{major}.{minor}.{patch}'
content = content.replace(match.group(0), f'version = "{new_version}"') content = content.replace(match.group(0), f'version = "{new_version}"')
with open('pyproject.toml', 'w') as f: with open('pyproject.toml', 'w') as f:
f.write(content) f.write(content)
# Update version references in INSTALL.md
with open('INSTALL.md', 'r') as f:
install = f.read()
install = install.replace(old_version, new_version)
with open('INSTALL.md', 'w') as f:
f.write(install)
print(f'Version bumped to {new_version}') print(f'Version bumped to {new_version}')
else: else:
print('Version not found') print('Version not found')
+49 -22
View File
@@ -64,19 +64,26 @@ class FilenameExtractor:
if dot_match: if dot_match:
year_pos = dot_match.start() year_pos = dot_match.start()
else: else:
# Last resort: any 4-digit number # Try year between mixed separators (like .1967_ or _1967.)
any_match = re.search(r'\b(\d{4})\b', self.file_name) sep_match = re.search(r'(?<=[.\-_\s])(\d{4})(?=[.\-_\s])', self.file_name)
if any_match: if sep_match:
year = int(any_match.group(1)) year_val = int(sep_match.group(1))
# Basic sanity check using constants if is_valid_year(year_val):
if is_valid_year(year): year_pos = sep_match.start(1)
year_pos = any_match.start() # Cut before the year for plain years else:
# Last resort: any 4-digit number
any_match = re.search(r'\b(\d{4})\b', self.file_name)
if any_match:
year = int(any_match.group(1))
# Basic sanity check using constants
if is_valid_year(year):
year_pos = any_match.start() # Cut before the year for plain years
# Find source position # Find source position
source = self.extract_source() source = self.extract_source()
if source: if source:
for alias in SOURCE_DICT[source]: for alias in SOURCE_DICT[source]:
match = re.search(r'\b' + re.escape(alias) + r'\b', self.file_name, re.IGNORECASE) match = re.search(r'(?<![a-zA-Z])' + re.escape(alias) + r'(?![a-zA-Z])', self.file_name, re.IGNORECASE)
if match: if match:
source_pos = match.start() source_pos = match.start()
break break
@@ -108,26 +115,23 @@ class FilenameExtractor:
# Remove bracketed prefixes like [01.1], [1], etc. # Remove bracketed prefixes like [01.1], [1], etc.
title = re.sub(r'^\s*\[[^\]]+\]\s*', '', title) title = re.sub(r'^\s*\[[^\]]+\]\s*', '', title)
# Remove order number prefixes like 01., 1., 1.1 followed by space/underscore # Remove order prefix (order followed by dot or space)
# Only remove if the number is multi-digit or has decimal (to avoid removing single digit titles)
match = re.match(r'^\s*(\d+(?:\.\d+)?)\.(?=\s|_)', title)
if match:
order = match.group(1)
if len(order) > 1 or '.' in order:
title = re.sub(r'^\s*(\d+(?:\.\d+)?)\.(?=\s|_)', '', title)
# Remove order like 1.9 where 1 is order, 9 is title
order = self.extract_order() order = self.extract_order()
if order: if order:
match = re.match(r'^' + re.escape(order) + r'\.(.+)', title) match = re.match(r'^' + re.escape(order) + r'[.\s]+(.+)', title)
if match: if match:
title = match.group(1) title = match.group(1)
# Clean up any remaining leading separators # Clean up any remaining leading separators
title = title.lstrip('_ \t') title = title.lstrip('_ \t')
# Clean up title: remove leading/trailing brackets and dots # Clean up title: remove leading/trailing brackets and orphaned dots
title = title.strip('[](). ') title = title.strip('[]. ')
# Only strip unmatched leading/trailing parens
if title.endswith(')') and title.count('(') < title.count(')'):
title = title.rstrip(')')
if title.startswith('(') and title.count('(') > title.count(')'):
title = title.lstrip('(')
# Replace dots with spaces if they appear to be word separators # Replace dots with spaces if they appear to be word separators
# Only replace dots that are surrounded by letters/digits (not at edges) # Only replace dots that are surrounded by letters/digits (not at edges)
@@ -151,6 +155,13 @@ class FilenameExtractor:
if dot_match: if dot_match:
return dot_match.group(1) return dot_match.group(1)
# Try year between mixed separators (like .1967_ or _1967.)
sep_match = re.search(r'(?<=[.\-_\s])(\d{4})(?=[.\-_\s])', self.file_name)
if sep_match:
year = int(sep_match.group(1))
if is_valid_year(year):
return str(year)
# Last resort: any 4-digit number (but this is less reliable) # Last resort: any 4-digit number (but this is less reliable)
any_match = re.search(r'\b(\d{4})\b', self.file_name) any_match = re.search(r'\b(\d{4})\b', self.file_name)
if any_match: if any_match:
@@ -213,6 +224,13 @@ class FilenameExtractor:
# Fallback to height-based if not in constants # Fallback to height-based if not in constants
return self._get_frame_class_from_height(height) return self._get_frame_class_from_height(height)
# 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:
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 # If no specific resolution found, check for non-standard quality indicators
for indicator in NON_STANDARD_QUALITY_INDICATORS: for indicator in NON_STANDARD_QUALITY_INDICATORS:
if re.search(r'\b' + re.escape(indicator) + r'\b', self.file_name, re.IGNORECASE): if re.search(r'\b' + re.escape(indicator) + r'\b', self.file_name, re.IGNORECASE):
@@ -334,8 +352,17 @@ class FilenameExtractor:
# Remove bracketed content first # Remove bracketed content first
text_without_brackets = re.sub(r'\[([^\]]+)\]', '', self.file_name) text_without_brackets = re.sub(r'\[([^\]]+)\]', '', self.file_name)
# Split on dots, spaces, and underscores # Find start of metadata section (after title) to avoid title words being
parts = re.split(r'[.\s_]+', text_without_brackets) # misdetected as language codes (e.g. "War" from "The.War.Wagon")
metadata_start = 0
year_m = (re.search(r'\(\d{4}\)', text_without_brackets) or
re.search(r'\.\d{4}\.', text_without_brackets) or
re.search(r'(?<=[.\-_\s])\d{4}(?=[.\-_\s])', text_without_brackets))
if year_m:
metadata_start = year_m.start()
# Split on dots, spaces, and underscores (only in the post-title portion)
parts = re.split(r'[.\s_]+', text_without_brackets[metadata_start:])
for part in parts: for part in parts:
part = part.strip() part = part.strip()
+8 -2
View File
@@ -195,7 +195,7 @@ class MediaInfoExtractor:
resolution = self.extract_resolution() resolution = self.extract_resolution()
if not resolution: if not resolution:
return None return None
height, width = resolution width, height = resolution
logger.debug( logger.debug(
f"[{self.file_path.name}] Frame class detection - Resolution: {width}x{height}" f"[{self.file_path.name}] Frame class detection - Resolution: {width}x{height}"
@@ -329,7 +329,10 @@ class MediaInfoExtractor:
return None return None
langs = [] langs = []
for a in tracks: 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:
# Try to get the 3-letter code # Try to get the 3-letter code
lang_obj = langcodes.Language.get(lang_code.lower()) lang_obj = langcodes.Language.get(lang_code.lower())
@@ -340,6 +343,9 @@ class MediaInfoExtractor:
logger.debug(f"Invalid language code '{lang_code}': {e}") logger.debug(f"Invalid language code '{lang_code}': {e}")
langs.append(lang_code.lower()[:3]) langs.append(lang_code.lower()[:3])
if not langs:
return None # No meaningful language info — let Filename extractor try
lang_counts = Counter(langs) lang_counts = Counter(langs)
audio_langs = [ audio_langs = [
f"{count}{lang}" if count > 1 else lang f"{count}{lang}" if count > 1 else lang
@@ -2,6 +2,24 @@
"description": "Comprehensive test dataset for filename metadata extraction", "description": "Comprehensive test dataset for filename metadata extraction",
"version": "2.0", "version": "2.0",
"test_cases": [ "test_cases": [
{
"filename": "The.War.Wagon.1967_BDRip Ukr_Eng[Hurtom].mkv",
"expected": {
"order": null,
"title": "The War Wagon",
"year": "1967",
"source": "BDRip",
"frame_class": null,
"hdr": null,
"movie_db": null,
"special_info": null,
"audio_langs": "ukr,eng",
"extension": "mkv"
},
"testname": "edge-multi-lang-001",
"category": "edge_cases",
"description": "Multiple languages without brackets"
},
{ {
"filename": "Le Jaguar.(1996).[1080i,3ukr,fra].mkv", "filename": "Le Jaguar.(1996).[1080i,3ukr,fra].mkv",
"expected": { "expected": {
@@ -799,7 +817,7 @@
"filename": "Movie.Title (2020) BDRip [1080p,ukr,eng].mkv", "filename": "Movie.Title (2020) BDRip [1080p,ukr,eng].mkv",
"expected": { "expected": {
"order": null, "order": null,
"title": "Movie.Title", "title": "Movie Title",
"year": "2020", "year": "2020",
"source": "BDRip", "source": "BDRip",
"frame_class": "1080p", "frame_class": "1080p",
@@ -810,7 +828,7 @@
"extension": "mkv" "extension": "mkv"
}, },
"category": "edge_cases", "category": "edge_cases",
"description": "Title with dots" "description": "Title with dot separator (dot replaced with space by extractor)"
}, },
{ {
"testname": "edge-no-brackets-001", "testname": "edge-no-brackets-001",
+62
View File
@@ -15,6 +15,22 @@ def load_test_filenames():
return [] return []
def load_test_cases():
"""Load full test cases (testname, filename, expected) from dataset"""
dataset_file = Path(__file__).parent / "datasets" / "filenames" / "filename_patterns.json"
if dataset_file.exists():
with open(dataset_file, 'r', encoding='utf-8') as f:
data = json.load(f)
return [
pytest.param(
case['filename'],
case['expected'],
id=case.get('testname', case['filename'])
)
for case in data['test_cases']
]
return []
@pytest.mark.parametrize("filename", load_test_filenames()) @pytest.mark.parametrize("filename", load_test_filenames())
def test_extract_title(filename): def test_extract_title(filename):
"""Test title extraction from filename""" """Test title extraction from filename"""
@@ -129,3 +145,49 @@ def test_extract_audio_tracks(filename):
for track in audio_tracks: for track in audio_tracks:
assert isinstance(track, dict) assert isinstance(track, dict)
assert 'language' in track assert 'language' in track
# ---------------------------------------------------------------------------
# Dataset-based value-checking tests (check against filename_patterns.json)
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("filename,expected", load_test_cases())
def test_expected_title(filename, expected):
"""Test that extracted title matches the expected value from dataset."""
extractor = FilenameExtractor(Path(filename), use_cache=False)
assert extractor.extract_title() == expected['title']
@pytest.mark.parametrize("filename,expected", load_test_cases())
def test_expected_year(filename, expected):
"""Test that extracted year matches the expected value from dataset."""
extractor = FilenameExtractor(Path(filename), use_cache=False)
assert extractor.extract_year() == expected['year']
@pytest.mark.parametrize("filename,expected", load_test_cases())
def test_expected_source(filename, expected):
"""Test that extracted source matches the expected value from dataset."""
extractor = FilenameExtractor(Path(filename), use_cache=False)
assert extractor.extract_source() == expected['source']
@pytest.mark.parametrize("filename,expected", load_test_cases())
def test_expected_frame_class(filename, expected):
"""Test that extracted frame_class matches the expected value from dataset."""
extractor = FilenameExtractor(Path(filename), use_cache=False)
assert extractor.extract_frame_class() == expected['frame_class']
@pytest.mark.parametrize("filename,expected", load_test_cases())
def test_expected_audio_langs(filename, expected):
"""Test that extracted audio_langs matches the expected value from dataset."""
extractor = FilenameExtractor(Path(filename), use_cache=False)
assert extractor.extract_audio_langs() == expected['audio_langs']
@pytest.mark.parametrize("filename,expected", load_test_cases())
def test_expected_movie_db(filename, expected):
"""Test that extracted movie_db matches the expected value from dataset."""
extractor = FilenameExtractor(Path(filename), use_cache=False)
assert extractor.extract_movie_db() == expected['movie_db']
+1 -1
View File
@@ -39,7 +39,7 @@ def test_frame_class_detection(test_case):
extractor.video_tracks = [mock_track] extractor.video_tracks = [mock_track]
extractor._get_tracks.return_value = [mock_track] # satisfies @requires_tracks_type decorator extractor._get_tracks.return_value = [mock_track] # satisfies @requires_tracks_type decorator
extractor._get_track.return_value = mock_track 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 extractor.extract_interlaced.return_value = interlaced
# Test the method # Test the method
+1 -7
View File
@@ -11,14 +11,8 @@ class RenameConfirmScreen(Screen):
#confirm_content { #confirm_content {
text-align: center; text-align: center;
} }
# Button {
# background: $surface;
# border: solid $surface;
}
Button:focus { Button:focus {
background: $primary; background: $primary;
# color: $text-primary;
# border: solid $primary;
} }
#buttons { #buttons {
align: center middle; align: center middle;
Generated
+1 -1
View File
@@ -208,7 +208,7 @@ wheels = [
[[package]] [[package]]
name = "moma" name = "moma"
version = "0.8.11" version = "0.9.5"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "langcodes" }, { name = "langcodes" },