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",
|
action="store_true",
|
||||||
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()
|
||||||
|
|
||||||
@ -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)
|
||||||
|
|||||||
@ -15,77 +15,125 @@ 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"))
|
|
||||||
|
|
||||||
# Filter out macOS resource fork files and other system files
|
|
||||||
video_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('._'):
|
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 = []
|
||||||
return video_files
|
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:
|
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))
|
||||||
for track in media_info.tracks:
|
for track in media_info.tracks:
|
||||||
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)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
print(f"Warning: Could not parse duration '{duration_ms}' for {file_path}")
|
print(f"Warning: Could not parse duration '{duration_ms}' for {file_path}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
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."""
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user