diff --git a/dist/renamer-0.5.3-py3-none-any.whl b/dist/renamer-0.5.3-py3-none-any.whl new file mode 100644 index 0000000..7ba7dd3 Binary files /dev/null and b/dist/renamer-0.5.3-py3-none-any.whl differ diff --git a/pyproject.toml b/pyproject.toml index 1c15733..8fd4d7e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "renamer" -version = "0.5.2" +version = "0.5.3" description = "Terminal-based media file renamer and metadata viewer" readme = "README.md" requires-python = ">=3.11" diff --git a/renamer/decorators/caching.py b/renamer/decorators/caching.py index c28967a..ebc330b 100644 --- a/renamer/decorators/caching.py +++ b/renamer/decorators/caching.py @@ -15,7 +15,7 @@ def cached_method(ttl_seconds: int = 3600) -> Callable: """Decorator to cache method results with TTL. Caches the result of a method call using a global file-based cache. - The cache key includes class name, method name, and parameters hash. + The cache key includes class name, method name, instance identifier, and parameters hash. Args: ttl_seconds: Time to live for cached results in seconds (default 1 hour) @@ -25,15 +25,18 @@ def cached_method(ttl_seconds: int = 3600) -> Callable: """ def decorator(func: Callable) -> Callable: def wrapper(self, *args, **kwargs) -> Any: - # Generate cache key: class_name.method_name.param_hash + # Generate cache key: class_name.method_name.instance_id.param_hash class_name = self.__class__.__name__ method_name = func.__name__ - # Create hash from args and kwargs + # Use instance identifier (file_path for extractors) + instance_id = getattr(self, 'file_path', str(id(self))) + + # Create hash from args and kwargs (excluding self) param_str = json.dumps((args, kwargs), sort_keys=True, default=str) param_hash = hashlib.md5(param_str.encode('utf-8')).hexdigest() - cache_key = f"{class_name}.{method_name}.{param_hash}" + cache_key = f"{class_name}.{method_name}.{instance_id}.{param_hash}" # Try to get from cache cached_result = _cache.get_object(cache_key) diff --git a/renamer/extractors/mediainfo_extractor.py b/renamer/extractors/mediainfo_extractor.py index c722a20..df780b3 100644 --- a/renamer/extractors/mediainfo_extractor.py +++ b/renamer/extractors/mediainfo_extractor.py @@ -65,33 +65,60 @@ class MediaInfoExtractor: return getattr(track, 'duration', 0) / 1000 if getattr(track, 'duration', None) else None return None - @cached_method() 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) - if not height: + width = getattr(self.video_tracks[0], 'width', None) + if not height or not width: return None # Check if interlaced interlaced = getattr(self.video_tracks[0], 'interlaced', None) scan_type = 'i' if interlaced == 'Yes' else 'p' - # Find the closest frame class based on height + # Calculate effective height for frame class determination + aspect_ratio = 16 / 9 + if height > width: + effective_height = height / aspect_ratio + else: + effective_height = height + + # First, try to match width to typical widths + width_matches = [] + for frame_class, info in FRAME_CLASSES.items(): + if width in info['typical_widths'] and frame_class.endswith(scan_type): + diff = abs(height - info['nominal_height']) + width_matches.append((frame_class, diff)) + + if width_matches: + # Choose the frame class with the smallest height difference + width_matches.sort(key=lambda x: x[1]) + return width_matches[0][0] + + # If no width match, fall back to height-based matching + # First try exact match with standard frame classes + frame_class = f"{int(round(effective_height))}{scan_type}" + if frame_class in FRAME_CLASSES: + return frame_class + + # Find closest standard height match closest_class = None min_diff = float('inf') - for frame_class, info in FRAME_CLASSES.items(): - if frame_class.endswith(scan_type): - diff = abs(height - info['nominal_height']) + for fc, info in FRAME_CLASSES.items(): + if fc.endswith(scan_type): + diff = abs(effective_height - info['nominal_height']) if diff < min_diff: min_diff = diff - closest_class = frame_class + closest_class = fc - # Return the closest match if within reasonable distance - if closest_class and min_diff <= 100: + # Return closest standard match if within reasonable distance (20 pixels) + if closest_class and min_diff <= 20: return closest_class - return None + + # For non-standard resolutions, create a custom frame class + return frame_class @cached_method() def extract_resolution(self) -> tuple[int, int] | None: diff --git a/renamer/test/test_mediainfo_extractor.py b/renamer/test/test_mediainfo_extractor.py index 5d3665d..7dfd57c 100644 --- a/renamer/test/test_mediainfo_extractor.py +++ b/renamer/test/test_mediainfo_extractor.py @@ -1,6 +1,7 @@ import pytest from pathlib import Path from renamer.extractors.mediainfo_extractor import MediaInfoExtractor +import json class TestMediaInfoExtractor: @@ -13,6 +14,13 @@ class TestMediaInfoExtractor: """Use the filenames.txt file for testing""" return Path(__file__).parent / "filenames.txt" + @pytest.fixture + def frame_class_cases(self): + """Load test cases for frame class extraction""" + cases_file = Path(__file__).parent / "test_mediainfo_frame_class_cases.json" + with open(cases_file, 'r') as f: + return json.load(f) + def test_extract_resolution(self, extractor, test_file): """Test extracting resolution from media info""" resolution = extractor.extract_resolution() @@ -47,4 +55,22 @@ class TestMediaInfoExtractor: """Test checking if video is 3D""" is_3d = extractor.is_3d() # Text files don't have video tracks - assert is_3d is False \ No newline at end of file + assert is_3d is False + + @pytest.mark.parametrize("case", [ + pytest.param(case, id=case["testname"]) + for case in json.load(open(Path(__file__).parent / "test_mediainfo_frame_class_cases.json")) + ]) + def test_extract_frame_class(self, case): + """Test extracting frame class from various resolutions""" + # Create a mock extractor with the test resolution + extractor = MediaInfoExtractor.__new__(MediaInfoExtractor) + extractor.video_tracks = [{ + 'width': case["resolution"][0], + 'height': case["resolution"][1], + 'interlaced': 'Yes' if case["interlaced"] else None + }] + + result = extractor.extract_frame_class() + print(f"Case: {case['testname']}, resolution: {case['resolution']}, expected: {case['expected_frame_class']}, got: {result}") + assert result == case["expected_frame_class"], f"Failed for {case['testname']}: expected {case['expected_frame_class']}, got {result}" \ No newline at end of file diff --git a/renamer/test/test_mediainfo_frame_class.json b/renamer/test/test_mediainfo_frame_class.json new file mode 100644 index 0000000..bc176f7 --- /dev/null +++ b/renamer/test/test_mediainfo_frame_class.json @@ -0,0 +1,146 @@ +[ + { + "testname": "test-480p-sd", + "resolution": [720, 480], + "interlaced": false, + "expected_frame_class": "480p" + }, + { + "testname": "test-576p-pal", + "resolution": [720, 576], + "interlaced": false, + "expected_frame_class": "576p" + }, + { + "testname": "test-720p-hd", + "resolution": [1280, 720], + "interlaced": false, + "expected_frame_class": "720p" + }, + { + "testname": "test-1080p-fullhd", + "resolution": [1920, 1080], + "interlaced": false, + "expected_frame_class": "1080p" + }, + { + "testname": "test-1080i-broadcast", + "resolution": [1920, 1080], + "interlaced": true, + "expected_frame_class": "1080i" + }, + { + "testname": "test-1440p-qhd", + "resolution": [2560, 1440], + "interlaced": false, + "expected_frame_class": "1440p" + }, + { + "testname": "test-2160p-uhd", + "resolution": [3840, 2160], + "interlaced": false, + "expected_frame_class": "2160p" + }, + { + "testname": "test-4320p-8k", + "resolution": [7680, 4320], + "interlaced": false, + "expected_frame_class": "4320p" + }, + { + "testname": "test-1080p-cinema-240", + "resolution": [1920, 804], + "interlaced": false, + "expected_frame_class": "1080p" + }, + { + "testname": "test-1080p-cinema-235", + "resolution": [1920, 816], + "interlaced": false, + "expected_frame_class": "1080p" + }, + { + "testname": "test-720p-cinema", + "resolution": [1280, 536], + "interlaced": false, + "expected_frame_class": "720p" + }, + { + "testname": "test-2160p-cinema", + "resolution": [3840, 1608], + "interlaced": false, + "expected_frame_class": "2160p" + }, + { + "testname": "test-mobile-vertical-iphone", + "resolution": [1170, 2532], + "interlaced": false, + "expected_frame_class": "1440p" + }, + { + "testname": "test-mobile-vertical-4k", + "resolution": [2160, 3840], + "interlaced": false, + "expected_frame_class": "2160p" + }, + { + "testname": "test-square-video", + "resolution": [1080, 1080], + "interlaced": false, + "expected_frame_class": "1080p" + }, + { + "testname": "test-vhs-capture", + "resolution": [720, 404], + "interlaced": false, + "expected_frame_class": "480p" + }, + { + "testname": "test-miniDV-pal", + "resolution": [720, 576], + "interlaced": true, + "expected_frame_class": "576i" + }, + { + "testname": "test-old-digital-camera-4by3", + "resolution": [1024, 768], + "interlaced": false, + "expected_frame_class": "768p" + }, + { + "testname": "test-old-digital-camera-lowres", + "resolution": [800, 600], + "interlaced": false, + "expected_frame_class": "600p" + }, + { + "testname": "test-webcam-legacy", + "resolution": [640, 480], + "interlaced": false, + "expected_frame_class": "480p" + }, + { + "testname": "test-odd-nonstandard-wide", + "resolution": [1600, 900], + "interlaced": false, + "expected_frame_class": "900p" + }, + { + "testname": "test-odd-nonstandard-small", + "resolution": [854, 480], + "interlaced": false, + "expected_frame_class": "480p" + }, + { + "testname": "test-ultrawide-monitor-capture", + "resolution": [3440, 1440], + "interlaced": false, + "expected_frame_class": "1440p" + }, + { + "testname": "test-strange-lowres", + "resolution": [512, 288], + "interlaced": false, + "expected_frame_class": "288p" + } +] \ No newline at end of file diff --git a/renamer/test/test_mediainfo_frame_class.py b/renamer/test/test_mediainfo_frame_class.py new file mode 100644 index 0000000..860db26 --- /dev/null +++ b/renamer/test/test_mediainfo_frame_class.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +"""Test script for MediaInfo frame class detection by resolution""" + +import json +import pytest +from unittest.mock import MagicMock +import sys +import os +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) + +from renamer.extractors.mediainfo_extractor import MediaInfoExtractor + +test_cases = json.load(open('renamer/test/test_mediainfo_frame_class.json')) + +@pytest.mark.parametrize("test_case", test_cases, ids=[tc['testname'] for tc in test_cases]) +def test_frame_class_detection(test_case): + """Test frame class detection for various resolutions""" + + testname = test_case['testname'] + width, height = test_case['resolution'] + interlaced = test_case['interlaced'] + expected = test_case['expected_frame_class'] + + # Create a mock MediaInfoExtractor + extractor = MagicMock(spec=MediaInfoExtractor) + from pathlib import Path + extractor.file_path = Path(f"test_{testname}") # Set a unique file_path for caching + + # Mock the video_tracks + mock_track = MagicMock() + mock_track.height = height + mock_track.width = width + mock_track.interlaced = 'Yes' if interlaced else 'No' + + extractor.video_tracks = [mock_track] + + # Test the method + actual = MediaInfoExtractor.extract_frame_class(extractor) + + assert actual == expected, f"{testname}: expected {expected}, got {actual}" \ No newline at end of file diff --git a/uv.lock b/uv.lock index 314488d..b7c523d 100644 --- a/uv.lock +++ b/uv.lock @@ -342,7 +342,7 @@ wheels = [ [[package]] name = "renamer" -version = "0.5.2" +version = "0.5.3" source = { editable = "." } dependencies = [ { name = "langcodes" },