Merge pull request 'fix: multi-format support, configurable fallback ratio (#12, #13)' (#29) from fix/issue-code-fixes-v2 into main
Reviewed-on: https://git.home.ms/jarianc/EpisodeMatcher/pulls/29
This commit is contained in:
commit
fd0d865858
@ -150,6 +150,17 @@ def main():
|
||||
action="store_true",
|
||||
help="Confirm destructive operations (required with --auto-delete-duplicates to actually delete files)"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--extensions",
|
||||
help="Comma-separated video extensions to process (e.g., 'mkv,mp4,avi'). Defaults to all common formats."
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--fallback-ratio",
|
||||
type=float,
|
||||
help="Minutes of video per GB when MediaInfo unavailable (default: 45). Use ~25 for 4K, ~60 for low-bitrate."
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
@ -212,8 +223,13 @@ def main():
|
||||
tvdb_episodes = []
|
||||
|
||||
# Initialize file classifier
|
||||
video_extensions = args.extensions.split(',') if args.extensions else None
|
||||
try:
|
||||
classifier = FileClassifier(str(folder_path))
|
||||
classifier = FileClassifier(
|
||||
str(folder_path),
|
||||
video_extensions=video_extensions,
|
||||
fallback_duration_minutes_per_gb=args.fallback_ratio,
|
||||
)
|
||||
except FileNotFoundError as e:
|
||||
print(f"Error: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
@ -15,77 +15,125 @@ except ImportError:
|
||||
config_manager = None
|
||||
|
||||
|
||||
# Supported video extensions (configurable via config or env var)
|
||||
DEFAULT_VIDEO_EXTENSIONS = {'.mkv', '.mp4', '.avi', '.webm', '.mov', '.wmv', '.flv', '.m4v'}
|
||||
|
||||
|
||||
class FileClassifier:
|
||||
"""Classifies video files as episodes or extras based on file size and video duration."""
|
||||
|
||||
def __init__(self, folder_path: str):
|
||||
"""Initialize with folder path containing video files."""
|
||||
|
||||
def __init__(self, folder_path: str, video_extensions: List[str] = None,
|
||||
fallback_duration_minutes_per_gb: float = None):
|
||||
"""Initialize with folder path containing video files.
|
||||
|
||||
Args:
|
||||
folder_path: Path to folder with video files.
|
||||
video_extensions: List of extensions to process (e.g., ['.mkv', '.mp4']).
|
||||
Defaults to all common formats.
|
||||
fallback_duration_minutes_per_gb: Minutes of video per GB when MediaInfo
|
||||
is unavailable. Defaults to 45 (configurable for 4K or low-bitrate content).
|
||||
"""
|
||||
self.folder_path = Path(folder_path)
|
||||
self.video_extensions = self._resolve_extensions(video_extensions)
|
||||
self.fallback_ratio = self._resolve_fallback_ratio(fallback_duration_minutes_per_gb)
|
||||
self.video_files = self._get_video_files()
|
||||
self.file_info = self._analyze_files()
|
||||
|
||||
|
||||
def _resolve_extensions(self, extensions: List[str] = None) -> set:
|
||||
"""Resolve the set of video extensions to scan."""
|
||||
if extensions:
|
||||
return {e.lower() if e.startswith('.') else f'.{e.lower()}' for e in extensions}
|
||||
env_ext = os.environ.get('VIDEO_EXTENSIONS', '')
|
||||
if env_ext:
|
||||
return {e.strip().lower() for e in env_ext.split(',') if e.strip()}
|
||||
return DEFAULT_VIDEO_EXTENSIONS
|
||||
|
||||
def _resolve_fallback_ratio(self, ratio: float = None) -> float:
|
||||
"""Resolve the fallback minutes/GB ratio for duration estimation."""
|
||||
if ratio is not None:
|
||||
return float(ratio)
|
||||
env_ratio = os.environ.get('FALLBACK_DURATION_RATIO', '')
|
||||
if env_ratio:
|
||||
try:
|
||||
return float(env_ratio)
|
||||
except ValueError:
|
||||
pass
|
||||
if config_manager:
|
||||
return config_manager.get_default_episode_duration()
|
||||
return 45.0
|
||||
|
||||
def _get_video_files(self) -> List[Path]:
|
||||
"""Get all MKV files in the folder, excluding system files."""
|
||||
"""Get all supported video files in the folder, excluding system files."""
|
||||
if not self.folder_path.exists():
|
||||
raise FileNotFoundError(f"Folder not found: {self.folder_path}")
|
||||
|
||||
all_mkv_files = list(self.folder_path.glob("*.mkv"))
|
||||
|
||||
# Filter out macOS resource fork files and other system files
|
||||
|
||||
video_files = []
|
||||
for file in all_mkv_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
|
||||
filtered = []
|
||||
for file in video_files:
|
||||
if file.name.startswith('._'):
|
||||
continue # Skip macOS resource fork files
|
||||
continue
|
||||
if file.name.startswith('.'):
|
||||
continue # Skip any hidden files
|
||||
video_files.append(file)
|
||||
|
||||
if not video_files:
|
||||
print(f"Warning: No valid MKV files found in {self.folder_path}")
|
||||
|
||||
return video_files
|
||||
continue
|
||||
filtered.append(file)
|
||||
|
||||
# Deduplicate (in case overlapping extensions)
|
||||
seen = set()
|
||||
unique = []
|
||||
for f in filtered:
|
||||
if f not in seen:
|
||||
seen.add(f)
|
||||
unique.append(f)
|
||||
|
||||
if not unique:
|
||||
ext_list = ', '.join(sorted(self.video_extensions))
|
||||
print(f"Warning: No valid video files found in {self.folder_path} (scanned: {ext_list})")
|
||||
|
||||
return unique
|
||||
|
||||
def _get_file_size(self, file_path: Path) -> int:
|
||||
"""Get file size in bytes."""
|
||||
return file_path.stat().st_size
|
||||
|
||||
def _get_video_duration(self, file_path: Path) -> float:
|
||||
"""Get video duration in minutes using MediaInfo."""
|
||||
"""Get video duration in minutes using MediaInfo, with configurable fallback."""
|
||||
if MediaInfo is None:
|
||||
print("MediaInfo not available, using file size as proxy for duration")
|
||||
# Rough estimate: 1GB ≈ 45 minutes for typical video
|
||||
print(f"MediaInfo not available, using file size as proxy for duration "
|
||||
f"(ratio: {self.fallback_ratio:.1f} min/GB)")
|
||||
size_gb = self._get_file_size(file_path) / (1024**3)
|
||||
return size_gb * 45
|
||||
|
||||
return size_gb * self.fallback_ratio
|
||||
|
||||
try:
|
||||
media_info = MediaInfo.parse(str(file_path))
|
||||
for track in media_info.tracks:
|
||||
if track.track_type == 'Video':
|
||||
duration_ms = track.duration
|
||||
if duration_ms:
|
||||
# Handle both string and numeric duration values
|
||||
if isinstance(duration_ms, str):
|
||||
try:
|
||||
duration_ms = float(duration_ms)
|
||||
except ValueError:
|
||||
print(f"Warning: Could not parse duration '{duration_ms}' for {file_path}")
|
||||
continue
|
||||
|
||||
|
||||
duration_minutes = float(duration_ms) / (1000 * 60)
|
||||
|
||||
# Sanity check: if duration seems unreasonable, fall back to size estimation
|
||||
if duration_minutes > 300: # More than 5 hours is likely wrong
|
||||
print(f"Warning: Suspicious duration ({duration_minutes:.1f} min) for {file_path.name}, using size estimation")
|
||||
|
||||
# Sanity check: fall back to size estimation for suspicious durations
|
||||
if duration_minutes > 300:
|
||||
print(f"Warning: Suspicious duration ({duration_minutes:.1f} min) for {file_path.name}, "
|
||||
f"using size estimation ({self.fallback_ratio:.1f} min/GB)")
|
||||
size_gb = self._get_file_size(file_path) / (1024**3)
|
||||
return size_gb * 45
|
||||
|
||||
return size_gb * self.fallback_ratio
|
||||
|
||||
return duration_minutes
|
||||
except Exception as e:
|
||||
print(f"Error getting duration for {file_path}: {e}")
|
||||
|
||||
# Fallback to size-based estimation
|
||||
|
||||
size_gb = self._get_file_size(file_path) / (1024**3)
|
||||
return size_gb * 45
|
||||
return size_gb * self.fallback_ratio
|
||||
|
||||
def _analyze_files(self) -> List[Dict]:
|
||||
"""Analyze all video files to get size and duration info."""
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user