Cleanup
This commit is contained in:
@@ -1,2 +1 @@
|
|||||||
"""MediaHive - Media Browser Server"""
|
"""MediaHive - Media Browser Server"""
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,11 @@ DEVMODE = bool(os.getenv("MEDIAHIVE_FRONTEND_URL"))
|
|||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
parser = argparse.ArgumentParser(description="MediaHive - Media scanning, indexing, and streaming")
|
parser = argparse.ArgumentParser(
|
||||||
|
description="MediaHive - Media scanning, indexing, and streaming"
|
||||||
|
)
|
||||||
|
# TODO: Accept .mediahive root folder directly from CLI.
|
||||||
|
# Future: use gitignore-style system (file in .mediahive folder) for path determination.
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"media_folder",
|
"media_folder",
|
||||||
nargs="?",
|
nargs="?",
|
||||||
|
|||||||
@@ -1,75 +1,21 @@
|
|||||||
"""
|
"""
|
||||||
Hivescan - Continuous media scanning with live WebSocket updates.
|
Hivescan - Continuous media scanning with live WebSocket updates.
|
||||||
|
|
||||||
Usage as a module:
|
Import from submodules directly:
|
||||||
python -m mediahive.hivescan /path/to/torrents/*
|
from mediahive.hivescan.scanner import start, stop
|
||||||
python -m mediahive.hivescan /path/* --port 9000
|
|
||||||
|
|
||||||
Or as a library:
|
|
||||||
from mediahive.hivescan.scanning import scan_downloads, categorize_downloads
|
from mediahive.hivescan.scanning import scan_downloads, categorize_downloads
|
||||||
from mediahive.hivescan.indexer import _process_movies, _process_series
|
from mediahive.hivescan.models import ContentType, ParsedContent
|
||||||
|
from mediahive.hivescan.tmdb_client import fetch_movie_info, fetch_series_info
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# Minimal public API - prefer importing from submodules directly
|
||||||
from mediahive.hivescan.models import ContentType, ContentHash, ParsedContent
|
from mediahive.hivescan.models import ContentType, ContentHash, ParsedContent
|
||||||
from mediahive.hivescan.scanning import (
|
|
||||||
scan_downloads,
|
|
||||||
categorize_downloads,
|
|
||||||
find_playable_file,
|
|
||||||
find_episode_files,
|
|
||||||
)
|
|
||||||
from mediahive.hivescan.utils import DEFAULT_OUTPUT_FOLDER, find_common_root
|
from mediahive.hivescan.utils import DEFAULT_OUTPUT_FOLDER, find_common_root
|
||||||
from mediahive.hivescan.showreel import generate_showreel_images, generate_episode_reel
|
|
||||||
from mediahive.hivescan import scanner
|
|
||||||
from mediahive.models.data import (
|
|
||||||
Episode,
|
|
||||||
IndexSnapshot,
|
|
||||||
MediaStats,
|
|
||||||
Movie,
|
|
||||||
Season,
|
|
||||||
Series,
|
|
||||||
TaskInfo,
|
|
||||||
Torrent,
|
|
||||||
)
|
|
||||||
from mediahive.hivescan.tmdb_client import (
|
|
||||||
fetch_movie_info,
|
|
||||||
fetch_series_info,
|
|
||||||
fetch_season_details,
|
|
||||||
set_cache_dir,
|
|
||||||
)
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
# Models
|
|
||||||
"ContentType",
|
"ContentType",
|
||||||
"ContentHash",
|
"ContentHash",
|
||||||
"ParsedContent",
|
"ParsedContent",
|
||||||
# Scanning
|
|
||||||
"scan_downloads",
|
|
||||||
"categorize_downloads",
|
|
||||||
"find_playable_file",
|
|
||||||
"find_episode_files",
|
|
||||||
# Struct types
|
|
||||||
"CastMember",
|
|
||||||
"Episode",
|
|
||||||
"IndexSnapshot",
|
|
||||||
"MediaStats",
|
|
||||||
"Movie",
|
|
||||||
"Season",
|
|
||||||
"Series",
|
|
||||||
"SimilarMedia",
|
|
||||||
"TaskInfo",
|
|
||||||
"Torrent",
|
|
||||||
"EpisodeInfo",
|
|
||||||
"Info",
|
|
||||||
"SeasonInfo",
|
|
||||||
# Showreel generation
|
|
||||||
"generate_showreel_images",
|
|
||||||
"generate_episode_reel",
|
|
||||||
# TMDb client
|
|
||||||
"fetch_movie_info",
|
|
||||||
"fetch_series_info",
|
|
||||||
"fetch_season_details",
|
|
||||||
"set_cache_dir",
|
|
||||||
# Utilities
|
|
||||||
"DEFAULT_OUTPUT_FOLDER",
|
"DEFAULT_OUTPUT_FOLDER",
|
||||||
"find_common_root",
|
"find_common_root",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import logging
|
|||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from mediahive.hivescan.utils import DEFAULT_OUTPUT_FOLDER, find_common_root
|
from mediahive.hivescan.utils import find_common_root
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
@@ -24,7 +24,7 @@ The server exposes:
|
|||||||
POST /api/scan Trigger a new scan
|
POST /api/scan Trigger a new scan
|
||||||
GET /api/status Current server status
|
GET /api/status Current server status
|
||||||
GET /api/index Full index as JSON (HTTP fallback)
|
GET /api/index Full index as JSON (HTTP fallback)
|
||||||
"""
|
""",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"paths",
|
"paths",
|
||||||
@@ -51,25 +51,30 @@ The server exposes:
|
|||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
# Derive the media root so we can set MEDIAHIVE_PATH
|
# TODO: Take .mediahive root folder from CLI directly.
|
||||||
all_paths: list[Path] = []
|
# Future: use gitignore-style system (file in .mediahive folder) for path determination.
|
||||||
for pattern in args.paths:
|
|
||||||
expanded = glob.glob(pattern)
|
|
||||||
if expanded:
|
|
||||||
all_paths.extend(Path(p) for p in expanded)
|
|
||||||
else:
|
|
||||||
all_paths.append(Path(pattern))
|
|
||||||
|
|
||||||
|
# Derive media_root only if no explicit output-dir is given
|
||||||
if args.output_dir:
|
if args.output_dir:
|
||||||
media_root = Path(args.output_dir).parent
|
media_root = Path(args.output_dir).parent.resolve()
|
||||||
else:
|
else:
|
||||||
|
# Expand globs once to find common root
|
||||||
|
all_paths: list[Path] = []
|
||||||
|
for pattern in args.paths:
|
||||||
|
expanded = glob.glob(pattern)
|
||||||
|
if expanded:
|
||||||
|
all_paths.extend(Path(p) for p in expanded)
|
||||||
|
else:
|
||||||
|
all_paths.append(Path(pattern))
|
||||||
|
|
||||||
media_root = asyncio.run(find_common_root(all_paths))
|
media_root = asyncio.run(find_common_root(all_paths))
|
||||||
if media_root is None:
|
if media_root is None:
|
||||||
print("Error: Cannot determine common root; use -o to set output directory")
|
print("Error: Cannot determine common root; use -o to set output directory")
|
||||||
exit(1)
|
exit(1)
|
||||||
|
media_root = media_root.resolve()
|
||||||
|
|
||||||
# Configure environment for the mediahive server + scanner
|
# Configure environment - scanner will re-expand patterns from HIVESCAN_PATHS
|
||||||
os.environ["MEDIAHIVE_PATH"] = str(media_root.resolve())
|
os.environ["MEDIAHIVE_PATH"] = str(media_root)
|
||||||
os.environ["HIVESCAN_PATHS"] = os.pathsep.join(args.paths)
|
os.environ["HIVESCAN_PATHS"] = os.pathsep.join(args.paths)
|
||||||
if args.output_dir:
|
if args.output_dir:
|
||||||
os.environ["HIVESCAN_OUTPUT"] = args.output_dir
|
os.environ["HIVESCAN_OUTPUT"] = args.output_dir
|
||||||
@@ -81,7 +86,10 @@ The server exposes:
|
|||||||
)
|
)
|
||||||
|
|
||||||
import uvicorn
|
import uvicorn
|
||||||
uvicorn.run("mediahive.server:app", host=args.host, port=args.port, log_level="info")
|
|
||||||
|
uvicorn.run(
|
||||||
|
"mediahive.server:app", host=args.host, port=args.port, log_level="info"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -113,4 +113,3 @@ async def download_season_poster(
|
|||||||
await AsyncPath(media_folder).mkdir(parents=True, exist_ok=True)
|
await AsyncPath(media_folder).mkdir(parents=True, exist_ok=True)
|
||||||
url = f"{TMDB_IMAGE_BASE}/{DEFAULT_POSTER_SIZE}{poster_path}"
|
url = f"{TMDB_IMAGE_BASE}/{DEFAULT_POSTER_SIZE}{poster_path}"
|
||||||
return await _download_image(url, output_path, f"season {season_num} poster")
|
return await _download_image(url, output_path, f"season {season_num} poster")
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from mediahive.models.data import (
|
|||||||
Series,
|
Series,
|
||||||
Torrent,
|
Torrent,
|
||||||
)
|
)
|
||||||
|
from mediahive.models.tmdb import EpisodeInfo, Info, SeasonInfo
|
||||||
from mediahive.hivescan.tmdb_client import (
|
from mediahive.hivescan.tmdb_client import (
|
||||||
fetch_movie_info,
|
fetch_movie_info,
|
||||||
fetch_series_info,
|
fetch_series_info,
|
||||||
@@ -24,7 +25,11 @@ from mediahive.hivescan.tmdb_client import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from mediahive.hivescan.models import ContentType, ParsedContent
|
from mediahive.hivescan.models import ContentType, ParsedContent
|
||||||
from mediahive.hivescan.scanning import find_cover_image, find_episode_files, find_playable_file
|
from mediahive.hivescan.scanning import (
|
||||||
|
find_cover_image,
|
||||||
|
find_episode_files,
|
||||||
|
find_playable_file,
|
||||||
|
)
|
||||||
from mediahive.hivescan.images import (
|
from mediahive.hivescan.images import (
|
||||||
download_cover_image,
|
download_cover_image,
|
||||||
download_backdrop_image,
|
download_backdrop_image,
|
||||||
@@ -119,7 +124,9 @@ async def _collect_episode_files(
|
|||||||
)
|
)
|
||||||
if not already_added:
|
if not already_added:
|
||||||
if item.content_hash and item.content_hash.size == 0:
|
if item.content_hash and item.content_hash.size == 0:
|
||||||
item.content_hash.size = await get_directory_size(item.content_hash.path)
|
item.content_hash.size = await get_directory_size(
|
||||||
|
item.content_hash.path
|
||||||
|
)
|
||||||
size = item.content_hash.size if item.content_hash else 0
|
size = item.content_hash.size if item.content_hash else 0
|
||||||
all_episode_files[key].append(
|
all_episode_files[key].append(
|
||||||
{
|
{
|
||||||
@@ -291,9 +298,9 @@ async def _process_movies(
|
|||||||
Yields (Movie, showreel_task_or_None) for each movie as it is processed.
|
Yields (Movie, showreel_task_or_None) for each movie as it is processed.
|
||||||
"""
|
"""
|
||||||
# In-memory cache for TMDb lookups
|
# In-memory cache for TMDb lookups
|
||||||
movie_tmdb_cache: Dict[str, Optional[TMDbInfo]] = {}
|
movie_tmdb_cache: Dict[str, Optional[Info]] = {}
|
||||||
|
|
||||||
async def get_movie_tmdb(title: str, year: Optional[int]) -> Optional[TMDbInfo]:
|
async def get_movie_tmdb(title: str, year: Optional[int]) -> Optional[Info]:
|
||||||
cache_key = f"{title.lower()}:{year}"
|
cache_key = f"{title.lower()}:{year}"
|
||||||
if cache_key in movie_tmdb_cache:
|
if cache_key in movie_tmdb_cache:
|
||||||
return movie_tmdb_cache[cache_key]
|
return movie_tmdb_cache[cache_key]
|
||||||
@@ -311,7 +318,9 @@ async def _process_movies(
|
|||||||
valid_movies.append(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(" Skipped %d movie torrents with no playable video files", skipped)
|
logger.debug(
|
||||||
|
" Skipped %d movie torrents with no playable video files", skipped
|
||||||
|
)
|
||||||
|
|
||||||
# Group by title+year
|
# Group by title+year
|
||||||
movie_groups: Dict[str, List[ParsedContent]] = {}
|
movie_groups: Dict[str, List[ParsedContent]] = {}
|
||||||
@@ -402,13 +411,16 @@ async def _process_movies(
|
|||||||
# Queue showreel generation
|
# Queue showreel generation
|
||||||
showreel_paths = []
|
showreel_paths = []
|
||||||
showreel_task = None
|
showreel_task = None
|
||||||
if generate_showreels and versions:
|
if generate_showreels and torrents:
|
||||||
# Find the best version for showreel (highest quality)
|
# Find the best version for showreel (highest quality)
|
||||||
best_relpath = max(versions.keys(), key=lambda k: (
|
best_relpath = max(
|
||||||
RESOLUTION_PRIORITY.get(versions[k].resolution or "", 0),
|
torrents.keys(),
|
||||||
versions[k].size or 0,
|
key=lambda k: (
|
||||||
k
|
RESOLUTION_PRIORITY.get(torrents[k].resolution or "", 0),
|
||||||
))
|
torrents[k].size or 0,
|
||||||
|
k,
|
||||||
|
),
|
||||||
|
)
|
||||||
best_version = torrents[best_relpath]
|
best_version = torrents[best_relpath]
|
||||||
if best_version.playable_file:
|
if best_version.playable_file:
|
||||||
abs_playable = (
|
abs_playable = (
|
||||||
@@ -455,7 +467,9 @@ async def _process_movies(
|
|||||||
item_id = hashlib.md5(f"movie:{title}:{year}".encode()).hexdigest()[:12]
|
item_id = hashlib.md5(f"movie:{title}:{year}".encode()).hexdigest()[:12]
|
||||||
|
|
||||||
cover_path = (
|
cover_path = (
|
||||||
await find_cover_image(title, year, "movie", cover_dir) if fetch_covers else None
|
await find_cover_image(title, year, "movie", cover_dir)
|
||||||
|
if fetch_covers
|
||||||
|
else None
|
||||||
)
|
)
|
||||||
|
|
||||||
torrents = {}
|
torrents = {}
|
||||||
@@ -470,21 +484,24 @@ async def _process_movies(
|
|||||||
showreel_task = None
|
showreel_task = None
|
||||||
if generate_showreels and torrents:
|
if generate_showreels and torrents:
|
||||||
# Find the best version for showreel (highest quality)
|
# Find the best version for showreel (highest quality)
|
||||||
best_relpath = max(torrents.keys(), key=lambda k: (
|
best_relpath = max(
|
||||||
RESOLUTION_PRIORITY.get(torrents[k].resolution or "", 0),
|
torrents.keys(),
|
||||||
torrents[k].size or 0,
|
key=lambda k: (
|
||||||
k
|
RESOLUTION_PRIORITY.get(torrents[k].resolution or "", 0),
|
||||||
))
|
torrents[k].size or 0,
|
||||||
|
k,
|
||||||
|
),
|
||||||
|
)
|
||||||
best_version = torrents[best_relpath]
|
best_version = torrents[best_relpath]
|
||||||
if best_version.playable_file and not best_version.playable_file.endswith(".bdmv"):
|
if best_version.playable_file and not best_version.playable_file.endswith(
|
||||||
|
".bdmv"
|
||||||
|
):
|
||||||
abs_playable = (
|
abs_playable = (
|
||||||
str(Path(media_root) / best_version.playable_file)
|
str(Path(media_root) / best_version.playable_file)
|
||||||
if media_root
|
if media_root
|
||||||
else best_version.playable_file
|
else best_version.playable_file
|
||||||
)
|
)
|
||||||
media_folder = get_media_folder_path(
|
media_folder = get_media_folder_path(title, year, "movie", cover_dir)
|
||||||
title, year, "movie", cover_dir
|
|
||||||
)
|
|
||||||
showreel_paths = get_expected_showreel_paths(
|
showreel_paths = get_expected_showreel_paths(
|
||||||
media_folder,
|
media_folder,
|
||||||
media_root=Path(media_root) if media_root else None,
|
media_root=Path(media_root) if media_root else None,
|
||||||
@@ -519,10 +536,10 @@ async def _process_series(
|
|||||||
Yields (Series, episode_reel_tasks) for each series as it is processed.
|
Yields (Series, episode_reel_tasks) for each series as it is processed.
|
||||||
"""
|
"""
|
||||||
# In-memory cache for TMDb lookups
|
# In-memory cache for TMDb lookups
|
||||||
series_tmdb_cache: Dict[str, Optional[TMDbInfo]] = {}
|
series_tmdb_cache: Dict[str, Optional[Info]] = {}
|
||||||
season_cache: Dict[Tuple[int, int], Optional[TMDbSeasonInfo]] = {}
|
season_cache: Dict[Tuple[int, int], Optional[SeasonInfo]] = {}
|
||||||
|
|
||||||
async def get_series_tmdb(title: str) -> Optional[TMDbInfo]:
|
async def get_series_tmdb(title: str) -> Optional[Info]:
|
||||||
cache_key = title.lower()
|
cache_key = title.lower()
|
||||||
if cache_key in series_tmdb_cache:
|
if cache_key in series_tmdb_cache:
|
||||||
return series_tmdb_cache[cache_key]
|
return series_tmdb_cache[cache_key]
|
||||||
@@ -605,7 +622,9 @@ async def _process_series(
|
|||||||
# Find/download cover
|
# Find/download cover
|
||||||
cover_path = None
|
cover_path = None
|
||||||
if fetch_covers:
|
if fetch_covers:
|
||||||
cover_path = await find_cover_image(display_title, None, "series", cover_dir)
|
cover_path = await find_cover_image(
|
||||||
|
display_title, None, "series", cover_dir
|
||||||
|
)
|
||||||
if not cover_path:
|
if not cover_path:
|
||||||
for tt in torrent_titles:
|
for tt in torrent_titles:
|
||||||
cover_path = await find_cover_image(tt, None, "series", cover_dir)
|
cover_path = await find_cover_image(tt, None, "series", cover_dir)
|
||||||
@@ -668,7 +687,9 @@ async def _process_series(
|
|||||||
series_id = hashlib.md5(f"series:{title}".encode()).hexdigest()[:12]
|
series_id = hashlib.md5(f"series:{title}".encode()).hexdigest()[:12]
|
||||||
|
|
||||||
cover_path = (
|
cover_path = (
|
||||||
await find_cover_image(title, None, "series", cover_dir) if fetch_covers else None
|
await find_cover_image(title, None, "series", cover_dir)
|
||||||
|
if fetch_covers
|
||||||
|
else None
|
||||||
)
|
)
|
||||||
series_folder = get_media_folder_path(title, None, "series", cover_dir)
|
series_folder = get_media_folder_path(title, None, "series", cover_dir)
|
||||||
|
|
||||||
|
|||||||
@@ -51,4 +51,3 @@ class ParsedContent:
|
|||||||
is_directory: bool = False
|
is_directory: bool = False
|
||||||
raw_parsed: dict = field(default_factory=dict)
|
raw_parsed: dict = field(default_factory=dict)
|
||||||
content_hash: Optional[ContentHash] = None
|
content_hash: Optional[ContentHash] = None
|
||||||
|
|
||||||
|
|||||||
@@ -78,4 +78,3 @@ def parse_episode_from_filename(filename: str) -> Optional[Tuple[int, int]]:
|
|||||||
return int(match.group(1)), int(match.group(2))
|
return int(match.group(1)), int(match.group(2))
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
+157
-56
@@ -3,8 +3,8 @@ Scan orchestration — background tasks for continuous media scanning.
|
|||||||
|
|
||||||
All scanning logic lives here in hivescan. Communication with the mediahive
|
All scanning logic lives here in hivescan. Communication with the mediahive
|
||||||
server happens exclusively through an async ``send`` callable that pushes
|
server happens exclusively through an async ``send`` callable that pushes
|
||||||
:class:`~mediahive.models.protocol.ScanEvent` messages (``EvUpsert`` /
|
:class:`~mediahive.models.events.ScanEvent` messages (``Upsert`` /
|
||||||
``EvTask``) onto an :class:`asyncio.Queue` owned by the caller.
|
``Task``) onto an :class:`asyncio.Queue` owned by the caller.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
@@ -29,9 +29,13 @@ from mediahive.hivescan.showreel import (
|
|||||||
movie_showreels_exist,
|
movie_showreels_exist,
|
||||||
)
|
)
|
||||||
from mediahive.hivescan.tmdb_client import set_cache_dir
|
from mediahive.hivescan.tmdb_client import set_cache_dir
|
||||||
from mediahive.hivescan.utils import DEFAULT_OUTPUT_FOLDER, find_common_root, make_relative_path
|
from mediahive.hivescan.utils import (
|
||||||
|
DEFAULT_OUTPUT_FOLDER,
|
||||||
|
find_common_root,
|
||||||
|
make_relative_path,
|
||||||
|
)
|
||||||
from mediahive.models.data import Movie, Series, TaskInfo
|
from mediahive.models.data import Movie, Series, TaskInfo
|
||||||
from mediahive.models.protocol import EvTask, EvUpsert, ScanEvent
|
from mediahive.models.events import ScanEvent, Task, Upsert
|
||||||
|
|
||||||
logger = logging.getLogger("hivescan.scanner")
|
logger = logging.getLogger("hivescan.scanner")
|
||||||
|
|
||||||
@@ -106,7 +110,8 @@ async def start(send: Send) -> None:
|
|||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Scanner started — %d scan paths, output=%s",
|
"Scanner started — %d scan paths, output=%s",
|
||||||
len(_scan_paths), _output_dir,
|
len(_scan_paths),
|
||||||
|
_output_dir,
|
||||||
)
|
)
|
||||||
|
|
||||||
_showreel_worker_task = asyncio.create_task(_showreel_worker())
|
_showreel_worker_task = asyncio.create_task(_showreel_worker())
|
||||||
@@ -192,7 +197,7 @@ async def _run_scan(override_paths: Optional[List[str]] = None):
|
|||||||
Full scan pipeline:
|
Full scan pipeline:
|
||||||
1. Walk filesystem, parse torrents
|
1. Walk filesystem, parse torrents
|
||||||
2. Categorise → movies / series
|
2. Categorise → movies / series
|
||||||
3. Iterate async generators, send each item as EvUpsert
|
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]}"
|
||||||
@@ -204,9 +209,16 @@ async def _run_scan(override_paths: Optional[List[str]] = None):
|
|||||||
|
|
||||||
if downloads:
|
if downloads:
|
||||||
logger.info("Scan started (%s)", task_id)
|
logger.info("Scan started (%s)", task_id)
|
||||||
await _send(EvTask(data=TaskInfo(
|
await _send(
|
||||||
id=task_id, status="running", progress=0, detail="Scanning filesystem...",
|
Task(
|
||||||
)))
|
data=TaskInfo(
|
||||||
|
id=task_id,
|
||||||
|
status="running",
|
||||||
|
progress=0,
|
||||||
|
detail="Scanning filesystem...",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
logger.info("Found %d items to process", len(downloads))
|
logger.info("Found %d items to process", len(downloads))
|
||||||
|
|
||||||
categories = categorize_downloads(downloads)
|
categories = categorize_downloads(downloads)
|
||||||
@@ -214,57 +226,110 @@ async def _run_scan(override_paths: Optional[List[str]] = None):
|
|||||||
processed = 0
|
processed = 0
|
||||||
|
|
||||||
# Process movies
|
# Process movies
|
||||||
await _send(EvTask(data=TaskInfo(
|
await _send(
|
||||||
id=task_id, status="running", progress=0, detail="Processing movies...",
|
Task(
|
||||||
)))
|
data=TaskInfo(
|
||||||
|
id=task_id,
|
||||||
|
status="running",
|
||||||
|
progress=0,
|
||||||
|
detail="Processing movies...",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
async for movie, showreel_task in _process_movies(
|
async for movie, showreel_task in _process_movies(
|
||||||
categories, _output_dir,
|
categories,
|
||||||
fetch_covers=True, generate_showreels=True, media_root=media_root_str,
|
_output_dir,
|
||||||
|
fetch_covers=True,
|
||||||
|
generate_showreels=True,
|
||||||
|
media_root=media_root_str,
|
||||||
):
|
):
|
||||||
await _send(EvUpsert(kind="movie", item=movie))
|
await _send(Upsert(kind="movie", item=movie))
|
||||||
if showreel_task:
|
if showreel_task:
|
||||||
await _showreel_queue.put(("movie", showreel_task, movie))
|
await _showreel_queue.put(("movie", showreel_task, movie))
|
||||||
processed += 1
|
processed += 1
|
||||||
progress = round(processed / total, 3) if total else 1
|
progress = round(processed / total, 3) if total else 1
|
||||||
await _send(EvTask(data=TaskInfo(
|
await _send(
|
||||||
id=task_id, status="running", progress=progress, detail=movie.title,
|
Task(
|
||||||
)))
|
data=TaskInfo(
|
||||||
|
id=task_id,
|
||||||
|
status="running",
|
||||||
|
progress=progress,
|
||||||
|
detail=movie.title,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
# Process series
|
# Process series
|
||||||
await _send(EvTask(data=TaskInfo(
|
await _send(
|
||||||
id=task_id, status="running",
|
Task(
|
||||||
progress=processed / total if total else 0.5,
|
data=TaskInfo(
|
||||||
detail="Processing series...",
|
id=task_id,
|
||||||
)))
|
status="running",
|
||||||
|
progress=processed / total if total else 0.5,
|
||||||
|
detail="Processing series...",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
async for series, ep_reel_tasks in _process_series(
|
async for series, ep_reel_tasks in _process_series(
|
||||||
categories, _output_dir,
|
categories,
|
||||||
fetch_covers=True, generate_showreels=True, media_root=media_root_str,
|
_output_dir,
|
||||||
|
fetch_covers=True,
|
||||||
|
generate_showreels=True,
|
||||||
|
media_root=media_root_str,
|
||||||
):
|
):
|
||||||
await _send(EvUpsert(kind="series", item=series))
|
await _send(Upsert(kind="series", item=series))
|
||||||
for task in ep_reel_tasks:
|
for task in ep_reel_tasks:
|
||||||
await _showreel_queue.put(("episode", task, series))
|
await _showreel_queue.put(("episode", task, series))
|
||||||
processed += 1
|
processed += 1
|
||||||
progress = round(processed / total, 3) if total else 1
|
progress = round(processed / total, 3) if total else 1
|
||||||
await _send(EvTask(data=TaskInfo(
|
await _send(
|
||||||
id=task_id, status="running", progress=progress, detail=series.title,
|
Task(
|
||||||
)))
|
data=TaskInfo(
|
||||||
|
id=task_id,
|
||||||
|
status="running",
|
||||||
|
progress=progress,
|
||||||
|
detail=series.title,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
await _send(EvTask(data=TaskInfo(
|
await _send(
|
||||||
id=task_id, status="completed", progress=1, detail="Scan complete",
|
Task(
|
||||||
)))
|
data=TaskInfo(
|
||||||
|
id=task_id,
|
||||||
|
status="completed",
|
||||||
|
progress=1,
|
||||||
|
detail="Scan complete",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
if downloads:
|
if downloads:
|
||||||
logger.info("Scan complete (%s)", task_id)
|
logger.info("Scan complete (%s)", task_id)
|
||||||
|
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
await _send(EvTask(data=TaskInfo(
|
await _send(
|
||||||
id=task_id, status="cancelled", progress=0, detail="Scan cancelled",
|
Task(
|
||||||
)))
|
data=TaskInfo(
|
||||||
|
id=task_id,
|
||||||
|
status="cancelled",
|
||||||
|
progress=0,
|
||||||
|
detail="Scan cancelled",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
logger.info("Scan cancelled (%s)", task_id)
|
logger.info("Scan cancelled (%s)", task_id)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Scan failed (%s)", task_id)
|
logger.exception("Scan failed (%s)", task_id)
|
||||||
await _send(EvTask(data=TaskInfo(
|
await _send(
|
||||||
id=task_id, status="error", progress=0, detail="Scan error",
|
Task(
|
||||||
)))
|
data=TaskInfo(
|
||||||
|
id=task_id,
|
||||||
|
status="error",
|
||||||
|
progress=0,
|
||||||
|
detail="Scan error",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -289,29 +354,57 @@ async def _showreel_worker():
|
|||||||
if await movie_showreels_exist(media_folder):
|
if await movie_showreels_exist(media_folder):
|
||||||
_showreel_queue.task_done()
|
_showreel_queue.task_done()
|
||||||
continue
|
continue
|
||||||
await _send(EvTask(data=TaskInfo(
|
await _send(
|
||||||
id=task_id, status="running", progress=0, detail=f"Showreel: {title}",
|
Task(
|
||||||
)))
|
data=TaskInfo(
|
||||||
|
id=task_id,
|
||||||
|
status="running",
|
||||||
|
progress=0,
|
||||||
|
detail=f"Showreel: {title}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
await generate_showreel_images(video_path, media_folder, title=title)
|
await generate_showreel_images(video_path, media_folder, title=title)
|
||||||
paths = get_expected_showreel_paths(media_folder, media_root=media_root_path)
|
paths = get_expected_showreel_paths(
|
||||||
|
media_folder, media_root=media_root_path
|
||||||
|
)
|
||||||
movie.showreel_images = paths if paths else None
|
movie.showreel_images = paths if paths else None
|
||||||
await _send(EvUpsert(kind="movie", item=movie))
|
await _send(Upsert(kind="movie", item=movie))
|
||||||
await _send(EvTask(data=TaskInfo(
|
await _send(
|
||||||
id=task_id, status="completed", progress=1, detail=f"Showreel: {title}",
|
Task(
|
||||||
)))
|
data=TaskInfo(
|
||||||
|
id=task_id,
|
||||||
|
status="completed",
|
||||||
|
progress=1,
|
||||||
|
detail=f"Showreel: {title}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
elif kind == "episode":
|
elif kind == "episode":
|
||||||
series: Series = item
|
series: Series = item
|
||||||
video_path, media_folder, season_num, episode_num, series_title = task_data
|
video_path, media_folder, season_num, episode_num, series_title = (
|
||||||
|
task_data
|
||||||
|
)
|
||||||
if await episode_reel_exists(media_folder, season_num, episode_num):
|
if await episode_reel_exists(media_folder, season_num, episode_num):
|
||||||
_showreel_queue.task_done()
|
_showreel_queue.task_done()
|
||||||
continue
|
continue
|
||||||
ep_code = f"S{season_num:02d}E{episode_num:02d}"
|
ep_code = f"S{season_num:02d}E{episode_num:02d}"
|
||||||
await _send(EvTask(data=TaskInfo(
|
await _send(
|
||||||
id=task_id, status="running", progress=0, detail=f"Reel: {series_title} {ep_code}",
|
Task(
|
||||||
)))
|
data=TaskInfo(
|
||||||
|
id=task_id,
|
||||||
|
status="running",
|
||||||
|
progress=0,
|
||||||
|
detail=f"Reel: {series_title} {ep_code}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
reel_path = await generate_episode_reel(
|
reel_path = await generate_episode_reel(
|
||||||
video_path, media_folder, season_num, episode_num,
|
video_path,
|
||||||
|
media_folder,
|
||||||
|
season_num,
|
||||||
|
episode_num,
|
||||||
)
|
)
|
||||||
if reel_path:
|
if reel_path:
|
||||||
for season in series.seasons:
|
for season in series.seasons:
|
||||||
@@ -319,12 +412,20 @@ async def _showreel_worker():
|
|||||||
for episode in season.episodes:
|
for episode in season.episodes:
|
||||||
if episode.episode_number == episode_num:
|
if episode.episode_number == episode_num:
|
||||||
episode.reel_image = make_relative_path(
|
episode.reel_image = make_relative_path(
|
||||||
reel_path, media_root_str,
|
reel_path,
|
||||||
|
media_root_str,
|
||||||
)
|
)
|
||||||
await _send(EvUpsert(kind="series", item=series))
|
await _send(Upsert(kind="series", item=series))
|
||||||
await _send(EvTask(data=TaskInfo(
|
await _send(
|
||||||
id=task_id, status="completed", progress=1, detail=f"Reel: {series_title} {ep_code}",
|
Task(
|
||||||
)))
|
data=TaskInfo(
|
||||||
|
id=task_id,
|
||||||
|
status="completed",
|
||||||
|
progress=1,
|
||||||
|
detail=f"Reel: {series_title} {ep_code}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
_showreel_queue.task_done()
|
_showreel_queue.task_done()
|
||||||
|
|
||||||
|
|||||||
@@ -79,7 +79,9 @@ def categorize_downloads(
|
|||||||
return categories
|
return categories
|
||||||
|
|
||||||
|
|
||||||
async def find_episode_files(path: Path) -> Dict[Tuple[int, int], List[Tuple[str, int]]]:
|
async def find_episode_files(
|
||||||
|
path: Path,
|
||||||
|
) -> Dict[Tuple[int, int], List[Tuple[str, int]]]:
|
||||||
"""
|
"""
|
||||||
Find all episode video files in a directory.
|
Find all episode video files in a directory.
|
||||||
|
|
||||||
@@ -115,7 +117,7 @@ async def find_episode_files(path: Path) -> Dict[Tuple[int, int], List[Tuple[str
|
|||||||
if ep_info not in episodes:
|
if ep_info not in episodes:
|
||||||
episodes[ep_info] = []
|
episodes[ep_info] = []
|
||||||
episodes[ep_info].append((str(f), (await af.stat()).st_size))
|
episodes[ep_info].append((str(f), (await af.stat()).st_size))
|
||||||
except (OSError, PermissionError):
|
except OSError, PermissionError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
_episode_files_cache[cache_key] = episodes
|
_episode_files_cache[cache_key] = episodes
|
||||||
@@ -159,7 +161,7 @@ async def find_playable_file(path: Path) -> Optional[str]:
|
|||||||
result = str(nested_bdmv)
|
result = str(nested_bdmv)
|
||||||
_playable_file_cache[cache_key] = result
|
_playable_file_cache[cache_key] = result
|
||||||
return result
|
return result
|
||||||
except (OSError, PermissionError):
|
except OSError, PermissionError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Find largest video file
|
# Find largest video file
|
||||||
@@ -171,7 +173,7 @@ async def find_playable_file(path: Path) -> Optional[str]:
|
|||||||
if "sample" in Path(f).name.lower():
|
if "sample" in Path(f).name.lower():
|
||||||
continue
|
continue
|
||||||
video_files.append((str(f), (await af.stat()).st_size))
|
video_files.append((str(f), (await af.stat()).st_size))
|
||||||
except (OSError, PermissionError):
|
except OSError, PermissionError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
if not video_files:
|
if not video_files:
|
||||||
@@ -205,4 +207,3 @@ async def find_cover_image(
|
|||||||
return str(legacy_path)
|
return str(legacy_path)
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
@@ -90,9 +90,13 @@ async def movie_showreels_exist(
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
async def episode_reel_exists(media_folder: Path, season_num: int, episode_num: int) -> bool:
|
async def episode_reel_exists(
|
||||||
|
media_folder: Path, season_num: int, episode_num: int
|
||||||
|
) -> bool:
|
||||||
"""Check if an episode reel file already exists."""
|
"""Check if an episode reel file already exists."""
|
||||||
return await AsyncPath(media_folder / f"S{season_num:02d}E{episode_num:02d}.webm").exists()
|
return await AsyncPath(
|
||||||
|
media_folder / f"S{season_num:02d}E{episode_num:02d}.webm"
|
||||||
|
).exists()
|
||||||
|
|
||||||
|
|
||||||
def get_bluray_uri(video_path: str) -> Optional[str]:
|
def get_bluray_uri(video_path: str) -> Optional[str]:
|
||||||
@@ -811,4 +815,3 @@ async def generate_episode_reel(
|
|||||||
episode_code = f"S{season_num:02d}E{episode_num:02d}"
|
episode_code = f"S{season_num:02d}E{episode_num:02d}"
|
||||||
logger.error("Error generating episode reel for %s: %s", episode_code, e)
|
logger.error("Error generating episode reel for %s: %s", episode_code, e)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
@@ -16,9 +16,11 @@ import httpx
|
|||||||
from aiopathlib import AsyncPath
|
from aiopathlib import AsyncPath
|
||||||
|
|
||||||
from mediahive.models.tmdb import (
|
from mediahive.models.tmdb import (
|
||||||
|
CastMember,
|
||||||
EpisodeInfo,
|
EpisodeInfo,
|
||||||
Info,
|
Info,
|
||||||
SeasonInfo,
|
SeasonInfo,
|
||||||
|
SimilarMedia,
|
||||||
)
|
)
|
||||||
|
|
||||||
# TMDb API configuration
|
# TMDb API configuration
|
||||||
@@ -163,7 +165,7 @@ async def fetch_series_details(series_id: int) -> Optional[Dict]:
|
|||||||
|
|
||||||
async def fetch_season_details(
|
async def fetch_season_details(
|
||||||
series_id: int, season_number: int
|
series_id: int, season_number: int
|
||||||
) -> Optional[TMDbSeasonInfo]:
|
) -> Optional[SeasonInfo]:
|
||||||
"""
|
"""
|
||||||
Fetch detailed season info including all episodes.
|
Fetch detailed season info including all episodes.
|
||||||
|
|
||||||
@@ -204,7 +206,7 @@ async def fetch_season_details(
|
|||||||
)
|
)
|
||||||
episodes.append(episode)
|
episodes.append(episode)
|
||||||
|
|
||||||
return TMDbSeasonInfo(
|
return SeasonInfo(
|
||||||
season_number=data.get("season_number", season_number),
|
season_number=data.get("season_number", season_number),
|
||||||
name=data.get("name"),
|
name=data.get("name"),
|
||||||
overview=data.get("overview"),
|
overview=data.get("overview"),
|
||||||
@@ -361,9 +363,7 @@ async def _search_movie_with_fallbacks(
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def fetch_movie_info(
|
async def fetch_movie_info(title: str, year: Optional[int] = None) -> Optional[Info]:
|
||||||
title: str, year: Optional[int] = None
|
|
||||||
) -> Optional[TMDbInfo]:
|
|
||||||
"""Fetch comprehensive movie info from TMDb."""
|
"""Fetch comprehensive movie info from TMDb."""
|
||||||
data = await _search_movie_with_fallbacks(title, year)
|
data = await _search_movie_with_fallbacks(title, year)
|
||||||
|
|
||||||
@@ -377,7 +377,7 @@ async def fetch_movie_info(
|
|||||||
details = await fetch_movie_details(movie_id)
|
details = await fetch_movie_details(movie_id)
|
||||||
if not details:
|
if not details:
|
||||||
# Fall back to basic info from search
|
# Fall back to basic info from search
|
||||||
return TMDbInfo(
|
return Info(
|
||||||
tmdb_id=movie_id,
|
tmdb_id=movie_id,
|
||||||
title=result.get("title"),
|
title=result.get("title"),
|
||||||
original_title=result.get("original_title"),
|
original_title=result.get("original_title"),
|
||||||
@@ -434,7 +434,7 @@ async def fetch_movie_info(
|
|||||||
for s in similar_data
|
for s in similar_data
|
||||||
]
|
]
|
||||||
|
|
||||||
return TMDbInfo(
|
return Info(
|
||||||
tmdb_id=movie_id,
|
tmdb_id=movie_id,
|
||||||
title=details.get("title"),
|
title=details.get("title"),
|
||||||
original_title=details.get("original_title"),
|
original_title=details.get("original_title"),
|
||||||
@@ -479,7 +479,7 @@ async def _search_series_with_fallbacks(title: str) -> Optional[Dict]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def fetch_series_info(title: str) -> Optional[TMDbInfo]:
|
async def fetch_series_info(title: str) -> Optional[Info]:
|
||||||
"""Fetch comprehensive TV series info from TMDb."""
|
"""Fetch comprehensive TV series info from TMDb."""
|
||||||
data = await _search_series_with_fallbacks(title)
|
data = await _search_series_with_fallbacks(title)
|
||||||
|
|
||||||
@@ -493,7 +493,7 @@ async def fetch_series_info(title: str) -> Optional[TMDbInfo]:
|
|||||||
details = await fetch_series_details(series_id)
|
details = await fetch_series_details(series_id)
|
||||||
if not details:
|
if not details:
|
||||||
# Fall back to basic info from search
|
# Fall back to basic info from search
|
||||||
return TMDbInfo(
|
return Info(
|
||||||
tmdb_id=series_id,
|
tmdb_id=series_id,
|
||||||
title=result.get("name"),
|
title=result.get("name"),
|
||||||
original_title=result.get("original_name"),
|
original_title=result.get("original_name"),
|
||||||
@@ -539,7 +539,7 @@ async def fetch_series_info(title: str) -> Optional[TMDbInfo]:
|
|||||||
# Get first air date
|
# Get first air date
|
||||||
first_air_date = details.get("first_air_date")
|
first_air_date = details.get("first_air_date")
|
||||||
|
|
||||||
return TMDbInfo(
|
return Info(
|
||||||
tmdb_id=series_id,
|
tmdb_id=series_id,
|
||||||
title=details.get("name"),
|
title=details.get("name"),
|
||||||
original_title=details.get("original_name"),
|
original_title=details.get("original_name"),
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"""Utility functions for paths, sizes, and timestamps."""
|
"""Utility functions for paths, sizes, and timestamps."""
|
||||||
|
|
||||||
import os
|
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
@@ -40,7 +39,7 @@ async def get_added_timestamp(path: Path) -> Optional[int]:
|
|||||||
ap = AsyncPath(path)
|
ap = AsyncPath(path)
|
||||||
try:
|
try:
|
||||||
stat_info = await ap.stat()
|
stat_info = await ap.stat()
|
||||||
except (OSError, PermissionError):
|
except OSError, PermissionError:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if await ap.is_dir():
|
if await ap.is_dir():
|
||||||
@@ -65,7 +64,7 @@ async def get_directory_size(path: Path) -> int:
|
|||||||
for item in ap.rglob("*"):
|
for item in ap.rglob("*"):
|
||||||
if await AsyncPath(item).is_file():
|
if await AsyncPath(item).is_file():
|
||||||
total += (await AsyncPath(item).stat()).st_size
|
total += (await AsyncPath(item).stat()).st_size
|
||||||
except (OSError, PermissionError):
|
except OSError, PermissionError:
|
||||||
pass
|
pass
|
||||||
return total
|
return total
|
||||||
|
|
||||||
@@ -99,7 +98,10 @@ async def find_common_root(paths: List[Path]) -> Optional[Path]:
|
|||||||
for p in resolved:
|
for p in resolved:
|
||||||
# Find the first existing parent to get device info
|
# Find the first existing parent to get device info
|
||||||
check_path = p
|
check_path = p
|
||||||
while not await AsyncPath(check_path).exists() and check_path.parent != check_path:
|
while (
|
||||||
|
not await AsyncPath(check_path).exists()
|
||||||
|
and check_path.parent != check_path
|
||||||
|
):
|
||||||
check_path = check_path.parent
|
check_path = check_path.parent
|
||||||
if await AsyncPath(check_path).exists():
|
if await AsyncPath(check_path).exists():
|
||||||
devices.add((await AsyncPath(check_path).stat()).st_dev)
|
devices.add((await AsyncPath(check_path).stat()).st_dev)
|
||||||
@@ -113,7 +115,11 @@ async def find_common_root(paths: List[Path]) -> Optional[Path]:
|
|||||||
# Find common path prefix
|
# Find common path prefix
|
||||||
if len(resolved) == 1:
|
if len(resolved) == 1:
|
||||||
# Single path - use its parent as root
|
# Single path - use its parent as root
|
||||||
return resolved[0].parent if await AsyncPath(resolved[0]).is_file() else resolved[0]
|
return (
|
||||||
|
resolved[0].parent
|
||||||
|
if await AsyncPath(resolved[0]).is_file()
|
||||||
|
else resolved[0]
|
||||||
|
)
|
||||||
|
|
||||||
# Get parts of each path
|
# Get parts of each path
|
||||||
all_parts = [p.parts for p in resolved]
|
all_parts = [p.parts for p in resolved]
|
||||||
@@ -194,4 +200,3 @@ def sort_by_quality(items: list, reverse: bool = True) -> None:
|
|||||||
),
|
),
|
||||||
reverse=reverse,
|
reverse=reverse,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+26
-41
@@ -25,12 +25,10 @@ from mediahive.models.data import (
|
|||||||
Series,
|
Series,
|
||||||
TaskInfo,
|
TaskInfo,
|
||||||
)
|
)
|
||||||
|
from mediahive.models.events import Remove, Task, Upsert
|
||||||
from mediahive.models.protocol import (
|
from mediahive.models.protocol import (
|
||||||
WsInit,
|
WsInit,
|
||||||
WsInitData,
|
WsInitData,
|
||||||
WsRemove,
|
|
||||||
WsTask,
|
|
||||||
WsUpsert,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger("mediahive.index_store")
|
logger = logging.getLogger("mediahive.index_store")
|
||||||
@@ -40,7 +38,15 @@ SNAPSHOT_DEBOUNCE = 5.0
|
|||||||
|
|
||||||
|
|
||||||
class IndexStore:
|
class IndexStore:
|
||||||
"""In-memory media index with WS broadcast and disk snapshots."""
|
"""In-memory media index with WS broadcast and disk snapshots.
|
||||||
|
|
||||||
|
# TODO: Decouple WS transport from data storage
|
||||||
|
# Consider splitting WS broadcasting into a separate Broadcaster class that:
|
||||||
|
# - Owns per-connection queues instead of direct WebSocket references
|
||||||
|
# - Each WS connection gets its own asyncio.Queue for messages
|
||||||
|
# - Scanner pushes events to all queues; each WS reader drains its own queue
|
||||||
|
# This would make IndexStore testable without FastAPI's WebSocket.
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(self, snapshot_path: Path, media_root: Optional[str] = None):
|
def __init__(self, snapshot_path: Path, media_root: Optional[str] = None):
|
||||||
self.snapshot_path = snapshot_path
|
self.snapshot_path = snapshot_path
|
||||||
@@ -68,9 +74,7 @@ class IndexStore:
|
|||||||
logger.info("No snapshot found at %s, starting fresh", self.snapshot_path)
|
logger.info("No snapshot found at %s, starting fresh", self.snapshot_path)
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
data = msgspec.json.decode(
|
data = msgspec.json.decode(await ap.read_bytes(), type=IndexSnapshot)
|
||||||
await ap.read_bytes(), type=IndexSnapshot
|
|
||||||
)
|
|
||||||
for m in data.movies:
|
for m in data.movies:
|
||||||
self.movies[m.id] = m
|
self.movies[m.id] = m
|
||||||
for s in data.series:
|
for s in data.series:
|
||||||
@@ -85,32 +89,13 @@ class IndexStore:
|
|||||||
|
|
||||||
async def _write_snapshot(self) -> None:
|
async def _write_snapshot(self) -> None:
|
||||||
"""Write current index to disk (called from debounce task)."""
|
"""Write current index to disk (called from debounce task)."""
|
||||||
movies_list = sorted(
|
snapshot = self._build_snapshot()
|
||||||
self.movies.values(), key=lambda x: (x.title.lower(), x.year or 0)
|
|
||||||
)
|
|
||||||
series_list = sorted(self.series.values(), key=lambda x: x.title.lower())
|
|
||||||
|
|
||||||
total_movie_versions = sum(len(m.torrents) for m in movies_list)
|
|
||||||
total_series_episodes = sum(
|
|
||||||
sum(len(season.episodes) for season in s.seasons) for s in series_list
|
|
||||||
)
|
|
||||||
|
|
||||||
snapshot = IndexSnapshot(
|
|
||||||
generated_at=datetime.now().isoformat(),
|
|
||||||
media_root=self.media_root,
|
|
||||||
stats=MediaStats(
|
|
||||||
total_movies=len(movies_list),
|
|
||||||
total_movie_versions=total_movie_versions,
|
|
||||||
total_series=len(series_list),
|
|
||||||
total_series_episodes=total_series_episodes,
|
|
||||||
),
|
|
||||||
movies=movies_list,
|
|
||||||
series=series_list,
|
|
||||||
)
|
|
||||||
|
|
||||||
await AsyncPath(self.snapshot_path.parent).mkdir(parents=True, exist_ok=True)
|
await AsyncPath(self.snapshot_path.parent).mkdir(parents=True, exist_ok=True)
|
||||||
tmp = self.snapshot_path.with_suffix(".tmp")
|
tmp = self.snapshot_path.with_suffix(".tmp")
|
||||||
await AsyncPath(tmp).write_bytes(msgspec.json.format(msgspec.json.encode(snapshot), indent=2))
|
await AsyncPath(tmp).write_bytes(
|
||||||
|
msgspec.json.format(msgspec.json.encode(snapshot), indent=2)
|
||||||
|
)
|
||||||
# os.replace is atomic and overwrites on all platforms (unlike rename on Windows)
|
# os.replace is atomic and overwrites on all platforms (unlike rename on Windows)
|
||||||
await asyncio.to_thread(os.replace, tmp, self.snapshot_path)
|
await asyncio.to_thread(os.replace, tmp, self.snapshot_path)
|
||||||
logger.debug("Snapshot written to %s", self.snapshot_path)
|
logger.debug("Snapshot written to %s", self.snapshot_path)
|
||||||
@@ -153,7 +138,7 @@ class IndexStore:
|
|||||||
return False
|
return False
|
||||||
self.movies[item.id] = item
|
self.movies[item.id] = item
|
||||||
self._schedule_snapshot()
|
self._schedule_snapshot()
|
||||||
self._broadcast(WsUpsert(kind="movie", item=item))
|
self._broadcast(Upsert(kind="movie", item=item))
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def upsert_series(self, item: Series) -> bool:
|
def upsert_series(self, item: Series) -> bool:
|
||||||
@@ -164,20 +149,20 @@ class IndexStore:
|
|||||||
return False
|
return False
|
||||||
self.series[item.id] = item
|
self.series[item.id] = item
|
||||||
self._schedule_snapshot()
|
self._schedule_snapshot()
|
||||||
self._broadcast(WsUpsert(kind="series", item=item))
|
self._broadcast(Upsert(kind="series", item=item))
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def remove_movie(self, item_id: str) -> None:
|
def remove_movie(self, item_id: str) -> None:
|
||||||
"""Remove a movie from the index and broadcast."""
|
"""Remove a movie from the index and broadcast."""
|
||||||
self.movies.pop(item_id, None)
|
self.movies.pop(item_id, None)
|
||||||
self._schedule_snapshot()
|
self._schedule_snapshot()
|
||||||
self._broadcast(WsRemove(kind="movie", id=item_id))
|
self._broadcast(Remove(kind="movie", id=item_id))
|
||||||
|
|
||||||
def remove_series(self, item_id: str) -> None:
|
def remove_series(self, item_id: str) -> None:
|
||||||
"""Remove a series from the index and broadcast."""
|
"""Remove a series from the index and broadcast."""
|
||||||
self.series.pop(item_id, None)
|
self.series.pop(item_id, None)
|
||||||
self._schedule_snapshot()
|
self._schedule_snapshot()
|
||||||
self._broadcast(WsRemove(kind="series", id=item_id))
|
self._broadcast(Remove(kind="series", id=item_id))
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# WebSocket management
|
# WebSocket management
|
||||||
@@ -222,18 +207,14 @@ class IndexStore:
|
|||||||
|
|
||||||
def broadcast_task(self, task_info: TaskInfo) -> None:
|
def broadcast_task(self, task_info: TaskInfo) -> None:
|
||||||
"""Broadcast a task progress message to all WS clients."""
|
"""Broadcast a task progress message to all WS clients."""
|
||||||
self._broadcast(WsTask(data=task_info))
|
self._broadcast(Task(data=task_info))
|
||||||
|
|
||||||
def broadcast(self, msg: object) -> None:
|
|
||||||
"""Broadcast an already-encoded message to all WS clients."""
|
|
||||||
self._broadcast(msg)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Read helpers
|
# Read helpers
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def get_full_index(self) -> IndexSnapshot:
|
def _build_snapshot(self) -> IndexSnapshot:
|
||||||
"""Return the full index as an IndexSnapshot."""
|
"""Build a sorted IndexSnapshot with computed stats."""
|
||||||
movies_list = sorted(
|
movies_list = sorted(
|
||||||
self.movies.values(), key=lambda x: (x.title.lower(), x.year or 0)
|
self.movies.values(), key=lambda x: (x.title.lower(), x.year or 0)
|
||||||
)
|
)
|
||||||
@@ -256,3 +237,7 @@ class IndexStore:
|
|||||||
movies=movies_list,
|
movies=movies_list,
|
||||||
series=series_list,
|
series=series_list,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def get_full_index(self) -> IndexSnapshot:
|
||||||
|
"""Return the full index as an IndexSnapshot."""
|
||||||
|
return self._build_snapshot()
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import msgspec
|
import msgspec
|
||||||
|
|
||||||
from .tmdb import CastMember, EpisodeInfo, Info, SimilarMedia
|
from .tmdb import Info
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""
|
||||||
|
Event types shared between scanner and WebSocket.
|
||||||
|
|
||||||
|
These types are used as:
|
||||||
|
- Internal scan events (scanner → server queue)
|
||||||
|
- WebSocket messages (server → clients)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import msgspec
|
||||||
|
|
||||||
|
from .data import Movie, Series, TaskInfo
|
||||||
|
|
||||||
|
|
||||||
|
class Upsert(msgspec.Struct, tag="upsert"):
|
||||||
|
"""Single item inserted or updated."""
|
||||||
|
|
||||||
|
kind: str # "movie" or "series"
|
||||||
|
item: Movie | Series
|
||||||
|
|
||||||
|
|
||||||
|
class Remove(msgspec.Struct, tag="remove"):
|
||||||
|
"""Single item removed."""
|
||||||
|
|
||||||
|
kind: str
|
||||||
|
id: str
|
||||||
|
|
||||||
|
|
||||||
|
class Task(msgspec.Struct, tag="task"):
|
||||||
|
"""Task progress broadcast."""
|
||||||
|
|
||||||
|
data: TaskInfo
|
||||||
|
|
||||||
|
|
||||||
|
# Union of scan events (scanner → server) and WS broadcast messages
|
||||||
|
ScanEvent = Upsert | Task
|
||||||
@@ -9,7 +9,8 @@ from __future__ import annotations
|
|||||||
import msgspec
|
import msgspec
|
||||||
from fastapi.responses import Response
|
from fastapi.responses import Response
|
||||||
|
|
||||||
from .data import Movie, Series, TaskInfo
|
from .data import Movie, Series
|
||||||
|
from .events import Remove, ScanEvent, Task, Upsert
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -30,49 +31,20 @@ class WsInit(msgspec.Struct, tag="init"):
|
|||||||
data: WsInitData
|
data: WsInitData
|
||||||
|
|
||||||
|
|
||||||
class WsUpsert(msgspec.Struct, tag="upsert"):
|
|
||||||
"""Single item inserted or updated."""
|
|
||||||
|
|
||||||
kind: str
|
|
||||||
item: Movie | Series
|
|
||||||
|
|
||||||
|
|
||||||
class WsRemove(msgspec.Struct, tag="remove"):
|
|
||||||
"""Single item removed."""
|
|
||||||
|
|
||||||
kind: str
|
|
||||||
id: str
|
|
||||||
|
|
||||||
|
|
||||||
class WsTask(msgspec.Struct, tag="task"):
|
|
||||||
"""Task progress broadcast."""
|
|
||||||
|
|
||||||
data: TaskInfo
|
|
||||||
|
|
||||||
|
|
||||||
# Union of all outbound WS messages (for documentation / future decoding)
|
# Union of all outbound WS messages (for documentation / future decoding)
|
||||||
WsMessage = WsInit | WsUpsert | WsRemove | WsTask
|
WsMessage = WsInit | Upsert | Remove | Task
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# Re-export unified types for backward compatibility
|
||||||
# Scan events (scanner → server, via async queue)
|
__all__ = [
|
||||||
# ---------------------------------------------------------------------------
|
"Remove",
|
||||||
|
"ScanEvent",
|
||||||
|
"Task",
|
||||||
class EvUpsert(msgspec.Struct, tag="upsert"):
|
"Upsert",
|
||||||
"""Scanner produced or updated a media item."""
|
"WsInit",
|
||||||
|
"WsInitData",
|
||||||
kind: str # "movie" or "series"
|
"WsMessage",
|
||||||
item: Movie | Series
|
]
|
||||||
|
|
||||||
|
|
||||||
class EvTask(msgspec.Struct, tag="task"):
|
|
||||||
"""Scanner progress update."""
|
|
||||||
|
|
||||||
data: TaskInfo
|
|
||||||
|
|
||||||
|
|
||||||
ScanEvent = EvUpsert | EvTask
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -118,5 +90,4 @@ class MsgspecResponse(Response):
|
|||||||
media_type = "application/json; charset=utf-8"
|
media_type = "application/json; charset=utf-8"
|
||||||
|
|
||||||
def render(self, content: object) -> bytes:
|
def render(self, content: object) -> bytes:
|
||||||
return msgspec.json.encode(content)</content>
|
return msgspec.json.encode(content)
|
||||||
<parameter name="filePath">c:\mediahive\mediahive\models\protocol.py
|
|
||||||
|
|||||||
@@ -86,5 +86,4 @@ class Info(msgspec.Struct):
|
|||||||
creators: list[str] | None = None
|
creators: list[str] | None = None
|
||||||
number_of_seasons: int | None = None
|
number_of_seasons: int | None = None
|
||||||
number_of_episodes: int | None = None
|
number_of_episodes: int | None = None
|
||||||
networks: list[str] | None = None</content>
|
networks: list[str] | None = None
|
||||||
<parameter name="filePath">c:\mediahive\mediahive\models\tmdb.py
|
|
||||||
|
|||||||
+9
-8
@@ -22,16 +22,13 @@ from fastapi.responses import FileResponse, StreamingResponse
|
|||||||
from fastapi_vue import Frontend
|
from fastapi_vue import Frontend
|
||||||
|
|
||||||
from mediahive.index_store import IndexStore
|
from mediahive.index_store import IndexStore
|
||||||
|
from mediahive.models.events import ScanEvent, Task, Upsert
|
||||||
from mediahive.models.protocol import (
|
from mediahive.models.protocol import (
|
||||||
EvTask,
|
|
||||||
EvUpsert,
|
|
||||||
MsgspecResponse,
|
MsgspecResponse,
|
||||||
PlayMediaRequest,
|
PlayMediaRequest,
|
||||||
OpenFolderRequest,
|
OpenFolderRequest,
|
||||||
ScanEvent,
|
|
||||||
ScanRequest,
|
ScanRequest,
|
||||||
StatusResponse,
|
StatusResponse,
|
||||||
WsTask,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
from mediahive.__main__ import DEVMODE
|
from mediahive.__main__ import DEVMODE
|
||||||
@@ -65,12 +62,12 @@ async def _consume_scan_events() -> None:
|
|||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
event = await _scan_events.get()
|
event = await _scan_events.get()
|
||||||
if isinstance(event, EvUpsert):
|
if isinstance(event, Upsert):
|
||||||
if event.kind == "movie":
|
if event.kind == "movie":
|
||||||
store.upsert_movie(event.item)
|
store.upsert_movie(event.item)
|
||||||
else:
|
else:
|
||||||
store.upsert_series(event.item)
|
store.upsert_series(event.item)
|
||||||
elif isinstance(event, EvTask):
|
elif isinstance(event, Task):
|
||||||
store.broadcast_task(event.data)
|
store.broadcast_task(event.data)
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
return
|
return
|
||||||
@@ -94,12 +91,16 @@ async def lifespan(app: FastAPI):
|
|||||||
await store.load_snapshot()
|
await store.load_snapshot()
|
||||||
logger.info(
|
logger.info(
|
||||||
"Index store ready: %d movies, %d series",
|
"Index store ready: %d movies, %d series",
|
||||||
len(store.movies), len(store.series),
|
len(store.movies),
|
||||||
|
len(store.series),
|
||||||
)
|
)
|
||||||
|
|
||||||
# If scan paths are configured, start the scanner subsystem
|
# If scan paths are configured, start the scanner subsystem
|
||||||
if os.environ.get("HIVESCAN_PATHS"):
|
if os.environ.get("HIVESCAN_PATHS"):
|
||||||
from mediahive.hivescan.scanner import start as start_scanner, stop as stop_scanner
|
from mediahive.hivescan.scanner import (
|
||||||
|
start as start_scanner,
|
||||||
|
stop as stop_scanner,
|
||||||
|
)
|
||||||
|
|
||||||
_consumer_task = asyncio.create_task(_consume_scan_events())
|
_consumer_task = asyncio.create_task(_consume_scan_events())
|
||||||
await start_scanner(_send_event)
|
await start_scanner(_send_event)
|
||||||
|
|||||||
Reference in New Issue
Block a user