Misc. fixes #2.
This commit is contained in:
@@ -1,3 +1,5 @@
|
|||||||
|
"""MediaHive CLI entrypoint."""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
"""Hivescan CLI entrypoint."""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"],
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user