Unsafe fixes.
This commit is contained in:
@@ -1 +1 @@
|
|||||||
"""MediaHive - Media Browser Server"""
|
"""MediaHive - Media Browser Server."""
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ def _derive_name(path: str) -> str:
|
|||||||
return p.name or p.anchor.strip("/\\").lower() or "media"
|
return p.name or p.anchor.strip("/\\").lower() or "media"
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main() -> None:
|
||||||
_configure_windows_event_loop_policy()
|
_configure_windows_event_loop_policy()
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
|
|||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
"""Platform-appropriate config persistence for MediaHive.
|
r"""Platform-appropriate config persistence for MediaHive.
|
||||||
|
|
||||||
Config file location:
|
Config file location:
|
||||||
Windows: %APPDATA%\\mediahive\\config.toml
|
Windows: %APPDATA%\mediahive\config.toml
|
||||||
macOS: ~/Library/Application Support/mediahive/config.toml
|
macOS: ~/Library/Application Support/mediahive/config.toml
|
||||||
Linux: $XDG_CONFIG_HOME/mediahive/config.toml (~/.config/mediahive/config.toml)
|
Linux: $XDG_CONFIG_HOME/mediahive/config.toml (~/.config/mediahive/config.toml)
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ def _configure_windows_event_loop_policy() -> None:
|
|||||||
asyncio.set_event_loop_policy(policy_cls())
|
asyncio.set_event_loop_policy(policy_cls())
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main() -> None:
|
||||||
_configure_windows_event_loop_policy()
|
_configure_windows_event_loop_policy()
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
|
|||||||
@@ -400,10 +400,7 @@ async def _process_movies(
|
|||||||
return await find_playable_file(item.path) is not None
|
return await find_playable_file(item.path) is not None
|
||||||
|
|
||||||
# Filter movies with playable files
|
# Filter movies with playable files
|
||||||
valid_movies = []
|
valid_movies = [item for item in categories[ContentType.MOVIE] if await has_playable(item)]
|
||||||
for item in categories[ContentType.MOVIE]:
|
|
||||||
if await has_playable(item):
|
|
||||||
valid_movies.append(item)
|
|
||||||
skipped = len(categories[ContentType.MOVIE]) - len(valid_movies)
|
skipped = len(categories[ContentType.MOVIE]) - len(valid_movies)
|
||||||
if skipped > 0:
|
if skipped > 0:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
@@ -429,7 +426,7 @@ async def _process_movies(
|
|||||||
len(categories[ContentType.MOVIE]),
|
len(categories[ContentType.MOVIE]),
|
||||||
) if movie_groups else None
|
) if movie_groups else None
|
||||||
|
|
||||||
for idx, (movie_key, items) in enumerate(movie_groups.items(), 1):
|
for idx, (_movie_key, items) in enumerate(movie_groups.items(), 1):
|
||||||
first_item = items[0]
|
first_item = items[0]
|
||||||
logger.debug(
|
logger.debug(
|
||||||
" [%d/%d] %s (%s)",
|
" [%d/%d] %s (%s)",
|
||||||
@@ -658,10 +655,7 @@ async def _process_series(
|
|||||||
return len(await find_episode_files(item.path)) > 0
|
return len(await find_episode_files(item.path)) > 0
|
||||||
|
|
||||||
# Filter series with video content
|
# Filter series with video content
|
||||||
valid_series = []
|
valid_series = [item for item in categories[ContentType.SERIES] if await has_video_content(item)]
|
||||||
for item in categories[ContentType.SERIES]:
|
|
||||||
if await has_video_content(item):
|
|
||||||
valid_series.append(item)
|
|
||||||
skipped = len(categories[ContentType.SERIES]) - len(valid_series)
|
skipped = len(categories[ContentType.SERIES]) - len(valid_series)
|
||||||
if skipped > 0:
|
if skipped > 0:
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -687,7 +681,7 @@ async def _process_series(
|
|||||||
len(categories[ContentType.SERIES]),
|
len(categories[ContentType.SERIES]),
|
||||||
)
|
)
|
||||||
|
|
||||||
for idx, (series_key, items) in enumerate(series_groups.items(), 1):
|
for idx, (_series_key, items) in enumerate(series_groups.items(), 1):
|
||||||
first_item = items[0]
|
first_item = items[0]
|
||||||
logger.debug(" [%d/%d] %s", idx, len(series_groups), first_item.title)
|
logger.debug(" [%d/%d] %s", idx, len(series_groups), first_item.title)
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ defined in ``.mediahive/scanignore`` (gitignore-style syntax).
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import contextlib
|
||||||
import logging
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable
|
||||||
@@ -59,7 +60,7 @@ class RootScanner:
|
|||||||
root_id: str,
|
root_id: str,
|
||||||
media_root: Path,
|
media_root: Path,
|
||||||
send: Send,
|
send: Send,
|
||||||
):
|
) -> None:
|
||||||
self.root_id = root_id
|
self.root_id = root_id
|
||||||
self.media_root = media_root
|
self.media_root = media_root
|
||||||
self._send = send
|
self._send = send
|
||||||
@@ -108,10 +109,8 @@ class RootScanner:
|
|||||||
self._rescan_worker_task,
|
self._rescan_worker_task,
|
||||||
):
|
):
|
||||||
if task and not task.done():
|
if task and not task.done():
|
||||||
try:
|
with contextlib.suppress(TimeoutError, asyncio.CancelledError):
|
||||||
await asyncio.wait_for(task, timeout=2.0)
|
await asyncio.wait_for(task, timeout=2.0)
|
||||||
except TimeoutError, asyncio.CancelledError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def is_scanning(self) -> bool:
|
def is_scanning(self) -> bool:
|
||||||
return self._scan_task is not None and not self._scan_task.done()
|
return self._scan_task is not None and not self._scan_task.done()
|
||||||
@@ -266,7 +265,7 @@ class RootScanner:
|
|||||||
try:
|
try:
|
||||||
root_children = await asyncio.to_thread(lambda: list(root_ap.iterdir()))
|
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)
|
logger.exception("Cannot list media root: %s", self.media_root)
|
||||||
return downloads
|
return downloads
|
||||||
|
|
||||||
for item_async in root_children:
|
for item_async in root_children:
|
||||||
@@ -302,7 +301,7 @@ class RootScanner:
|
|||||||
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
|
||||||
4. Queue showreel tasks
|
4. Queue showreel tasks.
|
||||||
"""
|
"""
|
||||||
task_id = f"scan-{uuid.uuid4().hex[:8]}"
|
task_id = f"scan-{uuid.uuid4().hex[:8]}"
|
||||||
media_root_str = self.media_root.as_posix()
|
media_root_str = self.media_root.as_posix()
|
||||||
@@ -674,10 +673,8 @@ class RootScanner:
|
|||||||
"Showreel worker error (queue size=%d)",
|
"Showreel worker error (queue size=%d)",
|
||||||
self._showreel_queue.qsize(),
|
self._showreel_queue.qsize(),
|
||||||
)
|
)
|
||||||
try:
|
with contextlib.suppress(ValueError):
|
||||||
self._showreel_queue.task_done()
|
self._showreel_queue.task_done()
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import glob
|
import glob
|
||||||
|
import operator
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -216,7 +217,7 @@ async def find_playable_file(path: Path) -> str | None:
|
|||||||
_playable_file_cache[cache_key] = None
|
_playable_file_cache[cache_key] = None
|
||||||
return None
|
return None
|
||||||
|
|
||||||
video_files.sort(key=lambda x: x[1], reverse=True)
|
video_files.sort(key=operator.itemgetter(1), reverse=True)
|
||||||
result = video_files[0][0]
|
result = video_files[0][0]
|
||||||
_playable_file_cache[cache_key] = result
|
_playable_file_cache[cache_key] = result
|
||||||
return result
|
return result
|
||||||
@@ -312,7 +313,7 @@ async def find_metadata_probe_file(playable_path: str | None) -> str | None:
|
|||||||
_bluray_probe_file_cache[cache_key] = None
|
_bluray_probe_file_cache[cache_key] = None
|
||||||
return None
|
return None
|
||||||
|
|
||||||
candidates.sort(key=lambda x: x[1], reverse=True)
|
candidates.sort(key=operator.itemgetter(1), reverse=True)
|
||||||
result = candidates[0][0]
|
result = candidates[0][0]
|
||||||
_bluray_probe_file_cache[cache_key] = result
|
_bluray_probe_file_cache[cache_key] = result
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ and HDR passthrough.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import contextlib
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
import shlex
|
import shlex
|
||||||
@@ -75,7 +76,7 @@ async def _run_ffmpeg(
|
|||||||
stdout, stderr = await proc.communicate()
|
stdout, stderr = await proc.communicate()
|
||||||
except Exception:
|
except Exception:
|
||||||
stdout, stderr = b"", b""
|
stdout, stderr = b"", b""
|
||||||
logger.error(
|
logger.exception(
|
||||||
"ffmpeg command timed out. cmd=%s stderr=%s",
|
"ffmpeg command timed out. cmd=%s stderr=%s",
|
||||||
shlex.join(cmd),
|
shlex.join(cmd),
|
||||||
_decode_stderr(stderr),
|
_decode_stderr(stderr),
|
||||||
@@ -113,10 +114,8 @@ async def _kill_proc(proc: asyncio.subprocess.Process | None) -> None:
|
|||||||
"""Kill a subprocess immediately if it is still running."""
|
"""Kill a subprocess immediately if it is still running."""
|
||||||
if proc is not None and proc.returncode is None:
|
if proc is not None and proc.returncode is None:
|
||||||
proc.kill()
|
proc.kill()
|
||||||
try:
|
with contextlib.suppress(Exception):
|
||||||
await asyncio.wait_for(proc.wait(), timeout=2)
|
await asyncio.wait_for(proc.wait(), timeout=2)
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
# Showreel timestamp positions in seconds (5, 10, 15, 20, 25 minutes)
|
# Showreel timestamp positions in seconds (5, 10, 15, 20, 25 minutes)
|
||||||
@@ -763,7 +762,7 @@ async def generate_showreel_images(
|
|||||||
|
|
||||||
# Detect Dolby Vision profile for tonemapping (profiles 5/7 need conversion)
|
# Detect Dolby Vision profile for tonemapping (profiles 5/7 need conversion)
|
||||||
dovi_profile = await detect_dovi_profile(ffmpeg_input)
|
dovi_profile = await detect_dovi_profile(ffmpeg_input)
|
||||||
needs_tonemap = dovi_profile is not None and dovi_profile in (5, 7)
|
needs_tonemap = dovi_profile is not None and dovi_profile in {5, 7}
|
||||||
if needs_tonemap:
|
if needs_tonemap:
|
||||||
logger.info(" DoVi profile %d detected, will convert to HDR10", dovi_profile)
|
logger.info(" DoVi profile %d detected, will convert to HDR10", dovi_profile)
|
||||||
|
|
||||||
@@ -924,7 +923,7 @@ async def generate_episode_reel(
|
|||||||
|
|
||||||
# Detect Dolby Vision profile for tonemapping (profiles 5/7 need conversion)
|
# Detect Dolby Vision profile for tonemapping (profiles 5/7 need conversion)
|
||||||
dovi_profile = await detect_dovi_profile(ffmpeg_input)
|
dovi_profile = await detect_dovi_profile(ffmpeg_input)
|
||||||
needs_tonemap = dovi_profile is not None and dovi_profile in (5, 7)
|
needs_tonemap = dovi_profile is not None and dovi_profile in {5, 7}
|
||||||
if needs_tonemap:
|
if needs_tonemap:
|
||||||
logger.info(" DoVi profile %d detected, will convert to HDR10", dovi_profile)
|
logger.info(" DoVi profile %d detected, will convert to HDR10", dovi_profile)
|
||||||
|
|
||||||
|
|||||||
@@ -85,14 +85,11 @@ async def _load_from_cache(cache_path: Path):
|
|||||||
return _NOT_FOUND
|
return _NOT_FOUND
|
||||||
|
|
||||||
|
|
||||||
async def _save_to_cache(cache_path: Path, data: dict | None):
|
async def _save_to_cache(cache_path: Path, data: dict | None) -> None:
|
||||||
"""Save response to cache."""
|
"""Save response to cache."""
|
||||||
try:
|
try:
|
||||||
await AsyncPath(_get_cache_dir()).mkdir(parents=True, exist_ok=True)
|
await AsyncPath(_get_cache_dir()).mkdir(parents=True, exist_ok=True)
|
||||||
if data is None:
|
text = json.dumps({"_cached_none": True}) if data is None else json.dumps(data)
|
||||||
text = json.dumps({"_cached_none": True})
|
|
||||||
else:
|
|
||||||
text = json.dumps(data)
|
|
||||||
await AsyncPath(cache_path).write_text(text, encoding="utf-8")
|
await AsyncPath(cache_path).write_text(text, encoding="utf-8")
|
||||||
except Exception:
|
except Exception:
|
||||||
pass # Cache write failures are not critical
|
pass # Cache write failures are not critical
|
||||||
@@ -144,20 +141,18 @@ async def tmdb_api_request(
|
|||||||
async def fetch_movie_details(movie_id: int) -> dict | None:
|
async def fetch_movie_details(movie_id: int) -> dict | None:
|
||||||
"""Fetch detailed movie info including credits, similar, keywords, and alternative titles."""
|
"""Fetch detailed movie info including credits, similar, keywords, and alternative titles."""
|
||||||
# Use append_to_response to get multiple data in one request
|
# Use append_to_response to get multiple data in one request
|
||||||
data = await tmdb_api_request(
|
return await tmdb_api_request(
|
||||||
f"/movie/{movie_id}",
|
f"/movie/{movie_id}",
|
||||||
{"append_to_response": "credits,similar,keywords,alternative_titles"},
|
{"append_to_response": "credits,similar,keywords,alternative_titles"},
|
||||||
)
|
)
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
async def fetch_series_details(series_id: int) -> dict | None:
|
async def fetch_series_details(series_id: int) -> dict | None:
|
||||||
"""Fetch detailed TV series info including credits, similar, and keywords."""
|
"""Fetch detailed TV series info including credits, similar, and keywords."""
|
||||||
# Use append_to_response to get multiple data in one request
|
# Use append_to_response to get multiple data in one request
|
||||||
data = await tmdb_api_request(
|
return await tmdb_api_request(
|
||||||
f"/tv/{series_id}", {"append_to_response": "credits,similar,keywords"}
|
f"/tv/{series_id}", {"append_to_response": "credits,similar,keywords"}
|
||||||
)
|
)
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
async def fetch_season_details(series_id: int, season_number: int) -> SeasonInfo | None:
|
async def fetch_season_details(series_id: int, season_number: int) -> SeasonInfo | None:
|
||||||
@@ -238,12 +233,10 @@ def _generate_title_variants(words: list[str], min_words: int = 2) -> list[str]:
|
|||||||
variants.append(" ".join(words))
|
variants.append(" ".join(words))
|
||||||
|
|
||||||
# Then try removing from end (most common: edition names at end)
|
# Then try removing from end (most common: edition names at end)
|
||||||
for num_words in range(len(words) - 1, min_words - 1, -1):
|
variants.extend(" ".join(words[:num_words]) for num_words in range(len(words) - 1, min_words - 1, -1))
|
||||||
variants.append(" ".join(words[:num_words]))
|
|
||||||
|
|
||||||
# Then try removing from start (garbage at beginning)
|
# Then try removing from start (garbage at beginning)
|
||||||
for start in range(1, len(words) - min_words + 1):
|
variants.extend(" ".join(words[start:]) for start in range(1, len(words) - min_words + 1))
|
||||||
variants.append(" ".join(words[start:]))
|
|
||||||
|
|
||||||
# Finally try middle portions (remove from both ends)
|
# Finally try middle portions (remove from both ends)
|
||||||
for start in range(1, len(words) - min_words):
|
for start in range(1, len(words) - min_words):
|
||||||
|
|||||||
@@ -184,7 +184,7 @@ async def find_common_root(paths: list[Path]) -> Path | None:
|
|||||||
|
|
||||||
# Find common prefix
|
# Find common prefix
|
||||||
common_parts = []
|
common_parts = []
|
||||||
for parts in zip(*all_parts):
|
for parts in zip(*all_parts, strict=False):
|
||||||
if len(set(parts)) == 1:
|
if len(set(parts)) == 1:
|
||||||
common_parts.append(parts[0])
|
common_parts.append(parts[0])
|
||||||
else:
|
else:
|
||||||
@@ -216,8 +216,7 @@ def sanitize_filename(name: str) -> str:
|
|||||||
"""Sanitize a string for use as a filename."""
|
"""Sanitize a string for use as a filename."""
|
||||||
for char in ["/", "\\", ":", "*", "?", '"', "<", ">", "|"]:
|
for char in ["/", "\\", ":", "*", "?", '"', "<", ">", "|"]:
|
||||||
name = name.replace(char, "_")
|
name = name.replace(char, "_")
|
||||||
name = name.strip(". ")
|
return name.strip(". ")
|
||||||
return name
|
|
||||||
|
|
||||||
|
|
||||||
def get_media_folder_name(title: str, year: int | None, media_type: str) -> str:
|
def get_media_folder_name(title: str, year: int | None, media_type: str) -> str:
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ debounced background task.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import contextlib
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -51,7 +52,7 @@ class IndexStore:
|
|||||||
snapshot_path: Path,
|
snapshot_path: Path,
|
||||||
media_root: str | None = None,
|
media_root: str | None = None,
|
||||||
root_id: str | None = None,
|
root_id: str | None = None,
|
||||||
):
|
) -> None:
|
||||||
self.snapshot_path = snapshot_path
|
self.snapshot_path = snapshot_path
|
||||||
self.media_root = media_root
|
self.media_root = media_root
|
||||||
self.root_id = root_id
|
self.root_id = root_id
|
||||||
@@ -205,10 +206,8 @@ class IndexStore:
|
|||||||
"""Force-write a snapshot immediately (e.g. on shutdown)."""
|
"""Force-write a snapshot immediately (e.g. on shutdown)."""
|
||||||
if self._snapshot_task and not self._snapshot_task.done():
|
if self._snapshot_task and not self._snapshot_task.done():
|
||||||
self._snapshot_task.cancel()
|
self._snapshot_task.cancel()
|
||||||
try:
|
with contextlib.suppress(asyncio.CancelledError):
|
||||||
await self._snapshot_task
|
await self._snapshot_task
|
||||||
except asyncio.CancelledError:
|
|
||||||
pass
|
|
||||||
await self._write_snapshot()
|
await self._write_snapshot()
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import contextlib
|
||||||
import hashlib
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -86,7 +87,7 @@ class RootEntry(msgspec.Struct):
|
|||||||
class RootContext:
|
class RootContext:
|
||||||
"""Runtime container for a single media root."""
|
"""Runtime container for a single media root."""
|
||||||
|
|
||||||
def __init__(self, root_id: str, root_path: Path, name: str | None = None):
|
def __init__(self, root_id: str, root_path: Path, name: str | None = None) -> None:
|
||||||
self.root_id = root_id
|
self.root_id = root_id
|
||||||
self.name = name or root_id
|
self.name = name or root_id
|
||||||
self.root_path = root_path
|
self.root_path = root_path
|
||||||
@@ -134,10 +135,8 @@ class RootContext:
|
|||||||
|
|
||||||
if self._consumer_task and not self._consumer_task.done():
|
if self._consumer_task and not self._consumer_task.done():
|
||||||
self._consumer_task.cancel()
|
self._consumer_task.cancel()
|
||||||
try:
|
with contextlib.suppress(asyncio.CancelledError):
|
||||||
await self._consumer_task
|
await self._consumer_task
|
||||||
except asyncio.CancelledError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await self.store.flush_snapshot()
|
await self.store.flush_snapshot()
|
||||||
@@ -175,7 +174,7 @@ class RootContext:
|
|||||||
class Supervisor:
|
class Supervisor:
|
||||||
"""Manages the active set of RootContexts and handles atomic replacement."""
|
"""Manages the active set of RootContexts and handles atomic replacement."""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self) -> None:
|
||||||
# Active contexts keyed by root_id
|
# Active contexts keyed by root_id
|
||||||
self._contexts: dict[str, RootContext] = {}
|
self._contexts: dict[str, RootContext] = {}
|
||||||
self._lock = asyncio.Lock()
|
self._lock = asyncio.Lock()
|
||||||
@@ -211,7 +210,7 @@ class Supervisor:
|
|||||||
total_movie_versions = 0
|
total_movie_versions = 0
|
||||||
total_series_episodes = 0
|
total_series_episodes = 0
|
||||||
for ctx in self._contexts.values():
|
for ctx in self._contexts.values():
|
||||||
if ctx.status != "ready" and ctx.status != "scanning":
|
if ctx.status not in {"ready", "scanning"}:
|
||||||
continue
|
continue
|
||||||
movies.extend(ctx.store.movies.values())
|
movies.extend(ctx.store.movies.values())
|
||||||
series.extend(ctx.store.series.values())
|
series.extend(ctx.store.series.values())
|
||||||
|
|||||||
+5
-10
@@ -18,7 +18,7 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager, suppress
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import aiofiles
|
import aiofiles
|
||||||
@@ -248,10 +248,7 @@ def _etag_matches_if_none_match(if_none_match: str | None, etag: str) -> bool:
|
|||||||
return v
|
return v
|
||||||
|
|
||||||
wanted = normalize(etag)
|
wanted = normalize(etag)
|
||||||
for candidate in if_none_match.split(","):
|
return any(normalize(candidate) == wanted for candidate in if_none_match.split(","))
|
||||||
if normalize(candidate) == wanted:
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_root_paths(roots: dict[str, str]) -> dict[str, str]:
|
def _validate_root_paths(roots: dict[str, str]) -> dict[str, str]:
|
||||||
@@ -283,7 +280,7 @@ def _validate_root_paths(roots: dict[str, str]) -> dict[str, str]:
|
|||||||
async def _attach_scanners() -> None:
|
async def _attach_scanners() -> None:
|
||||||
"""Ensure every active root context has a running scanner."""
|
"""Ensure every active root context has a running scanner."""
|
||||||
for ctx in supervisor.all_contexts().values():
|
for ctx in supervisor.all_contexts().values():
|
||||||
if ctx.scanner is None and ctx.status in ("ready", "loading"):
|
if ctx.scanner is None and ctx.status in {"ready", "loading"}:
|
||||||
try:
|
try:
|
||||||
scanner = RootScanner(ctx.root_id, ctx.root_path, ctx.send_event)
|
scanner = RootScanner(ctx.root_id, ctx.root_path, ctx.send_event)
|
||||||
await scanner.start()
|
await scanner.start()
|
||||||
@@ -361,10 +358,8 @@ async def lifespan(app: FastAPI):
|
|||||||
yield
|
yield
|
||||||
|
|
||||||
activation_task.cancel()
|
activation_task.cancel()
|
||||||
try:
|
with suppress(asyncio.CancelledError):
|
||||||
await activation_task
|
await activation_task
|
||||||
except asyncio.CancelledError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
await supervisor.shutdown()
|
await supervisor.shutdown()
|
||||||
|
|
||||||
@@ -464,7 +459,7 @@ async def trigger_root_scan(root_id: str):
|
|||||||
|
|
||||||
|
|
||||||
@app.websocket("/api/roots/{root_id}/ws")
|
@app.websocket("/api/roots/{root_id}/ws")
|
||||||
async def ws_endpoint(ws: WebSocket, root_id: str):
|
async def ws_endpoint(ws: WebSocket, root_id: str) -> None:
|
||||||
"""Live index updates and task progress for a single root."""
|
"""Live index updates and task progress for a single root."""
|
||||||
ctx = supervisor.get(root_id)
|
ctx = supervisor.get(root_id)
|
||||||
if ctx is None:
|
if ctx is None:
|
||||||
|
|||||||
+3
-3
@@ -9,7 +9,7 @@ from pathlib import Path
|
|||||||
class SCGITransport(xmlrpc.client.Transport):
|
class SCGITransport(xmlrpc.client.Transport):
|
||||||
"""SCGI transport for communicating with rtorrent via Unix socket."""
|
"""SCGI transport for communicating with rtorrent via Unix socket."""
|
||||||
|
|
||||||
def __init__(self, socket_path: str):
|
def __init__(self, socket_path: str) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.socket_path = socket_path
|
self.socket_path = socket_path
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@ class SCGITransport(xmlrpc.client.Transport):
|
|||||||
class RTorrentClient:
|
class RTorrentClient:
|
||||||
"""Client for communicating with rtorrent via XMLRPC over SCGI socket."""
|
"""Client for communicating with rtorrent via XMLRPC over SCGI socket."""
|
||||||
|
|
||||||
def __init__(self, socket_path: str = "/home/user/rtorrent/.session/rpc.socket"):
|
def __init__(self, socket_path: str = "/home/user/rtorrent/.session/rpc.socket") -> None:
|
||||||
self.socket_path = socket_path
|
self.socket_path = socket_path
|
||||||
transport = SCGITransport(socket_path)
|
transport = SCGITransport(socket_path)
|
||||||
self.proxy = xmlrpc.client.ServerProxy(
|
self.proxy = xmlrpc.client.ServerProxy(
|
||||||
@@ -59,7 +59,7 @@ class RTorrentClient:
|
|||||||
"""Get set of info hashes for all currently loaded torrents."""
|
"""Get set of info hashes for all currently loaded torrents."""
|
||||||
try:
|
try:
|
||||||
downloads = self.proxy.download_list("")
|
downloads = self.proxy.download_list("")
|
||||||
return set(h.upper() for h in downloads)
|
return {h.upper() for h in downloads}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error getting loaded torrents: {e}")
|
print(f"Error getting loaded torrents: {e}")
|
||||||
return set()
|
return set()
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ async def run_devserver(
|
|||||||
await pg.spawn(*vite, cwd=front)
|
await pg.spawn(*vite, cwd=front)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main() -> None:
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description="Run Vite and FastAPI development servers",
|
description="Run Vite and FastAPI development servers",
|
||||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
|||||||
@@ -10,6 +10,6 @@ from buildutil import build
|
|||||||
|
|
||||||
|
|
||||||
class CustomBuildHook(BuildHookInterface):
|
class CustomBuildHook(BuildHookInterface):
|
||||||
def initialize(self, version, build_data):
|
def initialize(self, version, build_data) -> None:
|
||||||
super().initialize(version, build_data)
|
super().initialize(version, build_data)
|
||||||
build("frontend")
|
build("frontend")
|
||||||
|
|||||||
@@ -178,7 +178,7 @@ def build(folder: str = "frontend") -> None:
|
|||||||
logger.warning(e)
|
logger.warning(e)
|
||||||
raise SystemExit(1)
|
raise SystemExit(1)
|
||||||
|
|
||||||
def run(cmd):
|
def run(cmd) -> None:
|
||||||
display_cmd = [Path(cmd[0]).stem, *cmd[1:]]
|
display_cmd = [Path(cmd[0]).stem, *cmd[1:]]
|
||||||
logger.info("### %s", " ".join(display_cmd))
|
logger.info("### %s", " ".join(display_cmd))
|
||||||
subprocess.run(cmd, check=True, cwd=folder)
|
subprocess.run(cmd, check=True, cwd=folder)
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ from fastapi_vue.hostutil import parse_endpoint
|
|||||||
class ProcessGroup:
|
class ProcessGroup:
|
||||||
"""Manage async subprocesses with automatic cleanup, like TaskGroup for processes."""
|
"""Manage async subprocesses with automatic cleanup, like TaskGroup for processes."""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self) -> None:
|
||||||
self._procs: list[asyncio.subprocess.Process] = []
|
self._procs: list[asyncio.subprocess.Process] = []
|
||||||
self._cmds: dict[int, str] = {} # pid -> command name
|
self._cmds: dict[int, str] = {} # pid -> command name
|
||||||
|
|
||||||
@@ -59,7 +59,7 @@ class ProcessGroup:
|
|||||||
"""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)
|
||||||
|
|
||||||
async def _cleanup(self, immediate: bool = False):
|
async def _cleanup(self, immediate: bool = False) -> None:
|
||||||
running = [p for p in self._procs if p.returncode is None]
|
running = [p for p in self._procs if p.returncode is None]
|
||||||
if not running:
|
if not running:
|
||||||
return
|
return
|
||||||
|
|||||||
+1
-2
@@ -84,13 +84,12 @@ def find_dist_files(version: str) -> list[Path]:
|
|||||||
Raises FileNotFoundError listing every missing file if any are absent.
|
Raises FileNotFoundError listing every missing file if any are absent.
|
||||||
"""
|
"""
|
||||||
dist_dir = REPO_ROOT / "dist"
|
dist_dir = REPO_ROOT / "dist"
|
||||||
ver = re.escape(version)
|
|
||||||
wheel = next((p for p in dist_dir.glob(f"mediahive-{version}-*.whl")), None)
|
wheel = next((p for p in dist_dir.glob(f"mediahive-{version}-*.whl")), None)
|
||||||
sdist = next(
|
sdist = next(
|
||||||
(
|
(
|
||||||
p
|
p
|
||||||
for p in dist_dir.glob(f"mediahive-{version}.*")
|
for p in dist_dir.glob(f"mediahive-{version}.*")
|
||||||
if p.suffix in (".gz", ".zip") and p.name != f"mediahive-{version}.zip"
|
if p.suffix in {".gz", ".zip"} and p.name != f"mediahive-{version}.zip"
|
||||||
),
|
),
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -164,8 +164,7 @@ def scan_torrent_directories(paths: list[str]) -> Iterator[Path]:
|
|||||||
for dir_path in glob.glob(pattern):
|
for dir_path in glob.glob(pattern):
|
||||||
torrent_dir = Path(dir_path)
|
torrent_dir = Path(dir_path)
|
||||||
if torrent_dir.is_dir():
|
if torrent_dir.is_dir():
|
||||||
for torrent_file in torrent_dir.glob("*.torrent"):
|
yield from torrent_dir.glob("*.torrent")
|
||||||
yield torrent_file
|
|
||||||
|
|
||||||
|
|
||||||
def find_torrents_with_tracker(
|
def find_torrents_with_tracker(
|
||||||
@@ -203,7 +202,7 @@ def format_size(size_bytes: int | None) -> str:
|
|||||||
return f"{size_bytes:.2f} PB"
|
return f"{size_bytes:.2f} PB"
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main() -> None:
|
||||||
"""Main entry point for the torrent scanner."""
|
"""Main entry point for the torrent scanner."""
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description="Scan and manage torrent files",
|
description="Scan and manage torrent files",
|
||||||
|
|||||||
Reference in New Issue
Block a user