Files
dl-organizer/src/vlm/scanner.py
T

661 lines
24 KiB
Python
Raw Normal View History

2026-02-09 17:43:35 +08:00
"""Inventory scanner for discovering and cataloging video files.
This module implements the core scanning functionality for the Video Library Manager,
including file discovery via `find`, metadata extraction, and categorization based on
directory structure.
2026-02-09 17:43:35 +08:00
"""
import csv
2026-02-13 13:36:39 +08:00
from concurrent.futures import ThreadPoolExecutor, as_completed
2026-02-09 17:43:35 +08:00
import json
import logging
import os
import subprocess
from datetime import datetime, timezone
from pathlib import Path
from typing import Callable, Iterator, Optional
2026-02-09 17:43:35 +08:00
from vlm.config import Config
from vlm.models import VideoFile
logger = logging.getLogger(__name__)
def _normalize_to_utc(timestamp: datetime) -> datetime:
"""Normalize a datetime to a UTC instant."""
if timestamp.tzinfo is None:
timestamp = timestamp.astimezone()
return timestamp.astimezone(timezone.utc)
def scan_library(
root: Path,
config: Config,
progress_callback: Optional[Callable[[int, int], None]] = None,
include_video_metadata: bool = True,
metadata_cache: Optional[dict[str, VideoFile]] = None
) -> list[VideoFile]:
2026-02-09 17:43:35 +08:00
"""Recursively scan library for video files.
Discovers all video files matching configured extensions within the library root,
records their metadata, and categorizes them based on directory structure.
Args:
root: Root directory to scan
config: Configuration object with video extensions and settings
progress_callback: Optional callback for progress updates
include_video_metadata: Whether to run ffprobe metadata extraction
metadata_cache: Optional cached inventory keyed by file path
2026-02-09 17:43:35 +08:00
Returns:
List of VideoFile objects representing discovered files
Note:
- Handles inaccessible files gracefully by logging errors and continuing
- Performs read-only operations without modifying any files or directories
- Categorizes files based on parent directory structure (movie/series/anime/other)
"""
logger.info(f"Starting library scan at: {root}")
if not root.exists():
logger.error(f"Library root does not exist: {root}")
return []
if not root.is_dir():
logger.error(f"Library root is not a directory: {root}")
return []
2026-04-07 08:07:18 +08:00
if include_video_metadata:
import shutil
if not shutil.which("ffprobe"):
logger.warning(
"ffprobe command not found in PATH. Video metadata extraction will be skipped. "
"Only file-level information (size, mtime) will be recorded."
)
include_video_metadata = False
2026-02-09 17:43:35 +08:00
video_files = []
file_count = 0
discovered_paths = _discover_video_paths(root, config.video_extensions)
total_paths = len(discovered_paths)
if progress_callback is not None:
progress_callback(0, total_paths)
2026-02-13 13:36:39 +08:00
max_workers = max(1, min(config.enrichment_max_concurrency, total_paths or 1))
use_parallel = include_video_metadata and total_paths > 1 and max_workers > 1
if use_parallel:
indexed_results: list[tuple[int, VideoFile]] = []
with ThreadPoolExecutor(max_workers=max_workers) as pool:
future_to_index = {
pool.submit(
_create_video_file,
file_path,
root,
config.categories,
include_video_metadata,
metadata_cache,
): index
for index, file_path in enumerate(discovered_paths, start=1)
}
for completed_count, future in enumerate(as_completed(future_to_index), start=1):
index = future_to_index[future]
try:
video_file = future.result()
except Exception as exc:
logger.error(f"Failed to scan file #{index}: {exc}")
video_file = None
if video_file is not None:
indexed_results.append((index, video_file))
file_count += 1
if progress_callback is not None:
progress_callback(completed_count, total_paths)
if file_count % 100 == 0 and file_count > 0:
logger.debug(f"Scanned {file_count} files so far...")
indexed_results.sort(key=lambda item: item[0])
video_files = [item[1] for item in indexed_results]
else:
for index, file_path in enumerate(discovered_paths, start=1):
video_file = _create_video_file(
file_path,
root,
config.categories,
include_video_metadata=include_video_metadata,
metadata_cache=metadata_cache
)
if video_file is None:
if progress_callback is not None:
progress_callback(index, total_paths)
continue
video_files.append(video_file)
file_count += 1
if progress_callback is not None:
progress_callback(index, total_paths)
2026-02-13 13:36:39 +08:00
if file_count % 100 == 0:
logger.debug(f"Scanned {file_count} files so far...")
2026-02-09 17:43:35 +08:00
logger.info(f"Scan complete. Found {file_count} video files")
return video_files
def _discover_video_paths(root: Path, video_extensions: list[str]) -> list[Path]:
"""Discover matching video files under root.
Uses the system `find` command for traversal speed and falls back to Python
recursion if `find` is unavailable.
"""
try:
return _discover_video_paths_with_find(root, video_extensions)
except FileNotFoundError:
logger.warning("`find` command not available - falling back to Python recursion")
return _discover_video_paths_recursive(root, video_extensions)
def _discover_video_paths_with_find(root: Path, video_extensions: list[str]) -> list[Path]:
"""Discover matching video files using the system `find` command."""
normalized_extensions = [ext.lower() for ext in video_extensions if ext]
if not normalized_extensions:
return []
command: list[str] = ["find", str(root), "-type", "f", "("]
for index, extension in enumerate(normalized_extensions):
if index > 0:
command.append("-o")
command.extend(["-iname", f"*{extension}"])
command.extend([")", "-print0"])
process = subprocess.Popen(
command,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE
)
stdout, stderr = process.communicate()
if process.returncode != 0:
stderr_text = stderr.decode(errors="replace").strip()
if stderr_text:
logger.warning(f"find reported issues while scanning: {stderr_text}")
discovered_paths: list[Path] = []
for path_bytes in stdout.split(b"\0"):
if not path_bytes:
continue
file_path = Path(os.fsdecode(path_bytes))
if _is_hidden_path(file_path, root):
continue
discovered_paths.append(file_path)
return discovered_paths
def _discover_video_paths_recursive(root: Path, video_extensions: list[str]) -> list[Path]:
"""Fallback discovery using Python directory traversal."""
discovered_paths: list[Path] = []
for file_path in _scan_directory_recursive(root, video_extensions):
if _is_hidden_path(file_path, root):
continue
discovered_paths.append(file_path)
return discovered_paths
def _is_hidden_path(file_path: Path, library_root: Path) -> bool:
"""Return True when any path component under root starts with a dot."""
try:
relative_path = file_path.relative_to(library_root)
except ValueError:
return file_path.name.startswith(".")
return any(part.startswith(".") for part in relative_path.parts)
2026-02-09 17:43:35 +08:00
def _scan_directory_recursive(
directory: Path,
video_extensions: list[str]
) -> Iterator[Path]:
2026-02-09 17:43:35 +08:00
"""Recursively scan a directory for video files.
Args:
directory: Directory to scan
video_extensions: List of configured video extensions
2026-02-09 17:43:35 +08:00
Yields:
File paths for each discovered video file
2026-02-09 17:43:35 +08:00
"""
try:
# Use os.scandir for efficient directory traversal
with os.scandir(directory) as entries:
for entry in entries:
try:
# Skip hidden files and directories (starting with .)
if entry.name.startswith('.'):
continue
if entry.is_file(follow_symlinks=False):
# Check if file has video extension
file_path = Path(entry.path)
if _is_video_file(file_path, video_extensions):
yield file_path
2026-02-09 17:43:35 +08:00
elif entry.is_dir(follow_symlinks=False):
# Recursively scan subdirectory
subdir_path = Path(entry.path)
yield from _scan_directory_recursive(subdir_path, video_extensions)
2026-02-09 17:43:35 +08:00
except (OSError, PermissionError) as e:
# Handle inaccessible files/directories gracefully
logger.error(f"Cannot access {entry.path}: {e}")
continue
except (OSError, PermissionError) as e:
# Handle inaccessible directory
logger.error(f"Cannot access directory {directory}: {e}")
def _is_video_file(file_path: Path, video_extensions: list[str]) -> bool:
"""Check if file has a video extension.
Args:
file_path: Path to file
video_extensions: List of valid video extensions (e.g., [".mp4", ".mkv"])
Returns:
True if file has a video extension, False otherwise
"""
file_extension = file_path.suffix.lower()
return file_extension in [ext.lower() for ext in video_extensions]
def _create_video_file(
file_path: Path,
library_root: Path,
categories_config: dict[str, list[str]],
include_video_metadata: bool = True,
metadata_cache: Optional[dict[str, VideoFile]] = None
) -> Optional[VideoFile]:
2026-02-09 17:43:35 +08:00
"""Create VideoFile object from file path.
2026-02-09 17:43:35 +08:00
Extracts file metadata and categorizes based on directory structure.
Optionally extracts video metadata using ffprobe if available.
2026-02-09 17:43:35 +08:00
Args:
file_path: Path to video file
library_root: Root of the library (for categorization)
categories_config: Mapping of category names to directory name lists
include_video_metadata: Whether to run ffprobe metadata extraction
metadata_cache: Optional cached inventory keyed by file path
2026-02-09 17:43:35 +08:00
Returns:
VideoFile object or None if file cannot be accessed
"""
try:
# Get file stats
stat = file_path.stat()
size_bytes = stat.st_size
modified_timestamp = datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc)
2026-02-09 17:43:35 +08:00
# Categorize based on directory structure
category = categorize_file(file_path, library_root, categories_config)
# Reuse cached metadata when file identity is unchanged.
video_metadata: dict = {}
if include_video_metadata:
cache_key = str(file_path)
cached_file = metadata_cache.get(cache_key) if metadata_cache else None
file_mtime_seconds = int(modified_timestamp.timestamp())
if (
cached_file is not None
and cached_file.size_bytes == size_bytes
and int(_normalize_to_utc(cached_file.modified_timestamp).timestamp()) == file_mtime_seconds
):
video_metadata = {
'resolution': cached_file.resolution,
'codec': cached_file.codec,
'duration_seconds': cached_file.duration_seconds,
'bitrate_kbps': cached_file.bitrate_kbps
}
else:
# Extract video metadata using ffprobe (optional, non-blocking)
video_metadata = extract_metadata(file_path)
2026-02-09 17:43:35 +08:00
# Create VideoFile object with optional metadata
return VideoFile(
path=file_path,
filename=file_path.name,
size_bytes=size_bytes,
modified_timestamp=modified_timestamp,
category=category,
resolution=video_metadata.get('resolution'),
codec=video_metadata.get('codec'),
duration_seconds=video_metadata.get('duration_seconds'),
bitrate_kbps=video_metadata.get('bitrate_kbps')
)
except (OSError, PermissionError) as e:
logger.error(f"Cannot read file metadata for {file_path}: {e}")
return None
def load_inventory_csv(input_path: Path) -> list[VideoFile]:
"""Load inventory CSV into VideoFile objects."""
from datetime import timezone
video_files: list[VideoFile] = []
with open(input_path, 'r', encoding='utf-8') as csvfile:
lines = [line for line in csvfile if not line.startswith('#')]
reader = csv.DictReader(lines)
for row in reader:
modified_timestamp = datetime.fromisoformat(row['modified_timestamp'])
if modified_timestamp.tzinfo is None:
modified_timestamp = modified_timestamp.replace(tzinfo=timezone.utc)
resolution = row.get('resolution') or None
codec = row.get('codec') or None
duration_seconds = float(row['duration_seconds']) if row.get('duration_seconds') else None
bitrate_kbps = int(row['bitrate_kbps']) if row.get('bitrate_kbps') else None
video_files.append(
VideoFile(
path=Path(row['path']),
filename=row['filename'],
size_bytes=int(row['size_bytes']),
modified_timestamp=modified_timestamp,
category=row['category'],
resolution=resolution,
codec=codec,
duration_seconds=duration_seconds,
bitrate_kbps=bitrate_kbps
)
)
return video_files
def categorize_file(
file_path: Path,
library_root: Path,
categories_config: dict[str, list[str]]
) -> str:
2026-02-09 17:43:35 +08:00
"""Determine category based on directory structure.
Categories are determined by the top-level directory within the library root,
matched against configured category mappings.
2026-02-09 17:43:35 +08:00
Args:
file_path: Path to video file
library_root: Root of the library
categories_config: Mapping of category names to directory name lists
Example: {"movie": ["movie", "movies"], "series": ["series", "tv"]}
2026-02-09 17:43:35 +08:00
Returns:
Category string: "movie", "series", "anime", or "other"
"""
try:
# Get relative path from library root
relative_path = file_path.relative_to(library_root)
2026-02-09 17:43:35 +08:00
# Get the first component of the relative path (top-level directory)
parts = relative_path.parts
if len(parts) > 0:
top_level_dir = parts[0].lower()
# Check against configured category mappings
for category, dir_names in categories_config.items():
# Case-insensitive matching
if top_level_dir in [name.lower() for name in dir_names]:
return category
# No match found
return "other"
2026-02-09 17:43:35 +08:00
else:
# File is directly in library root
return "other"
2026-02-09 17:43:35 +08:00
except ValueError:
# File is not within library root
logger.warning(f"File {file_path} is not within library root {library_root}")
return "other"
def extract_metadata(file_path: Path) -> dict:
"""Extract video metadata using ffprobe.
Attempts to extract resolution, codec, duration, and bitrate from video file
using ffprobe. If ffprobe is not available or fails, returns empty dict.
This is a non-blocking operation that gracefully handles failures.
Args:
file_path: Path to video file
Returns:
Dictionary with optional keys:
- resolution: str (e.g., "1920x1080")
- codec: str (e.g., "h264")
- duration_seconds: float
- bitrate_kbps: int
Note:
- Returns empty dict if ffprobe is not available
- Returns empty dict if ffprobe fails to extract metadata
- Logs warnings for failures but does not raise exceptions
"""
try:
# Run ffprobe to get video stream information in JSON format
result = subprocess.run(
[
'ffprobe',
'-v', 'quiet', # Suppress ffprobe output
'-print_format', 'json', # Output as JSON
'-show_streams', # Show stream information
'-show_format', # Show format information
str(file_path)
],
capture_output=True,
text=True,
timeout=10 # 10 second timeout to prevent hanging
)
if result.returncode != 0:
logger.debug(f"ffprobe failed for {file_path.name}: {result.stderr}")
return {}
# Parse JSON output
probe_data = json.loads(result.stdout)
# Extract metadata from the first video stream
metadata = {}
# Find the first video stream
video_stream = None
for stream in probe_data.get('streams', []):
if stream.get('codec_type') == 'video':
video_stream = stream
break
if video_stream:
# Extract resolution
width = video_stream.get('width')
height = video_stream.get('height')
if width and height:
metadata['resolution'] = f"{width}x{height}"
# Extract codec
codec_name = video_stream.get('codec_name')
if codec_name:
metadata['codec'] = codec_name
# Extract duration and bitrate from format section
format_info = probe_data.get('format', {})
# Extract duration
duration = format_info.get('duration')
if duration:
try:
metadata['duration_seconds'] = float(duration)
except (ValueError, TypeError):
pass
# Extract bitrate
bitrate = format_info.get('bit_rate')
if bitrate:
try:
# Convert from bits/sec to kbits/sec
metadata['bitrate_kbps'] = int(float(bitrate) / 1000)
except (ValueError, TypeError):
pass
if metadata:
logger.debug(f"Extracted metadata for {file_path.name}: {metadata}")
return metadata
except FileNotFoundError:
# ffprobe not installed or not in PATH
logger.debug("ffprobe not available - skipping metadata extraction")
return {}
except subprocess.TimeoutExpired:
logger.warning(f"ffprobe timeout for {file_path.name} - skipping metadata")
return {}
except json.JSONDecodeError as e:
logger.warning(f"Failed to parse ffprobe output for {file_path.name}: {e}")
return {}
except Exception as e:
# Catch any other unexpected errors
logger.warning(f"Unexpected error extracting metadata for {file_path.name}: {e}")
return {}
def save_inventory_csv(files: list[VideoFile], output: Path, library_root: Path) -> None:
"""Save inventory to CSV format (primary format).
Generates a CSV file with all file metadata following the defined schema:
path, filename, size_bytes, modified_timestamp, category, resolution, codec,
duration_seconds, bitrate_kbps
Args:
files: List of VideoFile objects to export
output: Path to output CSV file
library_root: Root of the library (included in report metadata)
Note:
- Timestamps are formatted as ISO 8601 (YYYY-MM-DDTHH:MM:SS) in UTC
- Missing optional values are represented as empty strings
- Header row is always present with column names
- Generation timestamp and library root are included as comment lines
"""
logger.info(f"Saving inventory to CSV: {output}")
# Ensure output directory exists
output.parent.mkdir(parents=True, exist_ok=True)
# Get generation timestamp in UTC
generation_timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S")
with open(output, 'w', newline='', encoding='utf-8') as csvfile:
# Write metadata as comments
csvfile.write("# vlm_schema_version: 1.0\n")
2026-02-09 17:43:35 +08:00
csvfile.write(f"# Generated: {generation_timestamp}\n")
csvfile.write(f"# Library Root: {library_root}\n")
# Define CSV schema
fieldnames = [
'path',
'filename',
'size_bytes',
'modified_timestamp',
'category',
'resolution',
'codec',
'duration_seconds',
'bitrate_kbps'
]
writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
writer.writeheader()
# Write each file
for video_file in files:
# Format timestamp as ISO 8601 in UTC
modified_utc = _normalize_to_utc(video_file.modified_timestamp)
2026-02-09 17:43:35 +08:00
row = {
'path': str(video_file.path),
'filename': video_file.filename,
'size_bytes': video_file.size_bytes,
'modified_timestamp': modified_utc.strftime("%Y-%m-%dT%H:%M:%S"),
'category': video_file.category,
'resolution': video_file.resolution or '',
'codec': video_file.codec or '',
'duration_seconds': video_file.duration_seconds if video_file.duration_seconds is not None else '',
'bitrate_kbps': video_file.bitrate_kbps if video_file.bitrate_kbps is not None else ''
}
writer.writerow(row)
logger.info(f"Saved {len(files)} files to CSV inventory")
def save_inventory_json(files: list[VideoFile], output: Path, library_root: Path) -> None:
"""Save inventory to JSON format (optional export format).
Generates a JSON file with all file metadata and report metadata.
Args:
files: List of VideoFile objects to export
output: Path to output JSON file
library_root: Root of the library (included in report metadata)
Note:
- Timestamps are formatted as ISO 8601 strings
- Missing optional values are represented as null
- Generation timestamp and library root are included in metadata section
"""
logger.info(f"Saving inventory to JSON: {output}")
# Ensure output directory exists
output.parent.mkdir(parents=True, exist_ok=True)
# Get generation timestamp in UTC
generation_timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S")
# Build JSON structure
inventory_data = {
'metadata': {
'generated': generation_timestamp,
'library_root': str(library_root),
'file_count': len(files)
},
'files': []
}
# Add each file
for video_file in files:
# Format timestamp as ISO 8601 in UTC
modified_utc = _normalize_to_utc(video_file.modified_timestamp)
2026-02-09 17:43:35 +08:00
file_data = {
'path': str(video_file.path),
'filename': video_file.filename,
'size_bytes': video_file.size_bytes,
'modified_timestamp': modified_utc.strftime("%Y-%m-%dT%H:%M:%S"),
'category': video_file.category,
'resolution': video_file.resolution,
'codec': video_file.codec,
'duration_seconds': video_file.duration_seconds,
'bitrate_kbps': video_file.bitrate_kbps
}
inventory_data['files'].append(file_data)
# Write JSON file with pretty formatting
with open(output, 'w', encoding='utf-8') as jsonfile:
json.dump(inventory_data, jsonfile, indent=2, ensure_ascii=False)
logger.info(f"Saved {len(files)} files to JSON inventory")