Ruff automatic fixes and formatting.

This commit is contained in:
2026-05-25 01:57:17 +00:00
parent 5f69b5365f
commit 0e5251ab91
24 changed files with 417 additions and 448 deletions
+5 -6
View File
@@ -1,5 +1,4 @@
"""
Hivescan - Continuous media scanning with live WebSocket updates.
"""Hivescan - Continuous media scanning with live WebSocket updates.
Import from submodules directly:
from mediahive.hivescan.scanner import start, stop
@@ -9,13 +8,13 @@ Import from submodules directly:
"""
# Minimal public API - prefer importing from submodules directly
from mediahive.hivescan.models import ContentType, ContentHash, ParsedContent
from mediahive.hivescan.models import ContentHash, ContentType, ParsedContent
from mediahive.hivescan.utils import DEFAULT_OUTPUT_FOLDER, find_common_root
__all__ = [
"ContentType",
"ContentHash",
"ParsedContent",
"DEFAULT_OUTPUT_FOLDER",
"ContentHash",
"ContentType",
"ParsedContent",
"find_common_root",
]
+9 -13
View File
@@ -1,14 +1,12 @@
"""TMDb image downloading functions."""
import httpx
from pathlib import Path
from typing import Optional
import httpx
from aiopathlib import AsyncPath
from mediahive.hivescan.utils import get_media_folder_path, sanitize_filename
# TMDb image configuration
TMDB_IMAGE_BASE = "https://image.tmdb.org/t/p"
DEFAULT_POSTER_SIZE = "w500"
@@ -16,7 +14,7 @@ DEFAULT_BACKDROP_SIZE = "w1280"
DEFAULT_PROFILE_SIZE = "w185"
# Shared async HTTP client (created lazily)
_image_client: Optional[httpx.AsyncClient] = None
_image_client: httpx.AsyncClient | None = None
def _get_image_client() -> httpx.AsyncClient:
@@ -31,9 +29,7 @@ def _get_image_client() -> httpx.AsyncClient:
return _image_client
async def _download_image(
url: str, output_path: Path, description: str
) -> Optional[str]:
async def _download_image(url: str, output_path: Path, description: str) -> str | None:
"""Download an image from URL to output path."""
ap = AsyncPath(output_path)
if await ap.exists():
@@ -54,11 +50,11 @@ async def _download_image(
async def download_cover_image(
poster_path: str,
title: str,
year: Optional[int],
year: int | None,
media_type: str,
cover_dir: Path,
size: str = DEFAULT_POSTER_SIZE,
) -> Optional[str]:
) -> str | None:
"""Download a cover image from TMDb."""
if not poster_path:
return None
@@ -77,11 +73,11 @@ async def download_cover_image(
async def download_backdrop_image(
backdrop_path: str,
title: str,
year: Optional[int],
year: int | None,
media_type: str,
cover_dir: Path,
size: str = DEFAULT_BACKDROP_SIZE,
) -> Optional[str]:
) -> str | None:
"""Download a backdrop image from TMDb."""
if not backdrop_path:
return None
@@ -101,7 +97,7 @@ async def download_season_poster(
poster_path: str,
media_folder: Path,
season_num: int,
) -> Optional[str]:
) -> str | None:
"""Download a season poster image from TMDb."""
if not poster_path:
return None
@@ -122,7 +118,7 @@ async def download_cast_profile(
cast_name: str,
cast_index: int,
size: str = DEFAULT_PROFILE_SIZE,
) -> Optional[str]:
) -> str | None:
"""Download a cached cast profile image from TMDb."""
if not profile_path:
return None
+65 -67
View File
@@ -3,8 +3,8 @@
import asyncio
import hashlib
import logging
from collections.abc import AsyncIterator
from pathlib import Path
from typing import AsyncIterator, Dict, List, Optional, Tuple
from mediahive.hivescan.images import (
download_backdrop_image,
@@ -52,7 +52,7 @@ logger = logging.getLogger("hivescan.indexer")
async def _build_torrent_info(
item: ParsedContent, media_root: Optional[str] = None
item: ParsedContent, media_root: str | None = None
) -> Torrent:
"""Build torrent info for a single torrent."""
playable_file = await find_playable_file(item.path)
@@ -86,10 +86,10 @@ async def _build_torrent_info(
async def _cache_cast_profiles(
info: Optional[Info],
info: Info | None,
media_folder: Path,
media_root: Optional[str] = None,
) -> Optional[Info]:
media_root: str | None = None,
) -> Info | None:
"""Replace TMDb cast profile paths with cached local image paths."""
if not info or not info.cast:
return info
@@ -121,15 +121,14 @@ async def _cache_cast_profiles(
async def _collect_episode_files(
items: List[ParsedContent],
) -> Dict[Tuple[int, int], List[Dict]]:
"""
Collect all episode files from a list of torrent items.
items: list[ParsedContent],
) -> dict[tuple[int, int], list[dict]]:
"""Collect all episode files from a list of torrent items.
Returns dict mapping (season, episode) to list of file info dicts.
"""
all_episode_files: Dict[Tuple[int, int], List[Dict]] = {}
probe_cache: Dict[str, object] = {}
all_episode_files: dict[tuple[int, int], list[dict]] = {}
probe_cache: dict[str, object] = {}
async def get_probe(path: str):
cached = probe_cache.get(path)
@@ -148,8 +147,7 @@ async def _collect_episode_files(
all_episode_files[key] = []
for file_path, file_size in files:
probe = await get_probe(file_path)
all_episode_files[key].append(
{
all_episode_files[key].append({
"path": file_path,
"size": file_size,
"probed_resolution": probe.resolution,
@@ -166,8 +164,7 @@ async def _collect_episode_files(
"encoder": item.encoder,
"torrent_path": item.path.as_posix(),
"torrent_title": item.title,
}
)
})
# Handle individual episodes from PTN parsing
if item.episode is not None and item.season is not None:
@@ -196,8 +193,7 @@ async def _collect_episode_files(
item.content_hash.path
)
size = item.content_hash.size if item.content_hash else 0
all_episode_files[key].append(
{
all_episode_files[key].append({
"path": playable,
"size": size,
"probed_resolution": probe.resolution,
@@ -214,22 +210,21 @@ async def _collect_episode_files(
"encoder": item.encoder,
"torrent_path": item.path.as_posix(),
"torrent_title": item.title,
}
)
})
return all_episode_files
def _build_episodes_data(
episodes_in_season: Dict[int, List[Dict]],
tmdb_episodes: Dict[int, EpisodeInfo],
episodes_in_season: dict[int, list[dict]],
tmdb_episodes: dict[int, EpisodeInfo],
series_folder: Path,
season_num: int,
generate_showreels: bool,
episode_reel_tasks: List,
episode_reel_tasks: list,
series_title: str,
media_root: Optional[str] = None,
) -> List[Episode]:
media_root: str | None = None,
) -> list[Episode]:
"""Build episode data list for a season."""
episodes_data = []
@@ -256,9 +251,13 @@ def _build_episodes_data(
episode_num,
media_root=Path(media_root) if media_root else None,
)
episode_reel_tasks.append(
(best_file, series_folder, season_num, episode_num, series_title)
)
episode_reel_tasks.append((
best_file,
series_folder,
season_num,
episode_num,
series_title,
))
torrents = {}
for f in episode_files:
@@ -290,7 +289,7 @@ def _build_episodes_data(
rating=tmdb_ep.vote_average if tmdb_ep else None,
director=tmdb_ep.director if tmdb_ep else None,
reel_image=reel_path,
reel_sources=reel_sources if reel_sources else None,
reel_sources=reel_sources or None,
torrents=torrents,
)
episodes_data.append(episode_data)
@@ -299,19 +298,19 @@ def _build_episodes_data(
async def _build_seasons_data(
all_episode_files: Dict[Tuple[int, int], List[Dict]],
tmdb_id: Optional[int],
all_episode_files: dict[tuple[int, int], list[dict]],
tmdb_id: int | None,
series_folder: Path,
display_title: str,
fetch_covers: bool,
generate_showreels: bool,
season_cache: Dict,
episode_reel_tasks: List,
media_root: Optional[str] = None,
) -> List[Season]:
season_cache: dict,
episode_reel_tasks: list,
media_root: str | None = None,
) -> list[Season]:
"""Build seasons data structure for a series."""
# Group episodes by season
seasons_map: Dict[int, Dict[int, List[Dict]]] = {}
seasons_map: dict[int, dict[int, list[dict]]] = {}
for (season_num, episode_num), files in all_episode_files.items():
if season_num not in seasons_map:
seasons_map[season_num] = {}
@@ -323,7 +322,7 @@ async def _build_seasons_data(
# Fetch TMDb season details if we have a TMDb ID
tmdb_season = None
tmdb_episodes: Dict[int, EpisodeInfo] = {}
tmdb_episodes: dict[int, EpisodeInfo] = {}
if tmdb_id:
cache_key = (tmdb_id, season_num)
@@ -379,18 +378,17 @@ async def _process_movies(
cover_dir: Path,
fetch_covers: bool,
generate_showreels: bool,
media_root: Optional[str] = None,
root_id: Optional[str] = None,
) -> AsyncIterator[Tuple[Movie, Optional[Tuple[str, Path, str]]]]:
"""
Async generator that processes all movies.
media_root: str | None = None,
root_id: str | None = None,
) -> AsyncIterator[tuple[Movie, tuple[str, Path, str] | None]]:
"""Async generator that processes all movies.
Yields (Movie, showreel_task_or_None) for each movie as it is processed.
"""
# In-memory cache for TMDb lookups
movie_tmdb_cache: Dict[str, Optional[Info]] = {}
movie_tmdb_cache: dict[str, Info | None] = {}
async def get_movie_tmdb(title: str, year: Optional[int]) -> Optional[Info]:
async def get_movie_tmdb(title: str, year: int | None) -> Info | None:
cache_key = f"{title.lower()}:{year}"
if cache_key in movie_tmdb_cache:
return movie_tmdb_cache[cache_key]
@@ -413,7 +411,7 @@ async def _process_movies(
)
# Group by title+year
movie_groups: Dict[str, List[ParsedContent]] = {}
movie_groups: dict[str, list[ParsedContent]] = {}
for item in valid_movies:
key = f"{item.title.lower()}:{item.year or 0}"
if key not in movie_groups:
@@ -421,8 +419,8 @@ async def _process_movies(
movie_groups[key].append(item)
# Re-group by TMDb ID
tmdb_movie_groups: Dict[int, Dict] = {}
no_tmdb_movie_groups: Dict[str, Dict] = {}
tmdb_movie_groups: dict[int, dict] = {}
no_tmdb_movie_groups: dict[str, dict] = {}
if movie_groups:
logger.info(
@@ -550,8 +548,8 @@ async def _process_movies(
newest=newest,
cover_path=make_relative_path(cover_path, media_root),
backdrop_path=make_relative_path(backdrop_path, media_root),
showreel_images=showreel_paths if showreel_paths else None,
showreel_source_sets=showreel_source_sets if showreel_source_sets else None,
showreel_images=showreel_paths or None,
showreel_source_sets=showreel_source_sets or None,
torrents=torrents,
root_id=root_id,
)
@@ -593,9 +591,10 @@ async def _process_movies(
),
)
best_version = torrents[best_relpath]
if best_version.playable_file and not best_version.playable_file.endswith(
(".bdmv", ".ifo")
):
if best_version.playable_file and not best_version.playable_file.endswith((
".bdmv",
".ifo",
)):
abs_playable = (
(Path(media_root) / best_version.playable_file).as_posix()
if media_root
@@ -621,8 +620,8 @@ async def _process_movies(
year=year,
newest=newest,
cover_path=make_relative_path(cover_path, media_root),
showreel_images=showreel_paths if showreel_paths else None,
showreel_source_sets=showreel_source_sets if showreel_source_sets else None,
showreel_images=showreel_paths or None,
showreel_source_sets=showreel_source_sets or None,
torrents=torrents,
root_id=root_id,
)
@@ -634,19 +633,18 @@ async def _process_series(
cover_dir: Path,
fetch_covers: bool,
generate_showreels: bool,
media_root: Optional[str] = None,
root_id: Optional[str] = None,
) -> AsyncIterator[Tuple[Series, List[Tuple[str, Path, int, int, str]]]]:
"""
Async generator that processes all series.
media_root: str | None = None,
root_id: str | None = None,
) -> AsyncIterator[tuple[Series, list[tuple[str, Path, int, int, str]]]]:
"""Async generator that processes all series.
Yields (Series, episode_reel_tasks) for each series as it is processed.
"""
# In-memory cache for TMDb lookups
series_tmdb_cache: Dict[str, Optional[Info]] = {}
season_cache: Dict[Tuple[int, int], Optional[SeasonInfo]] = {}
series_tmdb_cache: dict[str, Info | None] = {}
season_cache: dict[tuple[int, int], SeasonInfo | None] = {}
async def get_series_tmdb(title: str) -> Optional[Info]:
async def get_series_tmdb(title: str) -> Info | None:
cache_key = title.lower()
if cache_key in series_tmdb_cache:
return series_tmdb_cache[cache_key]
@@ -671,7 +669,7 @@ async def _process_series(
)
# Group by title
series_groups: Dict[str, List[ParsedContent]] = {}
series_groups: dict[str, list[ParsedContent]] = {}
for item in valid_series:
key = item.title.lower()
if key not in series_groups:
@@ -679,8 +677,8 @@ async def _process_series(
series_groups[key].append(item)
# Re-group by TMDb ID
tmdb_groups: Dict[int, Dict] = {}
no_tmdb_groups: Dict[str, Dict] = {}
tmdb_groups: dict[int, dict] = {}
no_tmdb_groups: dict[str, dict] = {}
if series_groups:
logger.info(
@@ -753,7 +751,7 @@ async def _process_series(
# Collect and build episode data
all_episode_files = await _collect_episode_files(items)
ep_reel_tasks: List[Tuple[str, Path, int, int, str]] = []
ep_reel_tasks: list[tuple[str, Path, int, int, str]] = []
seasons_data = await _build_seasons_data(
all_episode_files,
tmdb_id,
@@ -781,7 +779,7 @@ async def _process_series(
id=series_id,
title=display_title,
info=tmdb_info,
alternative_titles=different_titles if different_titles else None,
alternative_titles=different_titles or None,
newest=newest,
cover_path=make_relative_path(cover_path, media_root),
backdrop_path=make_relative_path(backdrop_path, media_root),
@@ -805,7 +803,7 @@ async def _process_series(
series_folder = get_media_folder_path(title, None, "series", cover_dir)
all_episode_files = await _collect_episode_files(items)
ep_reel_tasks: List[Tuple[str, Path, int, int, str]] = []
ep_reel_tasks: list[tuple[str, Path, int, int, str]] = []
seasons_data = await _build_seasons_data(
all_episode_files,
None,
+13 -14
View File
@@ -4,7 +4,6 @@ import hashlib
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path
from typing import Optional
class ContentType(Enum):
@@ -24,7 +23,7 @@ class ContentHash:
size: int = 0
@classmethod
def from_path(cls, path: Path) -> "ContentHash":
def from_path(cls, path: Path) -> ContentHash:
"""Generate a content hash based on torrent name."""
hash_val = hashlib.md5(path.name.encode()).hexdigest()[:16]
return cls(path=path, hash=hash_val)
@@ -38,17 +37,17 @@ class ParsedContent:
name: str
content_type: ContentType
title: str
year: Optional[int] = None
resolution: Optional[str] = None
quality: Optional[str] = None
network: Optional[str] = None
codec: Optional[str] = None
audio: Optional[str] = None
season: Optional[int] = None
episode: Optional[int] = None
episode_name: Optional[str] = None
encoder: Optional[str] = None
language: Optional[str] = None
year: int | None = None
resolution: str | None = None
quality: str | None = None
network: str | None = None
codec: str | None = None
audio: str | None = None
season: int | None = None
episode: int | None = None
episode_name: str | None = None
encoder: str | None = None
language: str | None = None
is_directory: bool = False
raw_parsed: dict = field(default_factory=dict)
content_hash: Optional[ContentHash] = None
content_hash: ContentHash | None = None
+4 -6
View File
@@ -2,7 +2,6 @@
import re
from pathlib import Path
from typing import Optional, Tuple
import PTN
from aiopathlib import AsyncPath
@@ -10,11 +9,10 @@ from aiopathlib import AsyncPath
from mediahive.hivescan.models import ContentHash, ContentType, ParsedContent
from mediahive.hivescan.utils import normalize_resolution_label
_EDGE_NON_ALPHANUMERICS_RE = re.compile(r"^[^0-9A-Za-z]+|[^0-9A-Za-z]+$")
def strip_edge_non_alphanumerics(value: Optional[str]) -> Optional[str]:
def strip_edge_non_alphanumerics(value: str | None) -> str | None:
"""Remove punctuation from the start and end of PTN scene tags."""
if not value:
return None
@@ -64,14 +62,14 @@ async def parse_download(path: Path) -> ParsedContent:
)
def parse_episode_from_filename(filename: str) -> Optional[Tuple[int, int]]:
"""
Parse season and episode numbers from a filename.
def parse_episode_from_filename(filename: str) -> tuple[int, int] | None:
"""Parse season and episode numbers from a filename.
Handles formats: S01E05, 1x05, Season 1 Episode 5
Returns:
Tuple of (season_number, episode_number) or None if not found
"""
name = filename.lower()
+2 -7
View File
@@ -1,5 +1,4 @@
"""
Gitignore-style path matcher for controlling which directories the scanner visits.
"""Gitignore-style path matcher for controlling which directories the scanner visits.
Reads patterns from ``<media_root>/.mediahive/scanignore``. The file uses the
same syntax as ``.gitignore``:
@@ -30,8 +29,6 @@ from __future__ import annotations
import re
from pathlib import Path
from typing import List, Tuple
# Built-in patterns that are always excluded (before user file)
_BUILTIN_EXCLUDES: list[str] = [
@@ -75,11 +72,9 @@ def _pattern_to_regex(pattern: str) -> re.Pattern[str]:
parts.append("(?:.+/)?")
i += 3
continue
else:
parts.append(".*")
i += 2
continue
else:
parts.append("[^/]*")
i += 1
elif c == "?":
@@ -112,7 +107,7 @@ class ScanIgnore:
def __init__(self, media_root: Path) -> None:
self.media_root = media_root.resolve()
self._rules: List[Tuple[bool, re.Pattern[str]]] = [] # (negated, regex)
self._rules: list[tuple[bool, re.Pattern[str]]] = [] # (negated, regex)
self._load_builtins()
self._load_file()
+40 -27
View File
@@ -1,5 +1,4 @@
"""
Scan orchestration — background tasks for continuous media scanning.
"""Scan orchestration — background tasks for continuous media scanning.
All scanning logic lives here in hivescan. Communication with the mediahive
server happens exclusively through an async ``send`` callable that pushes
@@ -14,10 +13,9 @@ from __future__ import annotations
import asyncio
import logging
import os
import uuid
from collections.abc import Awaitable, Callable
from pathlib import Path
from typing import Awaitable, Callable, List, Optional
from aiopathlib import AsyncPath
@@ -69,10 +67,10 @@ class RootScanner:
self._scanignore = ScanIgnore(media_root)
# Runtime state
self._scan_task: Optional[asyncio.Task] = None
self._scan_task: asyncio.Task | None = None
self._showreel_queue: asyncio.Queue = asyncio.Queue()
self._showreel_worker_task: Optional[asyncio.Task] = None
self._rescan_worker_task: Optional[asyncio.Task] = None
self._showreel_worker_task: asyncio.Task | None = None
self._rescan_worker_task: asyncio.Task | None = None
self._seen_mtimes: dict[str, int] = {}
# ------------------------------------------------------------------
@@ -96,15 +94,23 @@ class RootScanner:
async def stop(self) -> None:
"""Cancel all background tasks."""
for task in (self._scan_task, self._showreel_worker_task, self._rescan_worker_task):
for task in (
self._scan_task,
self._showreel_worker_task,
self._rescan_worker_task,
):
if task and not task.done():
task.cancel()
# Wait briefly for graceful shutdown
for task in (self._scan_task, self._showreel_worker_task, self._rescan_worker_task):
for task in (
self._scan_task,
self._showreel_worker_task,
self._rescan_worker_task,
):
if task and not task.done():
try:
await asyncio.wait_for(task, timeout=2.0)
except (asyncio.TimeoutError, asyncio.CancelledError):
except TimeoutError, asyncio.CancelledError:
pass
def is_scanning(self) -> bool:
@@ -139,9 +145,9 @@ class RootScanner:
except Exception:
logger.exception("Rescan loop error")
async def _discover_downloads(self, task_id: str) -> List[ParsedContent]:
async def _discover_downloads(self, task_id: str) -> list[ParsedContent]:
"""Recursively walk the media root, respecting scanignore rules."""
downloads: List[ParsedContent] = []
downloads: list[ParsedContent] = []
media_root_str = self.media_root.as_posix()
dirs_visited = 0
@@ -196,7 +202,7 @@ class RootScanner:
child_dirs.append(item)
else:
child_files.append(item)
except (OSError, PermissionError):
except OSError, PermissionError:
logger.debug("Cannot list directory: %s", directory)
return
@@ -207,14 +213,19 @@ class RootScanner:
mtime = int(stat_info.st_mtime)
except OSError:
return
if relpath not in self._seen_mtimes or self._seen_mtimes[relpath] != mtime:
if (
relpath not in self._seen_mtimes
or self._seen_mtimes[relpath] != mtime
):
self._seen_mtimes[relpath] = mtime
downloads.append(await parse_download(directory))
return
if child_dirs:
dirs_visited += 1
rel = make_relative_path(str(directory), media_root_str) or str(directory)
rel = make_relative_path(str(directory), media_root_str) or str(
directory
)
if dirs_visited % 5 == 1:
await _report(f"Scanning: {rel} ({len(downloads)} found)")
logger.info("Scanning: %s (%d found so far)", rel, len(downloads))
@@ -229,7 +240,10 @@ class RootScanner:
mtime = int(stat_info.st_mtime)
except OSError:
continue
if relpath in self._seen_mtimes and self._seen_mtimes[relpath] == mtime:
if (
relpath in self._seen_mtimes
and self._seen_mtimes[relpath] == mtime
):
continue
self._seen_mtimes[relpath] = mtime
downloads.append(await parse_download(child_file))
@@ -251,7 +265,7 @@ class RootScanner:
root_ap = AsyncPath(self.media_root)
try:
root_children = await asyncio.to_thread(lambda: list(root_ap.iterdir()))
except (OSError, PermissionError):
except OSError, PermissionError:
logger.error("Cannot list media root: %s", self.media_root)
return downloads
@@ -284,8 +298,7 @@ class RootScanner:
return downloads
async def _run_scan(self) -> None:
"""
Full scan pipeline:
"""Full scan pipeline:
1. Discover downloads
2. Categorise → movies / series
3. Iterate async generators, send each item as Upsert
@@ -538,8 +551,8 @@ class RootScanner:
media_folder, media_root=media_root_path
)
paths = [sources[0] for sources in source_sets if sources]
movie.showreel_images = paths if paths else None
movie.showreel_source_sets = source_sets if source_sets else None
movie.showreel_images = paths or None
movie.showreel_source_sets = source_sets or None
await self._send(Upsert(kind="movie", item=movie))
await self._send(
Task(
@@ -552,7 +565,9 @@ class RootScanner:
)
)
else:
logger.warning("Showreel generation returned nothing: %s", title)
logger.warning(
"Showreel generation returned nothing: %s", title
)
await self._send(
Task(
data=TaskInfo(
@@ -620,9 +635,7 @@ class RootScanner:
media_root_str,
)
)
episode.reel_sources = (
reel_sources if reel_sources else None
)
episode.reel_sources = reel_sources or None
await self._send(Upsert(kind="series", item=series))
await self._send(
Task(
@@ -658,7 +671,8 @@ class RootScanner:
return
except Exception:
logger.exception(
"Showreel worker error (queue size=%d)", self._showreel_queue.qsize()
"Showreel worker error (queue size=%d)",
self._showreel_queue.qsize(),
)
try:
self._showreel_queue.task_done()
@@ -666,7 +680,6 @@ class RootScanner:
pass
# ---------------------------------------------------------------------------
# Legacy module-level API removed — use RootScanner per root instead.
# ---------------------------------------------------------------------------
+27 -28
View File
@@ -4,7 +4,6 @@ import asyncio
import glob
from collections import defaultdict
from pathlib import Path
from typing import Dict, List, Optional, Tuple
from aiopathlib import AsyncPath
@@ -27,23 +26,23 @@ VIDEO_EXTENSIONS = {
}
# Caches for expensive operations
_episode_files_cache: Dict[str, Dict[Tuple[int, int], List[Tuple[str, int]]]] = {}
_playable_file_cache: Dict[str, Optional[str]] = {}
_bluray_probe_file_cache: Dict[str, Optional[str]] = {}
_episode_files_cache: dict[str, dict[tuple[int, int], list[tuple[str, int]]]] = {}
_playable_file_cache: dict[str, str | None] = {}
_bluray_probe_file_cache: dict[str, str | None] = {}
async def scan_downloads(base_pattern: str) -> List[ParsedContent]:
"""
Scan download directories matching the pattern.
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] = []
results: list[ParsedContent] = []
paths = await asyncio.to_thread(glob.glob, base_pattern)
for path_str in paths:
@@ -82,21 +81,21 @@ def categorize_downloads(
async def find_episode_files(
path: Path,
) -> Dict[Tuple[int, int], List[Tuple[str, int]]]:
"""
Find all episode video files in a directory.
) -> 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 = path.as_posix()
if cache_key in _episode_files_cache:
return _episode_files_cache[cache_key]
episodes: Dict[Tuple[int, int], List[Tuple[str, int]]] = {}
episodes: dict[tuple[int, int], list[tuple[str, int]]] = {}
ap = AsyncPath(path)
if await ap.is_file():
@@ -117,19 +116,19 @@ async def find_episode_files(
if ep_info:
if ep_info not in episodes:
episodes[ep_info] = []
episodes[ep_info].append(
(Path(f).as_posix(), (await af.stat()).st_size)
)
except (OSError, PermissionError):
episodes[ep_info].append((
Path(f).as_posix(),
(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.
async def find_playable_file(path: Path) -> str | None:
"""Find the main playable media file in a directory.
For Blu-ray discs: Returns BDMV/MovieObject.bdmv (fallback: BDMV/index.bdmv)
For other content: Returns the largest video file
@@ -198,7 +197,7 @@ async def find_playable_file(path: Path) -> Optional[str]:
result = nested_video_ts_ifo.as_posix()
_playable_file_cache[cache_key] = result
return result
except (OSError, PermissionError):
except OSError, PermissionError:
pass
# Find largest video file
@@ -210,7 +209,7 @@ async def find_playable_file(path: Path) -> Optional[str]:
if "sample" in Path(f).name.lower():
continue
video_files.append((Path(f).as_posix(), (await af.stat()).st_size))
except (OSError, PermissionError):
except OSError, PermissionError:
pass
if not video_files:
@@ -223,7 +222,7 @@ async def find_playable_file(path: Path) -> Optional[str]:
return result
async def find_metadata_probe_file(playable_path: Optional[str]) -> Optional[str]:
async def find_metadata_probe_file(playable_path: str | None) -> str | None:
"""Resolve a path suitable for ffmpeg stream metadata probing.
For regular files, returns ``playable_path`` unchanged.
@@ -252,7 +251,7 @@ async def find_metadata_probe_file(playable_path: Optional[str]) -> Optional[str
)
# Group VOBs by title set (VTS_XX_Y.VOB)
title_sets: Dict[str, List[Tuple[str, int]]] = defaultdict(list)
title_sets: dict[str, list[tuple[str, int]]] = defaultdict(list)
try:
for f in AsyncPath(video_ts_dir).glob("*.vob"):
af = AsyncPath(f)
@@ -263,7 +262,7 @@ async def find_metadata_probe_file(playable_path: Optional[str]) -> Optional[str
ts_num = name[4:6]
size = (await af.stat()).st_size
title_sets[ts_num].append((Path(f).as_posix(), size))
except (OSError, PermissionError):
except OSError, PermissionError:
_bluray_probe_file_cache[cache_key] = None
return None
@@ -298,14 +297,14 @@ async def find_metadata_probe_file(playable_path: Optional[str]) -> Optional[str
_bluray_probe_file_cache[cache_key] = None
return None
candidates: List[Tuple[str, int]] = []
candidates: list[tuple[str, int]] = []
try:
for f in ap_stream.rglob("*.m2ts"):
af = AsyncPath(f)
if not await af.is_file():
continue
candidates.append((Path(f).as_posix(), (await af.stat()).st_size))
except (OSError, PermissionError):
except OSError, PermissionError:
_bluray_probe_file_cache[cache_key] = None
return None
@@ -320,8 +319,8 @@ async def find_metadata_probe_file(playable_path: Optional[str]) -> Optional[str
async def find_cover_image(
title: str, year: Optional[int], media_type: str, cover_dir: Path
) -> Optional[str]:
title: str, year: int | None, media_type: str, cover_dir: Path
) -> str | None:
"""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"
+39 -46
View File
@@ -1,5 +1,4 @@
"""
Showreel generation module for media preview clips.
"""Showreel generation module for media preview clips.
Generates short video clips (reels) from movies and TV episodes using ffmpeg.
Supports automatic black bar detection and removal, hardware-accelerated encoding,
@@ -15,7 +14,6 @@ import sys
from collections import Counter
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
from aiopathlib import AsyncPath
@@ -71,7 +69,7 @@ async def _run_ffmpeg(
)
try:
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
except asyncio.TimeoutError:
except TimeoutError:
await _kill_proc(proc)
try:
stdout, stderr = await proc.communicate()
@@ -133,7 +131,7 @@ def get_reel_source_extensions() -> list[str]:
return REEL_SOURCE_EXTENSIONS.copy()
def _to_media_path(path: Path, media_root: Optional[Path] = None) -> str:
def _to_media_path(path: Path, media_root: Path | None = None) -> str:
"""Convert an absolute reel file path to a media-root-relative path when possible."""
if media_root:
try:
@@ -180,10 +178,9 @@ def get_reel_audio_options() -> list[str]:
def get_expected_showreel_paths(
media_folder: Path,
timestamps: list[int] = SHOWREEL_TIMESTAMPS,
media_root: Optional[Path] = None,
media_root: Path | None = None,
) -> list[str]:
"""
Compute the expected showreel paths without generating them.
"""Compute the expected showreel paths without generating them.
Args:
media_folder: Folder for this specific media item
@@ -192,6 +189,7 @@ def get_expected_showreel_paths(
Returns:
List of relative paths where showreels will be created for this platform
"""
paths = []
extension = get_reel_extension()
@@ -211,10 +209,9 @@ def get_expected_episode_reel_path(
media_folder: Path,
season_num: int,
episode_num: int,
media_root: Optional[Path] = None,
media_root: Path | None = None,
) -> str:
"""
Compute the expected episode reel path without generating it.
"""Compute the expected episode reel path without generating it.
Args:
media_folder: Folder for this series
@@ -224,6 +221,7 @@ def get_expected_episode_reel_path(
Returns:
Relative path where the reel will be created for this platform
"""
output_path = (
media_folder / f"S{season_num:02d}E{episode_num:02d}{get_reel_extension()}"
@@ -239,7 +237,7 @@ def get_expected_episode_reel_path(
def get_existing_showreel_paths(
media_folder: Path,
timestamps: list[int] = SHOWREEL_TIMESTAMPS,
media_root: Optional[Path] = None,
media_root: Path | None = None,
) -> list[str]:
"""Return preferred existing showreel paths, one per reel slot, in AV1-first order."""
source_sets = get_existing_showreel_source_sets(
@@ -253,7 +251,7 @@ def get_existing_showreel_paths(
def get_existing_showreel_source_sets(
media_folder: Path,
timestamps: list[int] = SHOWREEL_TIMESTAMPS,
media_root: Optional[Path] = None,
media_root: Path | None = None,
) -> list[list[str]]:
"""Return all existing showreel source files for each reel slot in AV1-first order."""
source_sets: list[list[str]] = []
@@ -272,7 +270,7 @@ def get_existing_episode_reel_path(
media_folder: Path,
season_num: int,
episode_num: int,
media_root: Optional[Path] = None,
media_root: Path | None = None,
) -> str | None:
"""Return the preferred existing episode reel path in AV1-first order."""
sources = get_existing_episode_reel_sources(
@@ -288,7 +286,7 @@ def get_existing_episode_reel_sources(
media_folder: Path,
season_num: int,
episode_num: int,
media_root: Optional[Path] = None,
media_root: Path | None = None,
) -> list[str]:
"""Return all existing episode reel source files in AV1-first order."""
ep_code = f"S{season_num:02d}E{episode_num:02d}"
@@ -319,15 +317,15 @@ async def episode_reel_exists(
).exists()
def get_bluray_uri(video_path: str) -> Optional[str]:
"""
Convert a Blu-ray index.bdmv path to an ffmpeg-compatible bluray: URI.
def get_bluray_uri(video_path: str) -> str | None:
"""Convert a Blu-ray index.bdmv path to an ffmpeg-compatible bluray: URI.
Args:
video_path: Path that may be a Blu-ray index.bdmv file
Returns:
bluray: URI if this is a Blu-ray disc, None otherwise
"""
if not video_path.endswith(".bdmv"):
return None
@@ -343,18 +341,18 @@ def get_bluray_uri(video_path: str) -> Optional[str]:
# Cache for AV1 encoder availability
_av1_encoder_cache: Optional[str] = None
_av1_encoder_cache: str | None = None
async def get_av1_encoder() -> str:
"""
Detect the best available AV1 encoder.
"""Detect the best available AV1 encoder.
Prefers hardware encoders (NVIDIA av1_nvenc) over software (libsvtav1).
Falls back to libsvtav1 if no hardware encoder is available.
Returns:
Encoder name to use with ffmpeg -c:v
"""
global _av1_encoder_cache
if _av1_encoder_cache is not None:
@@ -389,19 +387,18 @@ async def get_av1_encoder() -> str:
def get_encoder_options(encoder: str) -> list[str]:
"""
Get encoder-specific options for the given AV1 encoder.
"""Get encoder-specific options for the given AV1 encoder.
Args:
encoder: The encoder name (av1_nvenc, libsvtav1)
Returns:
List of ffmpeg arguments for encoder settings
"""
if encoder == "av1_nvenc":
# NVIDIA hardware encoder - use constant quality mode
return ["-cq", "35", "-preset", "p4"]
else:
# libsvtav1 software encoder
return ["-crf", "38", "-preset", "6"]
@@ -423,7 +420,9 @@ class MediaProbeInfo:
_media_probe_cache: dict[str, MediaProbeInfo] = {}
_duration_re = re.compile(r"Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)")
_dimension_re = re.compile(r"(\d{2,5})x(\d{2,5})")
_dovi_profile_re = re.compile(r"DOVI configuration record:.*?profile:\s*(\d+)", re.I)
_dovi_profile_re = re.compile(
r"DOVI configuration record:.*?profile:\s*(\d+)", re.IGNORECASE
)
_audio_stream_re = re.compile(r"Stream #\d+:\d+(?:\(([^)]+)\))?:\s+Audio:")
_subtitle_stream_re = re.compile(r"Stream #\d+:\d+(?:\(([^)]+)\))?:\s+Subtitle:")
@@ -511,9 +510,8 @@ async def probe_media_info(video_path: str) -> MediaProbeInfo:
return info
async def detect_dovi_profile(video_path: str) -> Optional[int]:
"""
Detect Dolby Vision profile from a video file.
async def detect_dovi_profile(video_path: str) -> int | None:
"""Detect Dolby Vision profile from a video file.
Returns the DoVi profile number (5, 7, 8, etc.) or None if not DoVi.
Profile 5: Dual-layer, no HDR10 base (needs conversion)
@@ -528,8 +526,7 @@ async def detect_dovi_profile(video_path: str) -> Optional[int]:
def get_dovi_to_hdr10_filter() -> str:
"""
Get the video filter string for converting DoVi to HDR10.
"""Get the video filter string for converting DoVi to HDR10.
Uses libplacebo to strip DoVi metadata while preserving HDR10 colorspace.
No tonemapping is applied - this just converts the container format.
@@ -543,8 +540,7 @@ def get_dovi_to_hdr10_filter() -> str:
async def is_hdr_video(video_path: str) -> bool:
"""
Check if a video file is HDR using ffmpeg probe output.
"""Check if a video file is HDR using ffmpeg probe output.
Returns True if the video has HDR metadata (bt2020, SMPTE ST 2084, etc.)
"""
@@ -554,9 +550,8 @@ async def is_hdr_video(video_path: str) -> bool:
return False
async def detect_crop(video_path: str) -> Optional[str]:
"""
Detect black bars in a video and return the crop filter string.
async def detect_crop(video_path: str) -> str | None:
"""Detect black bars in a video and return the crop filter string.
Only runs on 16:9 (1.78:1) source videos, since other aspect ratios like
2.35:1 or 4:3 are already correctly framed. Trusts cropping results only
@@ -571,6 +566,7 @@ async def detect_crop(video_path: str) -> Optional[str]:
Returns:
Crop filter string like "crop=1920:800:0:140" if black bars detected,
or None if no cropping needed or detection failed.
"""
# First, get source video dimensions to check if it's 16:9
probe_info = await probe_media_info(video_path)
@@ -655,8 +651,7 @@ async def detect_crop(video_path: str) -> Optional[str]:
return None
# If cropping in both directions, both must be symmetric
if x >= 8 and y >= 8:
if not (horizontal_symmetric and vertical_symmetric):
if x >= 8 and y >= 8 and not (horizontal_symmetric and vertical_symmetric):
return None
# Align all coordinates to 8 pixels (shrink content area if needed)
@@ -676,10 +671,8 @@ async def detect_crop(video_path: str) -> Optional[str]:
return crop_result
async def get_video_duration(video_path: str) -> Optional[float]:
"""
Get the duration of a video file in seconds using ffmpeg probe output.
"""
async def get_video_duration(video_path: str) -> float | None:
"""Get the duration of a video file in seconds using ffmpeg probe output."""
try:
return (await probe_media_info(video_path)).duration
except Exception:
@@ -693,8 +686,7 @@ async def generate_showreel_images(
title: str | None = None,
on_progress=None,
) -> list[str]:
"""
Generate showreel video clips from a video file at specified timestamps.
"""Generate showreel video clips from a video file at specified timestamps.
Saves 10-second clips in a platform-native format, downscaled to max 720px width,
preserving original color metadata. macOS emits MP4/H.265; other platforms emit WebM/AV1.
@@ -708,6 +700,7 @@ async def generate_showreel_images(
Returns:
List of relative paths to generated showreel video clips
"""
if not video_path:
logger.warning(
@@ -869,9 +862,8 @@ async def generate_episode_reel(
media_folder: Path,
season_num: int,
episode_num: int,
) -> Optional[str]:
"""
Generate a single 10-second reel video clip for a TV episode.
) -> str | None:
"""Generate a single 10-second reel video clip for a TV episode.
Saves a platform-native clip such as S01E05.mp4 on macOS or S01E05.webm elsewhere.
The clip is downscaled to max 720px width while preserving original color metadata.
@@ -884,6 +876,7 @@ async def generate_episode_reel(
Returns:
Relative path to generated image, or None if failed
"""
ep_code = f"S{season_num:02d}E{episode_num:02d}"
if not video_path:
+30 -42
View File
@@ -1,7 +1,5 @@
#!/usr/bin/env python3
"""
TMDb Client - Fetch movie and TV series metadata from The Movie Database (TMDb).
"""
"""TMDb Client - Fetch movie and TV series metadata from The Movie Database (TMDb)."""
import asyncio
import hashlib
@@ -10,7 +8,6 @@ import os
import sys
import urllib.parse
from pathlib import Path
from typing import Dict, Optional
import httpx
from aiopathlib import AsyncPath
@@ -28,10 +25,10 @@ TMDB_API_KEY = os.environ.get("TMDB_API_KEY", "6bd914e6a5df1c6d1ddf622cf2dbc232"
TMDB_API_BASE = "https://api.themoviedb.org/3"
# API response cache directory (can be overridden via set_cache_dir)
_tmdb_cache_dir: Optional[Path] = None
_tmdb_cache_dir: Path | None = None
# Persistent async HTTP client for connection reuse
_http_client: Optional[httpx.AsyncClient] = None
_http_client: httpx.AsyncClient | None = None
def set_cache_dir(cache_dir: Path) -> None:
@@ -65,7 +62,7 @@ def _get_http_client() -> httpx.AsyncClient:
_NOT_FOUND = object()
def _get_cache_path(endpoint: str, params: Dict[str, str]) -> Path:
def _get_cache_path(endpoint: str, params: dict[str, str]) -> Path:
"""Generate a cache file path for an API request."""
# Create a stable cache key from endpoint and sorted params
cache_key = endpoint + "?" + urllib.parse.urlencode(sorted(params.items()))
@@ -88,7 +85,7 @@ async def _load_from_cache(cache_path: Path):
return _NOT_FOUND
async def _save_to_cache(cache_path: Path, data: Optional[Dict]):
async def _save_to_cache(cache_path: Path, data: dict | None):
"""Save response to cache."""
try:
await AsyncPath(_get_cache_dir()).mkdir(parents=True, exist_ok=True)
@@ -105,8 +102,8 @@ async def _save_to_cache(cache_path: Path, data: Optional[Dict]):
async def tmdb_api_request(
endpoint: str, params: Optional[Dict[str, str]] = None
) -> Optional[Dict[str, str]]:
endpoint: str, params: dict[str, str] | None = None
) -> dict[str, str] | None:
"""Make a request to the TMDb API with disk caching and connection reuse."""
params = params or {}
@@ -144,7 +141,7 @@ async def tmdb_api_request(
return None
async def fetch_movie_details(movie_id: int) -> Optional[Dict]:
async def fetch_movie_details(movie_id: int) -> dict | None:
"""Fetch detailed movie info including credits, similar, keywords, and alternative titles."""
# Use append_to_response to get multiple data in one request
data = await tmdb_api_request(
@@ -154,7 +151,7 @@ async def fetch_movie_details(movie_id: int) -> Optional[Dict]:
return data
async def fetch_series_details(series_id: int) -> Optional[Dict]:
async def fetch_series_details(series_id: int) -> dict | None:
"""Fetch detailed TV series info including credits, similar, and keywords."""
# Use append_to_response to get multiple data in one request
data = await tmdb_api_request(
@@ -163,11 +160,8 @@ async def fetch_series_details(series_id: int) -> Optional[Dict]:
return data
async def fetch_season_details(
series_id: int, season_number: int
) -> Optional[SeasonInfo]:
"""
Fetch detailed season info including all episodes.
async def fetch_season_details(series_id: int, season_number: int) -> SeasonInfo | None:
"""Fetch detailed season info including all episodes.
Returns season metadata with episode list including:
- Episode names, overviews, air dates
@@ -231,8 +225,7 @@ def _map_person_gender(value: object) -> str | None:
def _generate_title_variants(words: list[str], min_words: int = 2) -> list[str]:
"""
Generate title variants by progressively removing words from both ends.
"""Generate title variants by progressively removing words from both ends.
Order: full title, then shorter from end, then shorter from start.
"""
@@ -272,8 +265,7 @@ def _normalize_for_match(text: str) -> set[str]:
def _titles_match(original_title: str, tmdb_title: str, search_query: str) -> bool:
"""
Check if TMDb result title reasonably matches our original title.
"""Check if TMDb result title reasonably matches our original title.
Uses word overlap to verify the result is relevant, preventing
false matches from short queries like "The" or just a year.
@@ -325,11 +317,8 @@ def _titles_match(original_title: str, tmdb_title: str, search_query: str) -> bo
)
async def _search_movie_with_fallbacks(
title: str, year: Optional[int]
) -> Optional[Dict]:
"""
Search for a movie with progressive title shortening fallbacks.
async def _search_movie_with_fallbacks(title: str, year: int | None) -> dict | None:
"""Search for a movie with progressive title shortening fallbacks.
PTN often includes edition names (THEATRICAL CUT, DIRECTOR'S CUT, etc.)
or garbage at the beginning/end of the title.
@@ -340,7 +329,7 @@ async def _search_movie_with_fallbacks(
variants = _generate_title_variants(words, min_words=2)
def _result_matches(
top_result: Dict, original_title: str, search_query: str
top_result: dict, original_title: str, search_query: str
) -> bool:
"""Check if result matches against either title or original_title."""
tmdb_title = top_result.get("title", "")
@@ -376,7 +365,7 @@ async def _search_movie_with_fallbacks(
return None
async def fetch_movie_info(title: str, year: Optional[int] = None) -> Optional[Info]:
async def fetch_movie_info(title: str, year: int | None = None) -> Info | None:
"""Fetch comprehensive movie info from TMDb."""
data = await _search_movie_with_fallbacks(title, year)
@@ -456,23 +445,22 @@ async def fetch_movie_info(title: str, year: Optional[int] = None) -> Optional[I
rating=details.get("vote_average"),
vote_count=details.get("vote_count"),
overview=details.get("overview"),
genres=genres if genres else None,
genres=genres or None,
release_date=details.get("release_date"),
runtime=details.get("runtime"),
status=details.get("status"),
tagline=details.get("tagline"),
poster_path=details.get("poster_path"),
backdrop_path=details.get("backdrop_path"),
similar=similar if similar else None,
keywords=keywords if keywords else None,
cast=cast if cast else None,
similar=similar or None,
keywords=keywords or None,
cast=cast or None,
director=director,
)
async def _search_series_with_fallbacks(title: str) -> Optional[Dict]:
"""
Search for a TV series with progressive title shortening fallbacks.
async def _search_series_with_fallbacks(title: str) -> dict | None:
"""Search for a TV series with progressive title shortening fallbacks.
PTN often includes extra text in the title at beginning or end.
Results are validated with fuzzy matching to prevent false positives.
@@ -493,7 +481,7 @@ async def _search_series_with_fallbacks(title: str) -> Optional[Dict]:
return None
async def fetch_series_info(title: str) -> Optional[Info]:
async def fetch_series_info(title: str) -> Info | None:
"""Fetch comprehensive TV series info from TMDb."""
data = await _search_series_with_fallbacks(title)
@@ -561,17 +549,17 @@ async def fetch_series_info(title: str) -> Optional[Info]:
rating=details.get("vote_average"),
vote_count=details.get("vote_count"),
overview=details.get("overview"),
genres=genres if genres else None,
genres=genres or None,
release_date=first_air_date,
status=details.get("status"),
tagline=details.get("tagline"),
poster_path=details.get("poster_path"),
backdrop_path=details.get("backdrop_path"),
similar=similar if similar else None,
keywords=keywords if keywords else None,
cast=cast if cast else None,
creators=creators if creators else None,
similar=similar or None,
keywords=keywords or None,
cast=cast or None,
creators=creators or None,
number_of_seasons=details.get("number_of_seasons"),
number_of_episodes=details.get("number_of_episodes"),
networks=networks if networks else None,
networks=networks or None,
)
+11 -16
View File
@@ -2,7 +2,6 @@
import time
from pathlib import Path
from typing import List, Optional
from aiopathlib import AsyncPath
@@ -84,9 +83,8 @@ def normalize_resolution_label(value: str | None) -> str | None:
return mapping.get(normalized)
async def get_added_timestamp(path: Path) -> Optional[int]:
"""
Get the timestamp when a torrent was added to the collection.
async def get_added_timestamp(path: Path) -> int | None:
"""Get the timestamp when a torrent was added to the collection.
Heuristic:
- For directories: use ctime (most accurate for torrent folder creation)
@@ -95,11 +93,12 @@ async def get_added_timestamp(path: Path) -> Optional[int]:
Returns:
Unix timestamp as int, or None if path doesn't exist
"""
ap = AsyncPath(path)
try:
stat_info = await ap.stat()
except (OSError, PermissionError):
except OSError, PermissionError:
return None
if await ap.is_dir():
@@ -124,7 +123,7 @@ async def get_directory_size(path: Path) -> int:
for item in ap.rglob("*"):
if await AsyncPath(item).is_file():
total += (await AsyncPath(item).stat()).st_size
except (OSError, PermissionError):
except OSError, PermissionError:
pass
return total
@@ -138,9 +137,8 @@ def format_size(size_bytes: int) -> str:
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.
async def find_common_root(paths: list[Path]) -> Path | None:
"""Find the common root directory for a list of paths.
Returns None if paths are on different drives/mounts or have no common ancestor.
"""
@@ -198,11 +196,8 @@ async def find_common_root(paths: List[Path]) -> Optional[Path]:
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.
def make_relative_path(path: str | None, root: str | None = None) -> str | None:
"""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.
"""
@@ -225,7 +220,7 @@ def sanitize_filename(name: str) -> str:
return name
def get_media_folder_name(title: str, year: Optional[int], media_type: str) -> str:
def get_media_folder_name(title: str, year: int | None, media_type: str) -> str:
"""Get the folder name for a media item."""
sanitized_title = sanitize_filename(title)
if media_type == "movie" and year:
@@ -234,7 +229,7 @@ def get_media_folder_name(title: str, year: Optional[int], media_type: str) -> s
def get_media_folder_path(
title: str, year: Optional[int], media_type: str, cover_dir: Path
title: str, year: int | None, 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"
+8 -5
View File
@@ -1,5 +1,4 @@
"""
In-memory index store with disk snapshot and WebSocket broadcast.
"""In-memory index store with disk snapshot and WebSocket broadcast.
The IndexStore is the single source of truth for the media index.
All mutations happen synchronously in the asyncio event loop — no locks needed.
@@ -12,7 +11,6 @@ import logging
import os
from datetime import datetime
from pathlib import Path
from typing import Optional
import msgspec
from aiopathlib import AsyncPath
@@ -48,7 +46,12 @@ class IndexStore:
# This would make IndexStore testable without FastAPI's WebSocket.
"""
def __init__(self, snapshot_path: Path, media_root: Optional[str] = None, root_id: Optional[str] = None):
def __init__(
self,
snapshot_path: Path,
media_root: str | None = None,
root_id: str | None = None,
):
self.snapshot_path = snapshot_path
self.media_root = media_root
self.root_id = root_id
@@ -62,7 +65,7 @@ class IndexStore:
# Snapshot debounce state
self._snapshot_dirty = False
self._snapshot_task: Optional[asyncio.Task] = None
self._snapshot_task: asyncio.Task | None = None
# ------------------------------------------------------------------
# Persistence
+1 -2
View File
@@ -1,5 +1,4 @@
"""
Data structures for mediahive and hivescan.
"""Data structures for mediahive and hivescan.
All types are msgspec.Structs for fast serialization.
"""
+1 -2
View File
@@ -1,5 +1,4 @@
"""
Event types shared between scanner and WebSocket.
"""Event types shared between scanner and WebSocket.
These types are used as:
- Internal scan events (scanner → server queue)
-1
View File
@@ -11,7 +11,6 @@ from fastapi.responses import Response
from .data import Movie, Series
from .events import Remove, ScanEvent, Task, Upsert
# ---------------------------------------------------------------------------
# WebSocket message types
# ---------------------------------------------------------------------------
+1 -3
View File
@@ -1,5 +1,4 @@
"""
TMDb data structures.
"""TMDb data structures.
All types are msgspec.Structs for fast serialization.
"""
@@ -8,7 +7,6 @@ from __future__ import annotations
import msgspec
# ---------------------------------------------------------------------------
# Sub-types (shared by TMDb results and index items)
# ---------------------------------------------------------------------------
+30 -16
View File
@@ -5,16 +5,13 @@ from __future__ import annotations
import asyncio
import hashlib
import logging
import os
from pathlib import Path
from typing import Optional
import msgspec
from mediahive.config import load_config, save_config
from mediahive.index_store import IndexStore
from mediahive.models.events import ScanEvent, Task, Upsert
from mediahive.models.data import TaskInfo
logger = logging.getLogger("mediahive.root_registry")
@@ -40,8 +37,10 @@ def _normalize_path(path: str) -> str:
if len(posix) >= 2 and posix[1] == ":":
posix = posix[0].lower() + posix[1:]
# Strip trailing slash (except root "/")
while len(posix) > 1 and posix.endswith("/") and not (
len(posix) == 3 and posix[1] == ":" and posix[2] == "/"
while (
len(posix) > 1
and posix.endswith("/")
and not (len(posix) == 3 and posix[1] == ":" and posix[2] == "/")
):
posix = posix[:-1]
return posix
@@ -92,17 +91,19 @@ class RootContext:
self.name = name or root_id
self.root_path = root_path
self.status = "loading"
self.error: Optional[str] = None
self.error: str | None = None
snapshot_path = root_path / ".mediahive" / "index.json"
self.store = IndexStore(snapshot_path, media_root=root_path.as_posix(), root_id=root_id)
self.store = IndexStore(
snapshot_path, media_root=root_path.as_posix(), root_id=root_id
)
# Scanner is injected later by the supervisor
self.scanner: Optional[object] = None
self.scanner: object | None = None
# Event queue and consumer
self._events: asyncio.Queue[ScanEvent] = asyncio.Queue()
self._consumer_task: Optional[asyncio.Task] = None
self._consumer_task: asyncio.Task | None = None
async def start(self) -> None:
"""Load snapshot and start event consumer."""
@@ -161,7 +162,9 @@ class RootContext:
except asyncio.CancelledError:
return
except Exception:
logger.exception("Error processing scan event for root %s", self.root_id)
logger.exception(
"Error processing scan event for root %s", self.root_id
)
# ---------------------------------------------------------------------------
@@ -181,7 +184,7 @@ class Supervisor:
# Read helpers
# ------------------------------------------------------------------
def get(self, root_id: str) -> Optional[RootContext]:
def get(self, root_id: str) -> RootContext | None:
return self._contexts.get(root_id)
def all_contexts(self) -> dict[str, RootContext]:
@@ -212,14 +215,15 @@ class Supervisor:
continue
movies.extend(ctx.store.movies.values())
series.extend(ctx.store.series.values())
total_movie_versions += sum(len(m.torrents) for m in ctx.store.movies.values())
total_movie_versions += sum(
len(m.torrents) for m in ctx.store.movies.values()
)
total_series_episodes += sum(
sum(len(season.episodes) for season in s.seasons)
for s in ctx.store.series.values()
)
from datetime import datetime
from mediahive.models.data import IndexSnapshot, MediaStats
return {
"version": 7,
@@ -238,7 +242,9 @@ class Supervisor:
# Atomic replacement
# ------------------------------------------------------------------
async def replace_roots(self, roots: dict[str, str]) -> tuple[list[RootEntry], list[dict]]:
async def replace_roots(
self, roots: dict[str, str]
) -> tuple[list[RootEntry], list[dict]]:
"""Atomically replace the active root set.
Returns (accepted_entries, failed_entries_with_reason).
@@ -253,11 +259,19 @@ class Supervisor:
for requested_name, path_str in roots.items():
p = Path(path_str).expanduser()
if not p.exists() or not p.is_dir():
failed.append({"name": requested_name, "path": path_str, "reason": "not a directory"})
failed.append({
"name": requested_name,
"path": path_str,
"reason": "not a directory",
})
continue
norm = _normalize_path(p.as_posix())
if norm in seen_paths:
failed.append({"name": requested_name, "path": path_str, "reason": "duplicate path"})
failed.append({
"name": requested_name,
"path": path_str,
"reason": "duplicate path",
})
continue
seen_paths.add(norm)
rid = compute_root_id(str(p))
+8 -9
View File
@@ -1,5 +1,4 @@
"""
FastAPI server for MediaHive.
"""FastAPI server for MediaHive.
Serves media files, the Vue frontend, and runs the continuous scanning
pipeline with live WebSocket updates. Excluded paths are controlled by
@@ -17,7 +16,6 @@ import os
import re
import subprocess
import sys
import tempfile
import urllib.error
import urllib.request
from contextlib import asynccontextmanager
@@ -39,7 +37,7 @@ from mediahive.models.protocol import (
PlayMediaRequest,
RootsRequest,
)
from mediahive.root_registry import Supervisor, compute_root_id
from mediahive.root_registry import Supervisor
logger = logging.getLogger("mediahive.server")
@@ -339,7 +337,10 @@ async def _activate_all_roots() -> None:
return
await _attach_scanners()
logger.info("Background root activation complete; %d root(s) active", len(supervisor.all_contexts()))
logger.info(
"Background root activation complete; %d root(s) active",
len(supervisor.all_contexts()),
)
# ---------------------------------------------------------------------------
@@ -518,9 +519,7 @@ async def open_folder(root_id: str, request: Request):
if target_path.is_file():
if not _select_file_in_windows_explorer(target_path):
select_arg = f'/n,/select,"{native_path}"'
subprocess.Popen(
["explorer.exe", select_arg], **_POPEN_KWARGS
)
subprocess.Popen(["explorer.exe", select_arg], **_POPEN_KWARGS)
else:
subprocess.Popen(["explorer.exe", native_path], **_POPEN_KWARGS)
elif sys.platform == "darwin":
@@ -569,7 +568,7 @@ def _mpcbe_request(path: str, timeout: float = 0.75) -> bool:
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
return 200 <= resp.status < 300
except (urllib.error.URLError, TimeoutError, OSError):
except urllib.error.URLError, TimeoutError, OSError:
return False
+13 -18
View File
@@ -155,7 +155,9 @@ def _save_playback_state(path: Path, state: dict[str, object]) -> None:
tmp_path.replace(path)
def _media_key_for_filepath(filepath: str, roots: list[Path]) -> tuple[str, Path] | None:
def _media_key_for_filepath(
filepath: str, roots: list[Path]
) -> tuple[str, Path] | None:
"""Resolve a filepath to a (relative_key, matched_root) tuple."""
for root in roots:
try:
@@ -195,7 +197,7 @@ def _mpcbe_request(path: str, timeout: float = MPC_BE_REQUEST_TIMEOUT) -> bool:
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
return 200 <= resp.status < 300
except (urllib.error.URLError, TimeoutError, OSError):
except urllib.error.URLError, TimeoutError, OSError:
return False
@@ -211,12 +213,10 @@ def _format_mpcbe_position(position_ms: int) -> str:
def _seek_mpcbe_to_position(position_ms: int) -> bool:
query = urllib.parse.urlencode(
{
query = urllib.parse.urlencode({
"wm_command": -1,
"position": _format_mpcbe_position(position_ms),
}
)
})
return _mpcbe_request(f"/command.html?{query}")
@@ -226,7 +226,7 @@ def _mpcbe_fetch_status() -> tuple[str, int, int, int] | None:
try:
with urllib.request.urlopen(req, timeout=MPC_BE_REQUEST_TIMEOUT) as resp:
response_html = resp.read().decode("utf-8", errors="replace")
except (urllib.error.URLError, TimeoutError, OSError):
except urllib.error.URLError, TimeoutError, OSError:
return None
state_match = _STATE_RE.search(response_html)
@@ -255,13 +255,9 @@ def _start_gamepad_remote(
seek_begin_hold_started_at: list[float | None] = [None, None, None, None]
seek_begin_fired = [False, False, False, False]
last_repeat_at = [
{
mask: 0.0
for mask in (
*_MPC_BE_COMMANDS.keys(),
*_MPC_BE_SEEK_MASK_TO_COMMANDS.keys(),
dict.fromkeys(
(*_MPC_BE_COMMANDS.keys(), *_MPC_BE_SEEK_MASK_TO_COMMANDS.keys()), 0.0
)
}
for _ in range(4)
]
request_pool = ThreadPoolExecutor(
@@ -405,13 +401,12 @@ def _start_gamepad_remote(
player_filepath, \
player_position_ms, \
player_duration_ms, \
player_state
nonlocal \
player_state, \
status_updated_at, \
status_miss_count, \
tracked_media_key, \
tracked_filepath
nonlocal resume_applied_for_key
tracked_filepath, \
resume_applied_for_key
if status_future is None or not status_future.done():
return
@@ -603,7 +598,7 @@ def _setup_logging() -> Path:
prev.unlink()
log_path.rename(prev)
log_file = open(log_path, "w", encoding="utf-8", buffering=1) # line-buffered
log_file = Path(log_path).open("w", encoding="utf-8", buffering=1) # line-buffered
# Redirect raw stdout/stderr so print() and tracebacks go to the file
sys.stdout = log_file
+9 -12
View File
@@ -1,12 +1,9 @@
#!/usr/bin/env python3
"""
RTorrent Client - Communicate with rtorrent via XMLRPC over SCGI socket.
"""
"""RTorrent Client - Communicate with rtorrent via XMLRPC over SCGI socket."""
import socket
import xmlrpc.client
from pathlib import Path
from typing import Dict, List, Optional
class SCGITransport(xmlrpc.client.Transport):
@@ -68,8 +65,7 @@ class RTorrentClient:
return set()
def load_torrent(self, torrent_path: Path, download_dir: Path) -> bool:
"""
Load a torrent file and set its download directory.
"""Load a torrent file and set its download directory.
Uses load.start_verbose to load and immediately start/hash-check.
Args:
@@ -78,6 +74,7 @@ class RTorrentClient:
Returns:
True if successful, False otherwise
"""
try:
# load.start_verbose with d.directory.set to specify download location
@@ -90,7 +87,7 @@ class RTorrentClient:
print(f"Error loading torrent {torrent_path}: {e}")
return False
def get_torrent_info(self, info_hash: str) -> Optional[Dict]:
def get_torrent_info(self, info_hash: str) -> dict | None:
"""Get info about a loaded torrent."""
try:
name = self.proxy.d.name(info_hash)
@@ -112,12 +109,12 @@ class RTorrentClient:
print(f"Error getting torrent info for {info_hash}: {e}")
return None
def get_unregistered_torrents(self) -> List[Dict]:
"""
Find all torrents with 'unregistered' or 'not registered' tracker errors.
def get_unregistered_torrents(self) -> list[dict]:
"""Find all torrents with 'unregistered' or 'not registered' tracker errors.
Returns:
List of torrent info dicts for torrents with registration errors
"""
unregistered = []
try:
@@ -139,8 +136,7 @@ class RTorrentClient:
return unregistered
def remove_torrent(self, info_hash: str, delete_files: bool = False) -> bool:
"""
Remove a torrent from rtorrent.
"""Remove a torrent from rtorrent.
Args:
info_hash: The info hash of the torrent to remove
@@ -148,6 +144,7 @@ class RTorrentClient:
Returns:
True if successful, False otherwise
"""
try:
if delete_files:
+3 -4
View File
@@ -9,9 +9,8 @@ from pathlib import Path
from typing import Any
import httpx
from fastapi_vue.hostutil import parse_endpoint
from buildutil import find_dev_tool, find_install_tool, logger
from fastapi_vue.hostutil import parse_endpoint
class ProcessGroup:
@@ -33,7 +32,7 @@ class ProcessGroup:
return proc
async def wait(
self, *waitables: "asyncio.subprocess.Process | Coroutine[Any, Any, Any]"
self, *waitables: asyncio.subprocess.Process | Coroutine[Any, Any, Any]
) -> None:
"""Wait for processes/coroutines to complete, raise SystemExit on failure."""
@@ -175,7 +174,7 @@ def setup_fastapi(
host = endpoints[0]["host"]
port = endpoints[0]["port"]
reload_dir = module.split(".")[0] # Don't reload on frontend changes
reload_dir = module.split(".", maxsplit=1)[0] # Don't reload on frontend changes
cmd = [
sys.executable,
+2 -2
View File
@@ -73,7 +73,7 @@ def fetch_ffmpeg() -> Path:
ffmpeg_entry = next(
name for name in zf.namelist() if name.endswith("/bin/ffmpeg.exe")
)
with zf.open(ffmpeg_entry) as src, open(dest, "wb") as out:
with zf.open(ffmpeg_entry) as src, Path(dest).open("wb") as out:
out.write(src.read())
print(f"ffmpeg staged at {dest} ({dest.stat().st_size // 1024 // 1024} MB)")
@@ -108,7 +108,7 @@ def fetch_macos_arm64_binaries() -> dict[str, Path]:
for name in zf.namelist()
if Path(name).name == tool_name and not name.endswith("/")
)
with zf.open(entry_name) as src, open(dest, "wb") as out:
with zf.open(entry_name) as src, Path(dest).open("wb") as out:
out.write(src.read())
dest.chmod(dest.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
+2 -2
View File
@@ -34,7 +34,7 @@ REPO_ROOT = Path(__file__).parent.parent
def load_gitea_config() -> dict:
pyproject = REPO_ROOT / "pyproject.toml"
with open(pyproject, "rb") as f:
with Path(pyproject).open("rb") as f:
data = tomllib.load(f)
repo_url = data.get("project", {}).get("urls", {}).get("Repository")
if not repo_url:
@@ -156,7 +156,7 @@ def upload_asset(
size_mb = path.stat().st_size / (1024 * 1024)
mime = "application/zip" if path.suffix == ".zip" else "application/octet-stream"
print(f"Uploading {path.name} ({size_mb:.1f} MB) ...")
with open(path, "rb") as fh:
with Path(path).open("rb") as fh:
resp = client.post(
url,
files={"attachment": (path.name, fh, mime)},
+16 -22
View File
@@ -1,17 +1,16 @@
#!/usr/bin/env python3
"""
Torrent Scanner - Scans for .torrent files and analyzes their trackers.
"""
"""Torrent Scanner - Scans for .torrent files and analyzes their trackers."""
import argparse
import glob
import hashlib
import shutil
from pathlib import Path
from collections.abc import Iterator
from dataclasses import dataclass
from typing import Iterator
from pathlib import Path
import bencodepy
from rtorrent_client import RTorrentClient
@@ -40,8 +39,7 @@ class TorrentInfo:
return self.path.parent.parent
def get_expected_data_path(self) -> Path:
"""
Get the expected path where downloaded data should exist.
"""Get the expected path where downloaded data should exist.
For multi-file torrents: download_dir/torrent_name/ (directory)
For single-file torrents: download_dir/torrent_name (file)
@@ -49,11 +47,11 @@ class TorrentInfo:
return self.get_download_directory() / self.name
def verify_download_exists(self) -> tuple[bool, str]:
"""
Verify that the downloaded data exists on disk.
"""Verify that the downloaded data exists on disk.
Returns:
Tuple of (exists: bool, message: str)
"""
expected_path = self.get_expected_data_path()
@@ -69,7 +67,6 @@ class TorrentInfo:
if file_count == 0:
return False, f"Directory exists but is empty: {expected_path}"
return True, f"Directory exists with {file_count} files"
else:
# Single-file torrent: expect a file
if not expected_path.exists():
return False, f"File not found: {expected_path}"
@@ -79,17 +76,17 @@ class TorrentInfo:
def parse_torrent(filepath: Path) -> TorrentInfo | None:
"""
Parse a .torrent file and extract relevant information.
"""Parse a .torrent file and extract relevant information.
Args:
filepath: Path to the .torrent file
Returns:
TorrentInfo object or None if parsing fails
"""
try:
with open(filepath, "rb") as f:
with Path(filepath).open("rb") as f:
data = bencodepy.decode(f.read())
except Exception as e:
print(f"Error parsing {filepath}: {e}")
@@ -154,14 +151,14 @@ def parse_torrent(filepath: Path) -> TorrentInfo | None:
def scan_torrent_directories(paths: list[str]) -> Iterator[Path]:
"""
Scan directories for .torrent files.
"""Scan directories for .torrent files.
Args:
paths: List of directory paths or glob patterns to scan
Yields:
Path objects for each .torrent file found
"""
for pattern in paths:
for dir_path in glob.glob(pattern):
@@ -174,8 +171,7 @@ def scan_torrent_directories(paths: list[str]) -> Iterator[Path]:
def find_torrents_with_tracker(
tracker_domain: str, paths: list[str]
) -> list[TorrentInfo]:
"""
Find all torrents that have a specific tracker domain.
"""Find all torrents that have a specific tracker domain.
Args:
tracker_domain: Domain to search for in tracker URLs (e.g., "hdbits.org")
@@ -183,6 +179,7 @@ def find_torrents_with_tracker(
Returns:
List of TorrentInfo objects for matching torrents
"""
matching_torrents = []
@@ -373,9 +370,8 @@ Examples:
print(f" {status} {download_path}")
else:
print(f" {status} {torrent_info['name']} (no data path)")
else:
# Remove from rtorrent (keeps downloaded files)
if client.remove_torrent(torrent_info["hash"]):
elif client.remove_torrent(torrent_info["hash"]):
removed_from_rtorrent += 1
# Delete the .torrent file if it exists
@@ -403,9 +399,7 @@ Examples:
else:
print(f" [DEL] {torrent_info['name']} (no data)")
else:
print(
f" [ERR] {torrent_info['name']}: failed to remove from rtorrent"
)
print(f" [ERR] {torrent_info['name']}: failed to remove from rtorrent")
print()
if dry_run: