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 | ||
|
|
cbcb349c9b | ||
|
|
6194e5e168 | ||
|
|
e3c31f02f3 | ||
|
|
eb46b3c7ed | ||
|
|
cf250c4089 | ||
|
|
d19fb730d2 | ||
|
|
60302e18d3 | ||
|
|
db39c02e30 | ||
|
|
f7270e7afc |
@@ -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
@@ -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
@@ -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.8/moma-0.9.8-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.8/moma-0.9.8-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.8/moma-0.9.8-py3-none-any.whl
|
||||||
```
|
```
|
||||||
|
|
||||||
### Method 3: Development Installation
|
### Method 3: Development Installation
|
||||||
|
|||||||
@@ -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
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "moma"
|
name = "moma"
|
||||||
version = "0.9.0"
|
version = "0.9.8"
|
||||||
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
|
||||||
|
|||||||
+53
-15
@@ -4,6 +4,7 @@ from textual.containers import Horizontal, Container, ScrollableContainer, Verti
|
|||||||
from textual.widget import Widget
|
from textual.widget import Widget
|
||||||
from textual.command import Provider, Hit
|
from textual.command import Provider, Hit
|
||||||
from rich.markup import escape
|
from rich.markup import escape
|
||||||
|
from rich.text import Text
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from functools import partial
|
from functools import partial
|
||||||
from typing import TYPE_CHECKING, cast, Any
|
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 .constants import MEDIA_TYPES
|
||||||
from .views import OpenScreen, HelpScreen, RenameConfirmScreen, SettingsScreen, ConvertConfirmScreen, DeleteConfirmScreen
|
from .views import OpenScreen, HelpScreen, RenameConfirmScreen, SettingsScreen, ConvertConfirmScreen, DeleteConfirmScreen
|
||||||
from .extractors.extractor import MediaExtractor
|
from .extractors.extractor import MediaExtractor
|
||||||
|
from .extractors.filename_extractor import FilenameExtractor
|
||||||
from .views import MediaPanelView, ProposedFilenameView
|
from .views import MediaPanelView, ProposedFilenameView
|
||||||
from .formatters.text_formatter import TextFormatter
|
from .formatters.text_formatter import TextFormatter
|
||||||
from .formatters.catalog_formatter import CatalogFormatter
|
from .formatters.catalog_formatter import CatalogFormatter
|
||||||
@@ -211,18 +213,56 @@ 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': '💿', # Silver compact disk
|
||||||
'wmv': '📀', # Video camera
|
'wmv': '📀', # Gold compact disk
|
||||||
'm4v': '📹', # Video camera
|
'm4v': '📹', # Video camera
|
||||||
'mpg': '📼', # Video camera
|
'mpg': '📼', # Video cassette
|
||||||
'mpeg': '📼', # Video camera
|
'mpeg': '📼', # Video cassette
|
||||||
}
|
}
|
||||||
|
|
||||||
return icons.get(ext, '📄') # Default to document icon
|
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):
|
def build_tree(self, path: Path, node):
|
||||||
try:
|
try:
|
||||||
for item in sorted(path.iterdir()):
|
for item in sorted(path.iterdir()):
|
||||||
@@ -231,16 +271,14 @@ class MomaApp(App):
|
|||||||
if item.name.startswith(".") or item.name == "lost+found":
|
if item.name.startswith(".") or item.name == "lost+found":
|
||||||
continue
|
continue
|
||||||
# Add folder icon before directory name
|
# Add folder icon before directory name
|
||||||
label = f" {escape(item.name)}"
|
dir_label: str = f" {escape(item.name)}"
|
||||||
subnode = node.add(label, data=item)
|
subnode = node.add(dir_label, data=item)
|
||||||
self.build_tree(item, subnode)
|
self.build_tree(item, subnode)
|
||||||
elif item.is_file() and item.suffix.lower() in {
|
elif item.is_file() and item.suffix.lower() in {
|
||||||
f".{ext}" for ext in MEDIA_TYPES
|
f".{ext}" for ext in MEDIA_TYPES
|
||||||
}:
|
}:
|
||||||
# Add file type icon before filename
|
# Add file type icon before filename with resolution colour
|
||||||
icon = self._get_file_icon(item)
|
node.add(self._make_file_label(item), data=item)
|
||||||
label = f"{icon} {escape(item.name)}"
|
|
||||||
node.add(label, data=item)
|
|
||||||
except PermissionError:
|
except PermissionError:
|
||||||
pass
|
pass
|
||||||
except PermissionError:
|
except PermissionError:
|
||||||
@@ -645,7 +683,7 @@ By Category:"""
|
|||||||
logging.info(f"Found node for {old_path}, updating to {new_path.name}")
|
logging.info(f"Found node for {old_path}, updating to {new_path.name}")
|
||||||
# Update label with icon
|
# Update label with icon
|
||||||
icon = self._get_file_icon(new_path)
|
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
|
node.data = new_path
|
||||||
logging.info(f"After update: node.data = {node.data}, node.label = {node.label}")
|
logging.info(f"After update: node.data = {node.data}, node.label = {node.label}")
|
||||||
# Ensure cursor stays on the renamed file
|
# Ensure cursor stays on the renamed file
|
||||||
@@ -708,7 +746,7 @@ By Category:"""
|
|||||||
|
|
||||||
# Get icon for the file
|
# Get icon for the file
|
||||||
icon = self._get_file_icon(file_path)
|
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
|
# Add the new file node in alphabetically sorted position
|
||||||
new_node = None
|
new_node = None
|
||||||
|
|||||||
@@ -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')
|
||||||
@@ -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)
|
||||||
@@ -150,7 +154,14 @@ class FilenameExtractor:
|
|||||||
dot_match = re.search(r'\.(\d{4})\.', self.file_name)
|
dot_match = re.search(r'\.(\d{4})\.', self.file_name)
|
||||||
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:
|
||||||
@@ -212,6 +223,13 @@ class FilenameExtractor:
|
|||||||
return frame_class
|
return frame_class
|
||||||
# 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:
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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}"
|
||||||
@@ -213,7 +213,8 @@ class MediaInfoExtractor:
|
|||||||
# Determine scan type from available attributes
|
# Determine scan type from available attributes
|
||||||
# Check scan_type first (e.g., "Interlaced", "Progressive", "MBAFF")
|
# Check scan_type first (e.g., "Interlaced", "Progressive", "MBAFF")
|
||||||
if scan_type_attr and isinstance(scan_type_attr, str):
|
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(
|
logger.debug(
|
||||||
f"[{self.file_path.name}] Using scan_type: {scan_type_attr!r} -> scan_type={scan_type!r}"
|
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
|
effective_height = height
|
||||||
|
|
||||||
# First, try to match width to typical widths
|
# 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 = []
|
width_matches = []
|
||||||
for frame_class, info in FRAME_CLASSES.items():
|
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):
|
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"])
|
diff = abs(height - info["nominal_height"])
|
||||||
width_matches.append((frame_class, diff))
|
width_matches.append((frame_class, diff))
|
||||||
|
|
||||||
@@ -329,7 +333,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 +347,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",
|
||||||
|
|||||||
@@ -154,5 +154,12 @@
|
|||||||
"interlaced": false,
|
"interlaced": false,
|
||||||
"expected_frame_class": "1080p",
|
"expected_frame_class": "1080p",
|
||||||
"testname": "test-mistakenly-high-height-2"
|
"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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -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"""
|
||||||
@@ -128,4 +144,50 @@ def test_extract_audio_tracks(filename):
|
|||||||
assert isinstance(audio_tracks, list)
|
assert isinstance(audio_tracks, list)
|
||||||
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']
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user