Files
mediahive/mediahive/hivescan/utils.py
T

203 lines
5.7 KiB
Python

"""Utility functions for paths, sizes, and timestamps."""
import time
from pathlib import Path
from typing import Optional, List
from aiopathlib import AsyncPath
# Default output folder name (created at common root of scanned paths)
DEFAULT_OUTPUT_FOLDER = ".mediahive"
# Threshold for considering atime "too close" to current time (1 hour)
_ATIME_FRESHNESS_THRESHOLD = 3600
# Resolution priority for quality sorting (higher = better)
RESOLUTION_PRIORITY = {
"2160p": 4,
"4K": 4,
"1080p": 3,
"1080i": 3,
"720p": 2,
"480p": 1,
}
async def get_added_timestamp(path: Path) -> Optional[int]:
"""
Get the timestamp when a torrent was added to the collection.
Heuristic:
- For directories: use ctime (most accurate for torrent folder creation)
- For files: use atime unless it's too close to current time (suggesting
the filesystem updates atime on reads), otherwise use max(mtime, ctime)
Returns:
Unix timestamp as int, or None if path doesn't exist
"""
ap = AsyncPath(path)
try:
stat_info = await ap.stat()
except OSError, PermissionError:
return None
if await ap.is_dir():
return int(stat_info.st_ctime)
now = time.time()
atime = stat_info.st_atime
if now - atime < _ATIME_FRESHNESS_THRESHOLD:
return int(max(stat_info.st_mtime, stat_info.st_ctime))
return int(atime)
async def get_directory_size(path: Path) -> int:
"""Calculate total size of a directory recursively."""
ap = AsyncPath(path)
total = 0
try:
if await ap.is_file():
return (await ap.stat()).st_size
for item in ap.rglob("*"):
if await AsyncPath(item).is_file():
total += (await AsyncPath(item).stat()).st_size
except OSError, PermissionError:
pass
return total
def format_size(size_bytes: int) -> str:
"""Format size in human-readable format."""
for unit in ["B", "KB", "MB", "GB", "TB"]:
if size_bytes < 1024:
return f"{size_bytes:.2f} {unit}"
size_bytes /= 1024
return f"{size_bytes:.2f} PB"
async def find_common_root(paths: List[Path]) -> Optional[Path]:
"""
Find the common root directory for a list of paths.
Returns None if paths are on different drives/mounts or have no common ancestor.
"""
if not paths:
return None
# Resolve all paths to absolute
resolved = [p.resolve() for p in paths]
# Check if all paths are on the same drive (relevant for Windows, but also
# catches cases where paths have completely different roots)
try:
# Get the device for each path
devices = set()
for p in resolved:
# Find the first existing parent to get device info
check_path = p
while (
not await AsyncPath(check_path).exists()
and check_path.parent != check_path
):
check_path = check_path.parent
if await AsyncPath(check_path).exists():
devices.add((await AsyncPath(check_path).stat()).st_dev)
if len(devices) > 1:
# Paths are on different devices/drives
return None
except OSError:
pass
# Find common path prefix
if len(resolved) == 1:
# Single path - use its parent as root
return (
resolved[0].parent
if await AsyncPath(resolved[0]).is_file()
else resolved[0]
)
# Get parts of each path
all_parts = [p.parts for p in resolved]
# Find common prefix
common_parts = []
for parts in zip(*all_parts):
if len(set(parts)) == 1:
common_parts.append(parts[0])
else:
break
if not common_parts:
return None
return Path(*common_parts)
def make_relative_path(
path: Optional[str], root: Optional[str] = None
) -> Optional[str]:
"""
Convert an absolute path to a posix-style path relative to the given root.
If root is None, returns the path as a posix string unchanged.
"""
if path is None:
return None
p = Path(path)
if root is None:
return p.as_posix()
try:
return p.relative_to(root).as_posix()
except ValueError:
return p.as_posix()
def sanitize_filename(name: str) -> str:
"""Sanitize a string for use as a filename."""
for char in ["/", "\\", ":", "*", "?", '"', "<", ">", "|"]:
name = name.replace(char, "_")
name = name.strip(". ")
return name
def get_media_folder_name(title: str, year: Optional[int], media_type: str) -> str:
"""Get the folder name for a media item."""
sanitized_title = sanitize_filename(title)
if media_type == "movie" and year:
return f"{sanitized_title} ({year})"
return sanitized_title
def get_media_folder_path(
title: str, year: Optional[int], media_type: str, cover_dir: Path
) -> Path:
"""Get the full path to a media item's folder."""
subdir = "movies" if media_type == "movie" else "series"
folder_name = get_media_folder_name(title, year, media_type)
return cover_dir / subdir / folder_name
def sort_by_quality(items: list, reverse: bool = True) -> None:
"""Sort items in-place by resolution quality and size.
Works with both plain dicts (intermediate episode files) and
msgspec.Struct instances (MovieVersion, EpisodeRelease).
"""
def _val(v, key, default=None):
return v.get(key, default) if isinstance(v, dict) else getattr(v, key, default)
items.sort(
key=lambda v: (
RESOLUTION_PRIORITY.get(_val(v, "resolution", "") or "", 0),
_val(v, "size", 0) or 0,
_val(v, "path", "") or "",
),
reverse=reverse,
)