Misc. fixes #2.

This commit is contained in:
2026-05-25 02:37:51 +00:00
parent e86f1e22de
commit 17c5a47936
15 changed files with 46 additions and 29 deletions
+2
View File
@@ -1,3 +1,5 @@
"""MediaHive CLI entrypoint."""
import argparse import argparse
import asyncio import asyncio
import json import json
+2
View File
@@ -1,3 +1,5 @@
"""Hivescan CLI entrypoint."""
import argparse import argparse
import asyncio import asyncio
import json import json
+8 -4
View File
@@ -383,7 +383,9 @@ async def _process_movies(
) -> AsyncIterator[tuple[Movie, tuple[str, Path, str] | None]]: ) -> AsyncIterator[tuple[Movie, tuple[str, Path, str] | None]]:
"""Async generator that processes all movies. """Async generator that processes all movies.
Yields (Movie, showreel_task_or_None) for each movie as it is processed. Yields:
Tuples of ``(Movie, showreel_task_or_None)`` as each movie is processed.
""" """
# In-memory cache for TMDb lookups # In-memory cache for TMDb lookups
movie_tmdb_cache: dict[str, Info | None] = {} movie_tmdb_cache: dict[str, Info | None] = {}
@@ -555,7 +557,7 @@ async def _process_movies(
yield movie, showreel_task yield movie, showreel_task
# Process movies without TMDb info # Process movies without TMDb info
for key, group_data in no_tmdb_movie_groups.items(): for group_data in no_tmdb_movie_groups.values():
items = group_data["items"] items = group_data["items"]
title = group_data["title"] title = group_data["title"]
year = group_data["year"] year = group_data["year"]
@@ -637,7 +639,9 @@ async def _process_series(
) -> AsyncIterator[tuple[Series, list[tuple[str, Path, int, int, str]]]]: ) -> AsyncIterator[tuple[Series, list[tuple[str, Path, int, int, str]]]]:
"""Async generator that processes all series. """Async generator that processes all series.
Yields (Series, episode_reel_tasks) for each series as it is processed. Yields:
Tuples of ``(Series, episode_reel_tasks)`` as each series is processed.
""" """
# In-memory cache for TMDb lookups # In-memory cache for TMDb lookups
series_tmdb_cache: dict[str, Info | None] = {} series_tmdb_cache: dict[str, Info | None] = {}
@@ -787,7 +791,7 @@ async def _process_series(
yield series, ep_reel_tasks yield series, ep_reel_tasks
# Process series without TMDb info # Process series without TMDb info
for key, group_data in no_tmdb_groups.items(): for group_data in no_tmdb_groups.values():
items = group_data["items"] items = group_data["items"]
title = group_data["title"] title = group_data["title"]
content_hash = hashlib.md5(f"series:{title}".encode()).hexdigest()[:12] content_hash = hashlib.md5(f"series:{title}".encode()).hexdigest()[:12]
+5 -4
View File
@@ -162,8 +162,8 @@ class RootScanner:
) )
) )
MEDIA_CONTAINER_DIRS = {"BDMV", "VIDEO_TS", "HVDVD_TS"} media_container_dirs = {"BDMV", "VIDEO_TS", "HVDVD_TS"}
VIDEO_EXTENSIONS = { video_extensions = {
".mkv", ".mkv",
".mp4", ".mp4",
".avi", ".avi",
@@ -196,7 +196,7 @@ class RootScanner:
continue continue
if await AsyncPath(item).is_dir(): if await AsyncPath(item).is_dir():
if item.name.upper() in MEDIA_CONTAINER_DIRS: if item.name.upper() in media_container_dirs:
is_media_container = True is_media_container = True
child_dirs.append(item) child_dirs.append(item)
else: else:
@@ -232,7 +232,7 @@ class RootScanner:
await _walk(child) await _walk(child)
await asyncio.sleep(0) await asyncio.sleep(0)
for child_file in child_files: for child_file in child_files:
if child_file.suffix.lower() in VIDEO_EXTENSIONS: if child_file.suffix.lower() in video_extensions:
relpath = make_relative_path(str(child_file), media_root_str) relpath = make_relative_path(str(child_file), media_root_str)
try: try:
stat_info = await AsyncPath(child_file).stat() stat_info = await AsyncPath(child_file).stat()
@@ -298,6 +298,7 @@ class RootScanner:
async def _run_scan(self) -> None: async def _run_scan(self) -> None:
"""Full scan pipeline: """Full scan pipeline:
1. Discover downloads 1. Discover downloads
2. Categorise → movies / series 2. Categorise → movies / series
3. Iterate async generators, send each item as Upsert 3. Iterate async generators, send each item as Upsert
+5 -5
View File
@@ -411,8 +411,8 @@ async def fetch_movie_info(title: str, year: int | None = None) -> Info | None:
alternative_titles = sorted(alt_titles_set) if alt_titles_set else None alternative_titles = sorted(alt_titles_set) if alt_titles_set else None
# Extract full cast # Extract full cast
credits = details.get("credits", {}) credits_data = details.get("credits", {})
cast_data = credits.get("cast", []) cast_data = credits_data.get("cast", [])
cast = [ cast = [
CastMember( CastMember(
name=c["name"], name=c["name"],
@@ -424,7 +424,7 @@ async def fetch_movie_info(title: str, year: int | None = None) -> Info | None:
] ]
# Extract director from crew # Extract director from crew
crew = credits.get("crew", []) crew = credits_data.get("crew", [])
directors = [c["name"] for c in crew if c.get("job") == "Director"] directors = [c["name"] for c in crew if c.get("job") == "Director"]
director = directors[0] if directors else None director = directors[0] if directors else None
@@ -512,8 +512,8 @@ async def fetch_series_info(title: str) -> Info | None:
keywords = [k["name"] for k in keywords_data] keywords = [k["name"] for k in keywords_data]
# Extract full cast # Extract full cast
credits = details.get("credits", {}) credits_data = details.get("credits", {})
cast_data = credits.get("cast", []) cast_data = credits_data.get("cast", [])
cast = [ cast = [
CastMember( CastMember(
name=c["name"], name=c["name"],
+1 -1
View File
@@ -96,7 +96,7 @@ class IndexStore:
logger.exception("Failed to load snapshot from %s", self.snapshot_path) logger.exception("Failed to load snapshot from %s", self.snapshot_path)
def _load_snapshot_sync(self, raw: bytes) -> None: def _load_snapshot_sync(self, raw: bytes) -> None:
"""Synchronous snapshot parsing (runs in thread pool).""" """Parse snapshot bytes in a thread-pool context."""
data = msgspec.json.decode(raw, type=IndexSnapshot) data = msgspec.json.decode(raw, type=IndexSnapshot)
for m in data.movies: for m in data.movies:
if m.showreel_source_sets: if m.showreel_source_sets:
+2 -1
View File
@@ -116,7 +116,8 @@ class IndexSnapshot(msgspec.Struct):
movies: list[Movie] = [] movies: list[Movie] = []
series: list[Series] = [] series: list[Series] = []
def __post_init__(self): def __post_init__(self) -> None:
"""Populate default stats when omitted from decoded payload."""
if self.stats is msgspec.UNSET: if self.stats is msgspec.UNSET:
self.stats = MediaStats() self.stats = MediaStats()
+1 -1
View File
@@ -144,7 +144,7 @@ class RootContext:
logger.exception("Error flushing snapshot for root %s", self.root_id) logger.exception("Error flushing snapshot for root %s", self.root_id)
async def send_event(self, event: ScanEvent) -> None: async def send_event(self, event: ScanEvent) -> None:
"""Called by the scanner to push an event into this root's queue.""" """Push a scanner event into this root's queue."""
await self._events.put(event) await self._events.put(event)
async def _consume_events(self) -> None: async def _consume_events(self) -> None:
+1 -2
View File
@@ -578,8 +578,7 @@ def _start_gamepad_remote(
def _setup_logging() -> Path: def _setup_logging() -> Path:
"""Redirect stdout/stderr and configure logging to a file in """Redirect stdout/stderr and configure logging to a file in %APPDATA%/mediahive/.
%APPDATA%/mediahive/.
In a PyInstaller --windowed build there is no console, so any print() or In a PyInstaller --windowed build there is no console, so any print() or
unhandled exception traceback would be lost. This ensures everything ends unhandled exception traceback would be lost. This ensures everything ends
+2 -1
View File
@@ -13,7 +13,7 @@ class SCGITransport(xmlrpc.client.Transport):
super().__init__() super().__init__()
self.socket_path = socket_path self.socket_path = socket_path
def single_request(self, host, handler, request_body, verbose=False): def single_request(self, _host, _handler, request_body, _verbose=False):
# Create SCGI request # Create SCGI request
headers = f"CONTENT_LENGTH\x00{len(request_body)}\x00SCGI\x001\x00" headers = f"CONTENT_LENGTH\x00{len(request_body)}\x00SCGI\x001\x00"
request = f"{len(headers)}:{headers},{request_body.decode('utf-8')}" request = f"{len(headers)}:{headers},{request_body.decode('utf-8')}"
@@ -68,6 +68,7 @@ class RTorrentClient:
def load_torrent(self, torrent_path: Path, download_dir: Path) -> bool: 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. Uses load.start_verbose to load and immediately start/hash-check.
Args: Args:
+1 -1
View File
@@ -11,7 +11,7 @@ from pathlib import Path
# Import util.py from scripts/fastapi-vue (not a package, so we adjust sys.path) # Import util.py from scripts/fastapi-vue (not a package, so we adjust sys.path)
sys.path.insert(0, str(Path(__file__).with_name("fastapi-vue"))) sys.path.insert(0, str(Path(__file__).with_name("fastapi-vue")))
from devutil import ( # type: ignore from devutil import ( # type: ignore[import-not-found]
ProcessGroup, ProcessGroup,
check_ports_free, check_ports_free,
logger, logger,
+3 -1
View File
@@ -3,7 +3,9 @@
import sys import sys
from pathlib import Path from pathlib import Path
from hatchling.builders.hooks.plugin.interface import BuildHookInterface # type: ignore from hatchling.builders.hooks.plugin.interface import ( # type: ignore[import-not-found]
BuildHookInterface,
)
sys.path.insert(0, str(Path(__file__).parent)) sys.path.insert(0, str(Path(__file__).parent))
from buildutil import build from buildutil import build
+8 -3
View File
@@ -9,7 +9,7 @@ import sys
from collections.abc import Coroutine from collections.abc import Coroutine
from contextlib import suppress from contextlib import suppress
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any, Self
import httpx import httpx
from buildutil import find_dev_tool, find_install_tool, logger from buildutil import find_dev_tool, find_install_tool, logger
@@ -58,10 +58,15 @@ class ProcessGroup:
logger.warning("%s failed with exit status %d", e.cmd, e.returncode) logger.warning("%s failed with exit status %d", e.cmd, e.returncode)
raise SystemExit(1) from None raise SystemExit(1) from None
async def __aenter__(self): async def __aenter__(self) -> Self:
"""Return this process group context manager."""
return self return self
async def __aexit__(self, exc_type, *_): async def __aexit__(
self,
exc_type: type[BaseException] | None,
*_: object,
) -> None:
"""Wait for one process to exit, terminate others, then wait for all.""" """Wait for one process to exit, terminate others, then wait for all."""
await self._cleanup(immediate=exc_type is not None) await self._cleanup(immediate=exc_type is not None)
+4 -4
View File
@@ -74,8 +74,8 @@ def fetch_ffmpeg() -> Path:
ffmpeg_entry = next( ffmpeg_entry = next(
name for name in zf.namelist() if name.endswith("/bin/ffmpeg.exe") name for name in zf.namelist() if name.endswith("/bin/ffmpeg.exe")
) )
with zf.open(ffmpeg_entry) as src, Path(dest).open("wb") as out: with zf.open(ffmpeg_entry) as src:
out.write(src.read()) Path(dest).write_bytes(src.read())
print(f"ffmpeg staged at {dest} ({dest.stat().st_size // 1024 // 1024} MB)") print(f"ffmpeg staged at {dest} ({dest.stat().st_size // 1024 // 1024} MB)")
return dest return dest
@@ -109,8 +109,8 @@ def fetch_macos_arm64_binaries() -> dict[str, Path]:
for name in zf.namelist() for name in zf.namelist()
if Path(name).name == tool_name and not name.endswith("/") if Path(name).name == tool_name and not name.endswith("/")
) )
with zf.open(entry_name) as src, Path(dest).open("wb") as out: with zf.open(entry_name) as src:
out.write(src.read()) Path(dest).write_bytes(src.read())
dest.chmod(dest.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) dest.chmod(dest.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
+1 -1
View File
@@ -202,7 +202,7 @@ def format_size(size_bytes: int | None) -> str:
def main() -> None: def main() -> None:
"""Main entry point for the torrent scanner.""" """Run the torrent scanner command-line workflow."""
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="Scan and manage torrent files", description="Scan and manage torrent files",
formatter_class=argparse.RawDescriptionHelpFormatter, formatter_class=argparse.RawDescriptionHelpFormatter,