Compare commits
1 Commits
main
...
fix/issue-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c818e69b0f |
@ -1,5 +1,20 @@
|
|||||||
"""Episode Matcher module for classifying and renaming video files."""
|
"""Episode Matcher module for classifying and renaming video files."""
|
||||||
from .file_classifier import FileClassifier
|
from .file_classifier import FileClassifier
|
||||||
from .episode_renamer import EpisodeRenamer
|
from .episode_renamer import EpisodeRenamer
|
||||||
|
from .strategies import (
|
||||||
|
DurationMatchStrategy,
|
||||||
|
SequentialStrategy,
|
||||||
|
DiscMappingStrategy,
|
||||||
|
ConstraintAwareStrategy,
|
||||||
|
match_episodes,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = ['FileClassifier', 'EpisodeRenamer']
|
__all__ = [
|
||||||
|
'FileClassifier',
|
||||||
|
'EpisodeRenamer',
|
||||||
|
'DurationMatchStrategy',
|
||||||
|
'SequentialStrategy',
|
||||||
|
'DiscMappingStrategy',
|
||||||
|
'ConstraintAwareStrategy',
|
||||||
|
'match_episodes',
|
||||||
|
]
|
||||||
|
|||||||
@ -14,48 +14,67 @@ try:
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
config_manager = None
|
config_manager = None
|
||||||
|
|
||||||
|
# Supported video file extensions
|
||||||
|
DEFAULT_VIDEO_EXTENSIONS = {'.mkv', '.mp4', '.avi', '.mov', '.wmv', '.flv', '.webm', '.m4v'}
|
||||||
|
|
||||||
|
|
||||||
class FileClassifier:
|
class FileClassifier:
|
||||||
"""Classifies video files as episodes or extras based on file size and video duration."""
|
"""Classifies video files as episodes or extras based on file size and video duration."""
|
||||||
|
|
||||||
def __init__(self, folder_path: str):
|
def __init__(self, folder_path: str, video_extensions: set = None):
|
||||||
"""Initialize with folder path containing video files."""
|
"""Initialize with folder path containing video files.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
folder_path: Path to folder containing video files.
|
||||||
|
video_extensions: Set of file extensions to scan (e.g., {'.mkv', '.mp4'}).
|
||||||
|
Defaults to DEFAULT_VIDEO_EXTENSIONS if not provided.
|
||||||
|
"""
|
||||||
self.folder_path = Path(folder_path)
|
self.folder_path = Path(folder_path)
|
||||||
|
self.video_extensions = video_extensions or DEFAULT_VIDEO_EXTENSIONS
|
||||||
self.video_files = self._get_video_files()
|
self.video_files = self._get_video_files()
|
||||||
self.file_info = self._analyze_files()
|
self.file_info = self._analyze_files()
|
||||||
|
|
||||||
def _get_video_files(self) -> List[Path]:
|
def _get_video_files(self) -> List[Path]:
|
||||||
"""Get all MKV files in the folder, excluding system files."""
|
"""Get all video files in the folder, excluding system files."""
|
||||||
if not self.folder_path.exists():
|
if not self.folder_path.exists():
|
||||||
raise FileNotFoundError(f"Folder not found: {self.folder_path}")
|
raise FileNotFoundError(f"Folder not found: {self.folder_path}")
|
||||||
|
|
||||||
all_mkv_files = list(self.folder_path.glob("*.mkv"))
|
video_files = []
|
||||||
|
for ext in self.video_extensions:
|
||||||
|
video_files.extend(self.folder_path.glob(f"*{ext}"))
|
||||||
|
|
||||||
# Filter out macOS resource fork files and other system files
|
# Filter out macOS resource fork files and other system files
|
||||||
video_files = []
|
filtered = []
|
||||||
for file in all_mkv_files:
|
for file in video_files:
|
||||||
if file.name.startswith('._'):
|
if file.name.startswith('._'):
|
||||||
continue # Skip macOS resource fork files
|
continue
|
||||||
if file.name.startswith('.'):
|
if file.name.startswith('.'):
|
||||||
continue # Skip any hidden files
|
continue
|
||||||
video_files.append(file)
|
filtered.append(file)
|
||||||
|
|
||||||
if not video_files:
|
if not filtered:
|
||||||
print(f"Warning: No valid MKV files found in {self.folder_path}")
|
ext_list = ', '.join(sorted(self.video_extensions))
|
||||||
|
print(f"Warning: No video files found ({ext_list}) in {self.folder_path}")
|
||||||
|
|
||||||
return video_files
|
return filtered
|
||||||
|
|
||||||
def _get_file_size(self, file_path: Path) -> int:
|
def _get_file_size(self, file_path: Path) -> int:
|
||||||
"""Get file size in bytes."""
|
"""Get file size in bytes."""
|
||||||
return file_path.stat().st_size
|
return file_path.stat().st_size
|
||||||
|
|
||||||
|
def _get_fallback_duration_ratio(self) -> float:
|
||||||
|
"""Get minutes-per-GB ratio for fallback duration estimation."""
|
||||||
|
if config_manager:
|
||||||
|
return config_manager.get_duration_minutes_per_gb()
|
||||||
|
return 45.0
|
||||||
|
|
||||||
def _get_video_duration(self, file_path: Path) -> float:
|
def _get_video_duration(self, file_path: Path) -> float:
|
||||||
"""Get video duration in minutes using MediaInfo."""
|
"""Get video duration in minutes using MediaInfo."""
|
||||||
|
fallback_ratio = self._get_fallback_duration_ratio()
|
||||||
if MediaInfo is None:
|
if MediaInfo is None:
|
||||||
print("MediaInfo not available, using file size as proxy for duration")
|
print(f"MediaInfo not available, using file size as proxy for duration ({fallback_ratio} min/GB)")
|
||||||
# Rough estimate: 1GB ≈ 45 minutes for typical video
|
|
||||||
size_gb = self._get_file_size(file_path) / (1024**3)
|
size_gb = self._get_file_size(file_path) / (1024**3)
|
||||||
return size_gb * 45
|
return size_gb * fallback_ratio
|
||||||
|
|
||||||
try:
|
try:
|
||||||
media_info = MediaInfo.parse(str(file_path))
|
media_info = MediaInfo.parse(str(file_path))
|
||||||
@ -74,10 +93,10 @@ class FileClassifier:
|
|||||||
duration_minutes = float(duration_ms) / (1000 * 60)
|
duration_minutes = float(duration_ms) / (1000 * 60)
|
||||||
|
|
||||||
# Sanity check: if duration seems unreasonable, fall back to size estimation
|
# Sanity check: if duration seems unreasonable, fall back to size estimation
|
||||||
if duration_minutes > 300: # More than 5 hours is likely wrong
|
if duration_minutes > 300:
|
||||||
print(f"Warning: Suspicious duration ({duration_minutes:.1f} min) for {file_path.name}, using size estimation")
|
print(f"Warning: Suspicious duration ({duration_minutes:.1f} min) for {file_path.name}, using size estimation")
|
||||||
size_gb = self._get_file_size(file_path) / (1024**3)
|
size_gb = self._get_file_size(file_path) / (1024**3)
|
||||||
return size_gb * 45
|
return size_gb * fallback_ratio
|
||||||
|
|
||||||
return duration_minutes
|
return duration_minutes
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -85,7 +104,7 @@ class FileClassifier:
|
|||||||
|
|
||||||
# Fallback to size-based estimation
|
# Fallback to size-based estimation
|
||||||
size_gb = self._get_file_size(file_path) / (1024**3)
|
size_gb = self._get_file_size(file_path) / (1024**3)
|
||||||
return size_gb * 45
|
return size_gb * fallback_ratio
|
||||||
|
|
||||||
def _analyze_files(self) -> List[Dict]:
|
def _analyze_files(self) -> List[Dict]:
|
||||||
"""Analyze all video files to get size and duration info."""
|
"""Analyze all video files to get size and duration info."""
|
||||||
|
|||||||
200
src/Matcher/strategies.py
Normal file
200
src/Matcher/strategies.py
Normal file
@ -0,0 +1,200 @@
|
|||||||
|
"""Matching strategies for episode-to-file mapping."""
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Dict, Optional
|
||||||
|
|
||||||
|
|
||||||
|
class DurationMatchStrategy:
|
||||||
|
"""Match files to TVDB episodes by duration similarity."""
|
||||||
|
|
||||||
|
def __init__(self, tolerance_minutes: float = 1.0):
|
||||||
|
self.tolerance = tolerance_minutes
|
||||||
|
|
||||||
|
def match(self, files: List[Dict], tvdb_episodes: List[Dict],
|
||||||
|
allowed_episodes: Optional[List[int]] = None) -> List[Dict]:
|
||||||
|
matched = []
|
||||||
|
used_files = set()
|
||||||
|
used_episodes = set()
|
||||||
|
|
||||||
|
for file_info in files:
|
||||||
|
if id(file_info) in used_files:
|
||||||
|
continue
|
||||||
|
|
||||||
|
file_duration = file_info['duration']
|
||||||
|
best_match = None
|
||||||
|
best_score = float('inf')
|
||||||
|
|
||||||
|
for tvdb_ep in tvdb_episodes:
|
||||||
|
if tvdb_ep['episode_number'] in used_episodes:
|
||||||
|
continue
|
||||||
|
if allowed_episodes and tvdb_ep['episode_number'] not in allowed_episodes:
|
||||||
|
continue
|
||||||
|
|
||||||
|
tvdb_duration = tvdb_ep.get('runtime', 0)
|
||||||
|
if tvdb_duration == 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
duration_diff = abs(file_duration - tvdb_duration)
|
||||||
|
if duration_diff < best_score:
|
||||||
|
best_score = duration_diff
|
||||||
|
best_match = tvdb_ep
|
||||||
|
|
||||||
|
if best_match and best_score <= self.tolerance:
|
||||||
|
matched.append({
|
||||||
|
'file_info': file_info['file_info'],
|
||||||
|
'episode_number': best_match['episode_number'],
|
||||||
|
'tvdb_info': best_match,
|
||||||
|
'duration_diff': best_score,
|
||||||
|
'strategy': 'duration_match'
|
||||||
|
})
|
||||||
|
used_files.add(id(file_info))
|
||||||
|
used_episodes.add(best_match['episode_number'])
|
||||||
|
|
||||||
|
return matched, used_files, used_episodes
|
||||||
|
|
||||||
|
|
||||||
|
class SequentialStrategy:
|
||||||
|
"""Assign remaining files to episodes in sequential order."""
|
||||||
|
|
||||||
|
def match(self, remaining_files: List[Dict], tvdb_episodes: List[Dict],
|
||||||
|
used_episodes: set, allowed_episodes: List[int]) -> List[Dict]:
|
||||||
|
matched = []
|
||||||
|
available = sorted(
|
||||||
|
[e for e in allowed_episodes if e not in used_episodes]
|
||||||
|
)
|
||||||
|
|
||||||
|
sorted_files = sorted(remaining_files, key=lambda x: x['filename'])
|
||||||
|
|
||||||
|
for i, file_info in enumerate(sorted_files):
|
||||||
|
if i >= len(available):
|
||||||
|
break
|
||||||
|
|
||||||
|
episode_number = available[i]
|
||||||
|
tvdb_match = next(
|
||||||
|
(ep for ep in tvdb_episodes if ep['episode_number'] == episode_number),
|
||||||
|
None
|
||||||
|
)
|
||||||
|
|
||||||
|
duration_diff = None
|
||||||
|
if tvdb_match and tvdb_match.get('runtime', 0) > 0:
|
||||||
|
duration_diff = abs(file_info['duration'] - tvdb_match['runtime'])
|
||||||
|
|
||||||
|
matched.append({
|
||||||
|
'file_info': file_info['file_info'],
|
||||||
|
'episode_number': episode_number,
|
||||||
|
'tvdb_info': tvdb_match,
|
||||||
|
'duration_diff': duration_diff,
|
||||||
|
'strategy': 'sequential'
|
||||||
|
})
|
||||||
|
|
||||||
|
return matched
|
||||||
|
|
||||||
|
|
||||||
|
class DiscMappingStrategy:
|
||||||
|
"""Match files to episodes using explicit disc-to-episode mapping."""
|
||||||
|
|
||||||
|
def __init__(self, disc_mapping: Dict[int, List[int]]):
|
||||||
|
self.disc_mapping = disc_mapping
|
||||||
|
|
||||||
|
def match(self, episodes_info: List[Dict], tvdb_episodes: List[Dict]) -> List[Dict]:
|
||||||
|
matched = []
|
||||||
|
disc_groups = {}
|
||||||
|
|
||||||
|
for info in episodes_info:
|
||||||
|
disc_num = info['disc_number']
|
||||||
|
if disc_num is not None:
|
||||||
|
disc_groups.setdefault(disc_num, []).append(info)
|
||||||
|
|
||||||
|
for disc_num in sorted(disc_groups.keys()):
|
||||||
|
files = sorted(disc_groups[disc_num], key=lambda x: x['filename'])
|
||||||
|
expected = self.disc_mapping.get(disc_num, [])
|
||||||
|
|
||||||
|
for i, file_info in enumerate(files):
|
||||||
|
if i >= len(expected):
|
||||||
|
break
|
||||||
|
|
||||||
|
episode_number = expected[i]
|
||||||
|
tvdb_episode = next(
|
||||||
|
(ep for ep in tvdb_episodes if ep['episode_number'] == episode_number),
|
||||||
|
None
|
||||||
|
)
|
||||||
|
|
||||||
|
matched.append({
|
||||||
|
'file_info': file_info['file_info'],
|
||||||
|
'episode_number': episode_number,
|
||||||
|
'tvdb_info': tvdb_episode,
|
||||||
|
'strategy': 'disc_mapping'
|
||||||
|
})
|
||||||
|
|
||||||
|
return matched
|
||||||
|
|
||||||
|
|
||||||
|
class ConstraintAwareStrategy:
|
||||||
|
"""Match files respecting disc capacity and duration constraints."""
|
||||||
|
|
||||||
|
def __init__(self, max_duration_diff: float = 2.0,
|
||||||
|
min_file_duration_buffer: float = 2.0):
|
||||||
|
self.max_duration_diff = max_duration_diff
|
||||||
|
self.min_file_buffer = min_file_duration_buffer
|
||||||
|
|
||||||
|
def validate(self, file_info: Dict, tvdb_episode: Dict,
|
||||||
|
disc_assignments: Dict[int, int],
|
||||||
|
disc_capacity: Dict[int, int]) -> bool:
|
||||||
|
file_duration = file_info['duration']
|
||||||
|
tvdb_duration = tvdb_episode.get('runtime', 0)
|
||||||
|
file_disc = file_info['disc_number']
|
||||||
|
|
||||||
|
if tvdb_duration > 0:
|
||||||
|
diff = abs(file_duration - tvdb_duration)
|
||||||
|
if diff > self.max_duration_diff:
|
||||||
|
return False
|
||||||
|
if file_duration < (tvdb_duration - self.min_file_buffer):
|
||||||
|
return False
|
||||||
|
|
||||||
|
if file_disc and disc_capacity:
|
||||||
|
max_eps = disc_capacity.get(file_disc, 0)
|
||||||
|
current = disc_assignments.get(file_disc, 0)
|
||||||
|
if current >= max_eps:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def match_episodes(files: List[Dict], tvdb_episodes: List[Dict],
|
||||||
|
disc_mapping: Optional[Dict[int, List[int]]] = None,
|
||||||
|
disc_capacity: Optional[Dict[int, int]] = None
|
||||||
|
) -> List[Dict]:
|
||||||
|
"""
|
||||||
|
Multi-strategy episode matcher.
|
||||||
|
|
||||||
|
Pipeline:
|
||||||
|
1. If disc_mapping provided, use DiscMappingStrategy
|
||||||
|
2. Otherwise, use DurationMatchStrategy for close matches
|
||||||
|
3. Fill gaps with SequentialStrategy
|
||||||
|
|
||||||
|
Args:
|
||||||
|
files: Episode info dicts with file_info, disc_number, duration, filename
|
||||||
|
tvdb_episodes: TVDB episode data with runtime
|
||||||
|
disc_mapping: Optional disc-to-episode mapping
|
||||||
|
disc_capacity: Optional disc capacity constraints
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of match dicts sorted by episode number
|
||||||
|
"""
|
||||||
|
all_allowed = list(range(1, len(tvdb_episodes) + 1))
|
||||||
|
|
||||||
|
if disc_mapping:
|
||||||
|
return DiscMappingStrategy(disc_mapping).match(files, tvdb_episodes)
|
||||||
|
|
||||||
|
# Phase 1: Duration matching
|
||||||
|
duration_strategy = DurationMatchStrategy()
|
||||||
|
matched, used_files, used_eps = duration_strategy.match(
|
||||||
|
files, tvdb_episodes, all_allowed
|
||||||
|
)
|
||||||
|
|
||||||
|
# Phase 2: Sequential fill for remaining
|
||||||
|
remaining = [f for f in files if id(f) not in used_files]
|
||||||
|
sequential = SequentialStrategy()
|
||||||
|
matched.extend(sequential.match(remaining, tvdb_episodes, used_eps, all_allowed))
|
||||||
|
|
||||||
|
matched.sort(key=lambda x: x['episode_number'])
|
||||||
|
return matched
|
||||||
@ -23,6 +23,8 @@ class ConfigManager:
|
|||||||
default_config = {
|
default_config = {
|
||||||
"tvdb_api_key": "",
|
"tvdb_api_key": "",
|
||||||
"default_episode_duration": 45,
|
"default_episode_duration": 45,
|
||||||
|
"duration_minutes_per_gb": 45,
|
||||||
|
"video_extensions": [".mkv", ".mp4", ".avi", ".mov", ".wmv", ".flv", ".webm", ".m4v"],
|
||||||
"classification_thresholds": {
|
"classification_thresholds": {
|
||||||
"size_threshold_ratio": 0.3,
|
"size_threshold_ratio": 0.3,
|
||||||
"duration_threshold_ratio": 0.4
|
"duration_threshold_ratio": 0.4
|
||||||
@ -65,6 +67,16 @@ class ConfigManager:
|
|||||||
"""Get default episode duration in minutes."""
|
"""Get default episode duration in minutes."""
|
||||||
return self.config.get("default_episode_duration", 45)
|
return self.config.get("default_episode_duration", 45)
|
||||||
|
|
||||||
|
def get_duration_minutes_per_gb(self) -> float:
|
||||||
|
"""Get fallback duration estimation ratio (minutes per GB)."""
|
||||||
|
return float(self.config.get("duration_minutes_per_gb", 45))
|
||||||
|
|
||||||
|
def get_video_extensions(self) -> list:
|
||||||
|
"""Get list of supported video file extensions."""
|
||||||
|
return self.config.get("video_extensions", [
|
||||||
|
".mkv", ".mp4", ".avi", ".mov", ".wmv", ".flv", ".webm", ".m4v"
|
||||||
|
])
|
||||||
|
|
||||||
def get_classification_thresholds(self) -> Dict[str, float]:
|
def get_classification_thresholds(self) -> Dict[str, float]:
|
||||||
"""Get classification threshold ratios."""
|
"""Get classification threshold ratios."""
|
||||||
return self.config.get("classification_thresholds", {
|
return self.config.get("classification_thresholds", {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user