fix: support multiple video formats, configurable fallback ratio

- Accept .mkv, .mp4, .avi, .webm, .mov, .wmv, .flv, .m4v (Issue #12)
- CLI --extensions flag to filter formats
- Configurable MediaInfo fallback ratio via --fallback-ratio or env (Issue #13)
- Defaults: 45 min/GB, override with ~25 for 4K, ~60 for low-bitrate
This commit is contained in:
Jarian 2026-07-05 11:47:09 +00:00
parent 5aa37556d7
commit 0d6a219465
2 changed files with 99 additions and 35 deletions

View File

@ -151,6 +151,17 @@ def main():
help="Confirm destructive operations (required with --auto-delete-duplicates to actually delete files)" 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() args = parser.parse_args()
# Validate inputs # Validate inputs
@ -212,8 +223,13 @@ def main():
tvdb_episodes = [] tvdb_episodes = []
# Initialize file classifier # Initialize file classifier
video_extensions = args.extensions.split(',') if args.extensions else None
try: 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: except FileNotFoundError as e:
print(f"Error: {e}") print(f"Error: {e}")
sys.exit(1) sys.exit(1)

View File

@ -15,47 +15,96 @@ except ImportError:
config_manager = None 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: 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: List[str] = None,
"""Initialize with folder path containing video files.""" 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.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.video_files = self._get_video_files()
self.file_info = self._analyze_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]: 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(): 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: # Deduplicate (in case overlapping extensions)
print(f"Warning: No valid MKV files found in {self.folder_path}") seen = set()
unique = []
for f in filtered:
if f not in seen:
seen.add(f)
unique.append(f)
return video_files 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: 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_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, with configurable fallback."""
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 "
# Rough estimate: 1GB ≈ 45 minutes for typical video f"(ratio: {self.fallback_ratio:.1f} min/GB)")
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 * self.fallback_ratio
try: try:
media_info = MediaInfo.parse(str(file_path)) media_info = MediaInfo.parse(str(file_path))
@ -63,7 +112,6 @@ class FileClassifier:
if track.track_type == 'Video': if track.track_type == 'Video':
duration_ms = track.duration duration_ms = track.duration
if duration_ms: if duration_ms:
# Handle both string and numeric duration values
if isinstance(duration_ms, str): if isinstance(duration_ms, str):
try: try:
duration_ms = float(duration_ms) duration_ms = float(duration_ms)
@ -73,19 +121,19 @@ 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: fall back to size estimation for suspicious durations
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}, "
f"using size estimation ({self.fallback_ratio:.1f} min/GB)")
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 * self.fallback_ratio
return duration_minutes return duration_minutes
except Exception as e: except Exception as e:
print(f"Error getting duration for {file_path}: {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) 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]: 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."""