Files
mediahive/hivescan/scanning.py
T

208 lines
6.2 KiB
Python

"""File system scanning functions."""
import asyncio
import glob
from pathlib import Path
from typing import Dict, List, Optional, Tuple
from aiopathlib import AsyncPath
from hivescan.models import ContentType, ParsedContent
from hivescan.parsing import parse_download, parse_episode_from_filename
from hivescan.utils import get_media_folder_path, sanitize_filename
# Video file extensions
VIDEO_EXTENSIONS = {
".mkv",
".mp4",
".avi",
".m4v",
".mov",
".wmv",
".flv",
".webm",
".ts",
".m2ts",
}
# Caches for expensive operations
_episode_files_cache: Dict[str, Dict[Tuple[int, int], List[Tuple[str, int]]]] = {}
_playable_file_cache: Dict[str, Optional[str]] = {}
async def scan_downloads(base_pattern: str) -> List[ParsedContent]:
"""
Scan download directories matching the pattern.
Args:
base_pattern: Glob pattern for finding download directories
Returns:
List of ParsedContent objects for each found download
"""
exclude_patterns = [".torrents", "incomplete", ".incomplete"]
results: List[ParsedContent] = []
paths = await asyncio.to_thread(glob.glob, base_pattern)
for path_str in paths:
path = Path(path_str)
ap = AsyncPath(path)
if path.name.startswith("."):
continue
if any(excl.lower() in path.name.lower() for excl in exclude_patterns):
continue
if not await ap.exists():
continue
results.append(await parse_download(path))
return results
def categorize_downloads(
downloads: list[ParsedContent],
) -> dict[ContentType, list[ParsedContent]]:
"""Categorize downloads by content type."""
categories: dict[ContentType, list[ParsedContent]] = {
ContentType.MOVIE: [],
ContentType.SERIES: [],
ContentType.OTHER: [],
}
for download in downloads:
categories[download.content_type].append(download)
return categories
async def find_episode_files(path: Path) -> Dict[Tuple[int, int], List[Tuple[str, int]]]:
"""
Find all episode video files in a directory.
Args:
path: Path to search (can be a season pack directory or single file)
Returns:
Dict mapping (season_num, episode_num) to list of (file_path, file_size) tuples
"""
cache_key = str(path)
if cache_key in _episode_files_cache:
return _episode_files_cache[cache_key]
episodes: Dict[Tuple[int, int], List[Tuple[str, int]]] = {}
ap = AsyncPath(path)
if await ap.is_file():
if path.suffix.lower() in VIDEO_EXTENSIONS:
ep_info = parse_episode_from_filename(path.name)
if ep_info:
episodes[ep_info] = [(str(path), (await ap.stat()).st_size)]
_episode_files_cache[cache_key] = episodes
return episodes
try:
for f in ap.rglob("*"):
af = AsyncPath(f)
if await af.is_file() and Path(f).suffix.lower() in VIDEO_EXTENSIONS:
if "sample" in Path(f).name.lower():
continue
ep_info = parse_episode_from_filename(Path(f).name)
if ep_info:
if ep_info not in episodes:
episodes[ep_info] = []
episodes[ep_info].append((str(f), (await af.stat()).st_size))
except (OSError, PermissionError):
pass
_episode_files_cache[cache_key] = episodes
return episodes
async def find_playable_file(path: Path) -> Optional[str]:
"""
Find the main playable media file in a directory.
For Blu-ray discs: Returns BDMV/index.bdmv
For other content: Returns the largest video file
"""
cache_key = str(path)
if cache_key in _playable_file_cache:
return _playable_file_cache[cache_key]
ap = AsyncPath(path)
if await ap.is_file():
if path.suffix.lower() in VIDEO_EXTENSIONS:
result = str(path)
_playable_file_cache[cache_key] = result
return result
_playable_file_cache[cache_key] = None
return None
# Check for Blu-ray disc structure
bdmv_index = path / "BDMV" / "index.bdmv"
if await AsyncPath(bdmv_index).exists():
result = str(bdmv_index)
_playable_file_cache[cache_key] = result
return result
# Check nested Blu-ray structure (e.g., MovieName/DISC1/BDMV/)
try:
for subdir in ap.iterdir():
if await AsyncPath(subdir).is_dir():
nested_bdmv = Path(subdir) / "BDMV" / "index.bdmv"
if await AsyncPath(nested_bdmv).exists():
result = str(nested_bdmv)
_playable_file_cache[cache_key] = result
return result
except (OSError, PermissionError):
pass
# Find largest video file
video_files = []
try:
for f in ap.rglob("*"):
af = AsyncPath(f)
if await af.is_file() and Path(f).suffix.lower() in VIDEO_EXTENSIONS:
if "sample" in Path(f).name.lower():
continue
video_files.append((str(f), (await af.stat()).st_size))
except (OSError, PermissionError):
pass
if not video_files:
_playable_file_cache[cache_key] = None
return None
video_files.sort(key=lambda x: x[1], reverse=True)
result = video_files[0][0]
_playable_file_cache[cache_key] = result
return result
async def find_cover_image(
title: str, year: Optional[int], media_type: str, cover_dir: Path
) -> Optional[str]:
"""Find a cover image for the given media item."""
media_folder = get_media_folder_path(title, year, media_type, cover_dir)
cover_path = media_folder / "cover.jpg"
if await AsyncPath(cover_path).exists():
return str(cover_path)
# Legacy structure fallback
subdir = "movies" if media_type == "movie" else "series"
if media_type == "movie" and year:
legacy_path = cover_dir / subdir / f"{sanitize_filename(title)} ({year}).jpg"
if await AsyncPath(legacy_path).exists():
return str(legacy_path)
legacy_path = cover_dir / subdir / f"{sanitize_filename(title)}.jpg"
if await AsyncPath(legacy_path).exists():
return str(legacy_path)
return None