Files
mediahive/hivescan/indexer.py
T

707 lines
25 KiB
Python

"""Media index generation — async generators for continuous scanning."""
import hashlib
import logging
from pathlib import Path
from typing import AsyncIterator, Dict, List, Optional, Tuple
from hivescan.showreel import (
get_expected_episode_reel_path,
get_expected_showreel_paths,
)
from hivescan.structs import (
Episode,
EpisodeRelease,
Movie,
MovieVersion,
Season,
Series,
TMDbEpisodeInfo,
TMDbInfo,
TMDbSeasonInfo,
)
from hivescan.tmdb_client import (
fetch_movie_info,
fetch_series_info,
fetch_season_details,
)
from hivescan.models import ContentType, ParsedContent
from hivescan.scanning import find_cover_image, find_episode_files, find_playable_file
from hivescan.images import (
download_cover_image,
download_backdrop_image,
download_season_poster,
)
from hivescan.utils import (
get_added_timestamp,
get_media_folder_path,
make_relative_path,
sort_by_quality,
)
logger = logging.getLogger("hivescan.indexer")
def _build_version_info(
item: ParsedContent, media_root: Optional[str] = None
) -> MovieVersion:
"""Build version/release info for a single torrent."""
playable_file = find_playable_file(item.path)
size = item.content_hash.size if item.content_hash else None
newest = get_added_timestamp(item.path)
return MovieVersion(
path=make_relative_path(str(item.path), media_root),
playable_file=make_relative_path(playable_file, media_root),
resolution=item.resolution,
quality=item.quality,
codec=item.codec,
audio=item.audio,
encoder=item.encoder,
size=size,
newest=newest,
)
def _collect_episode_files(
items: List[ParsedContent],
) -> Dict[Tuple[int, int], List[Dict]]:
"""
Collect all episode files from a list of torrent items.
Returns dict mapping (season, episode) to list of file info dicts.
"""
all_episode_files: Dict[Tuple[int, int], List[Dict]] = {}
for item in items:
episode_files = find_episode_files(item.path)
for (season_num, episode_num), files in episode_files.items():
key = (season_num, episode_num)
if key not in all_episode_files:
all_episode_files[key] = []
for file_path, file_size in files:
all_episode_files[key].append(
{
"path": file_path,
"size": file_size,
"resolution": item.resolution,
"quality": item.quality,
"codec": item.codec,
"audio": item.audio,
"encoder": item.encoder,
"torrent_path": str(item.path),
}
)
# Handle individual episodes from PTN parsing
if item.episode is not None and item.season is not None:
season_nums = (
item.season if isinstance(item.season, list) else [item.season]
)
episode_nums = (
item.episode if isinstance(item.episode, list) else [item.episode]
)
playable = find_playable_file(item.path)
if playable:
for sn in season_nums:
for ep in episode_nums:
key = (sn, ep)
if key not in all_episode_files:
all_episode_files[key] = []
already_added = any(
f["path"] == playable
for f in all_episode_files.get(key, [])
)
if not already_added:
size = item.content_hash.size if item.content_hash else 0
all_episode_files[key].append(
{
"path": playable,
"size": size,
"resolution": item.resolution,
"quality": item.quality,
"codec": item.codec,
"audio": item.audio,
"encoder": item.encoder,
"torrent_path": str(item.path),
}
)
return all_episode_files
def _build_episodes_data(
episodes_in_season: Dict[int, List[Dict]],
tmdb_episodes: Dict[int, TMDbEpisodeInfo],
series_folder: Path,
season_num: int,
generate_showreels: bool,
episode_reel_tasks: List,
series_title: str,
media_root: Optional[str] = None,
) -> List[Episode]:
"""Build episode data list for a season."""
episodes_data = []
for episode_num in sorted(episodes_in_season.keys()):
episode_files = episodes_in_season[episode_num]
sort_by_quality(episode_files)
tmdb_ep = tmdb_episodes.get(episode_num)
reel_path = None
if generate_showreels and episode_files:
best_file = episode_files[0]["path"]
if best_file and not best_file.endswith(".bdmv"):
reel_path = get_expected_episode_reel_path(
series_folder,
season_num,
episode_num,
media_root=Path(media_root) if media_root else None,
)
episode_reel_tasks.append(
(best_file, series_folder, season_num, episode_num, series_title)
)
releases = []
for f in episode_files:
releases.append(
EpisodeRelease(
path=make_relative_path(f["torrent_path"], media_root),
playable_file=make_relative_path(f["path"], media_root),
resolution=f.get("resolution"),
quality=f.get("quality"),
codec=f.get("codec"),
audio=f.get("audio"),
encoder=f.get("encoder"),
size=f.get("size"),
)
)
episode_data = Episode(
episode_number=episode_num,
name=tmdb_ep.name if tmdb_ep else None,
overview=tmdb_ep.overview if tmdb_ep else None,
air_date=tmdb_ep.air_date if tmdb_ep else None,
runtime=tmdb_ep.runtime if tmdb_ep else None,
still_path=tmdb_ep.still_path if tmdb_ep else None,
rating=tmdb_ep.vote_average if tmdb_ep else None,
director=tmdb_ep.director if tmdb_ep else None,
reel_image=reel_path,
releases=releases,
)
episodes_data.append(episode_data)
return episodes_data
async def _build_seasons_data(
all_episode_files: Dict[Tuple[int, int], List[Dict]],
tmdb_id: Optional[int],
series_folder: Path,
display_title: str,
fetch_covers: bool,
generate_showreels: bool,
season_cache: Dict,
episode_reel_tasks: List,
media_root: Optional[str] = None,
) -> List[Season]:
"""Build seasons data structure for a series."""
# Group episodes by season
seasons_map: Dict[int, Dict[int, List[Dict]]] = {}
for (season_num, episode_num), files in all_episode_files.items():
if season_num not in seasons_map:
seasons_map[season_num] = {}
seasons_map[season_num][episode_num] = files
seasons_data = []
for season_num in sorted(seasons_map.keys()):
episodes_in_season = seasons_map[season_num]
# Fetch TMDb season details if we have a TMDb ID
tmdb_season = None
tmdb_episodes: Dict[int, TMDbEpisodeInfo] = {}
if tmdb_id:
cache_key = (tmdb_id, season_num)
if cache_key not in season_cache:
logger.info(
" Fetching season %d details for %s", season_num, display_title
)
season_cache[cache_key] = await fetch_season_details(
tmdb_id, season_num
)
tmdb_season = season_cache[cache_key]
if tmdb_season and tmdb_season.episodes:
for ep in tmdb_season.episodes:
tmdb_episodes[ep.episode_number] = ep
# Download season poster
season_poster_path = None
if fetch_covers and tmdb_season and tmdb_season.poster_path:
season_poster_path = await download_season_poster(
tmdb_season.poster_path, series_folder, season_num
)
episodes_data = _build_episodes_data(
episodes_in_season,
tmdb_episodes,
series_folder,
season_num,
generate_showreels,
episode_reel_tasks,
display_title,
media_root,
)
season_data = Season(
season_number=season_num,
name=tmdb_season.name if tmdb_season else None,
overview=tmdb_season.overview if tmdb_season else None,
air_date=tmdb_season.air_date if tmdb_season else None,
poster_path=make_relative_path(season_poster_path, media_root)
if season_poster_path
else None,
episode_count=len(episodes_data),
episodes=episodes_data,
)
seasons_data.append(season_data)
return seasons_data
async def _process_movies(
categories: dict,
cover_dir: Path,
fetch_covers: bool,
generate_showreels: bool,
media_root: Optional[str] = None,
) -> AsyncIterator[Tuple[Movie, Optional[Tuple[str, Path, str]]]]:
"""
Async generator that processes all movies.
Yields (Movie, showreel_task_or_None) for each movie as it is processed.
"""
# In-memory cache for TMDb lookups
movie_tmdb_cache: Dict[str, Optional[TMDbInfo]] = {}
async def get_movie_tmdb(title: str, year: Optional[int]) -> Optional[TMDbInfo]:
cache_key = f"{title.lower()}:{year}"
if cache_key in movie_tmdb_cache:
return movie_tmdb_cache[cache_key]
tmdb_info = await fetch_movie_info(title, year)
movie_tmdb_cache[cache_key] = tmdb_info
return tmdb_info
def has_playable(item: ParsedContent) -> bool:
return find_playable_file(item.path) is not None
# Filter movies with playable files
valid_movies = [
item for item in categories[ContentType.MOVIE] if has_playable(item)
]
skipped = len(categories[ContentType.MOVIE]) - len(valid_movies)
if skipped > 0:
print(f" Skipped {skipped} movie torrents with no playable video files")
# Group by title+year
movie_groups: Dict[str, List[ParsedContent]] = {}
for item in valid_movies:
key = f"{item.title.lower()}:{item.year or 0}"
if key not in movie_groups:
movie_groups[key] = []
movie_groups[key].append(item)
# Re-group by TMDb ID
tmdb_movie_groups: Dict[int, Dict] = {}
no_tmdb_movie_groups: Dict[str, Dict] = {}
logger.info(
" Processing %d unique movies (%d total versions)...",
len(movie_groups),
len(categories[ContentType.MOVIE]),
)
for idx, (movie_key, items) in enumerate(movie_groups.items(), 1):
first_item = items[0]
logger.info(
" [%d/%d] %s (%s)",
idx,
len(movie_groups),
first_item.title,
first_item.year,
)
tmdb_info = await get_movie_tmdb(first_item.title, first_item.year)
if tmdb_info and tmdb_info.tmdb_id:
if tmdb_info.tmdb_id not in tmdb_movie_groups:
tmdb_movie_groups[tmdb_info.tmdb_id] = {
"tmdb_info": tmdb_info,
"items": [],
"torrent_titles": set(),
"year": first_item.year,
}
tmdb_movie_groups[tmdb_info.tmdb_id]["items"].extend(items)
tmdb_movie_groups[tmdb_info.tmdb_id]["torrent_titles"].add(first_item.title)
else:
key = f"{first_item.title.lower()}:{first_item.year or 0}"
if key not in no_tmdb_movie_groups:
no_tmdb_movie_groups[key] = {
"items": [],
"title": first_item.title,
"year": first_item.year,
}
no_tmdb_movie_groups[key]["items"].extend(items)
# Process movies with TMDb info — yield each as ready
for tmdb_id, group_data in tmdb_movie_groups.items():
tmdb_info = group_data["tmdb_info"]
items = group_data["items"]
torrent_titles = group_data["torrent_titles"]
year = group_data["year"]
display_title = tmdb_info.title
item_id = hashlib.md5(f"movie:{tmdb_id}".encode()).hexdigest()[:12]
# Find/download cover
cover_path = None
if fetch_covers:
cover_path = find_cover_image(display_title, year, "movie", cover_dir)
if not cover_path:
for tt in torrent_titles:
cover_path = find_cover_image(tt, year, "movie", cover_dir)
if cover_path:
break
if not cover_path and tmdb_info.poster_path:
cover_path = await download_cover_image(
tmdb_info.poster_path, display_title, year, "movie", cover_dir
)
versions = [_build_version_info(item, media_root) for item in items]
sort_by_quality(versions)
# Queue showreel generation
showreel_paths = []
showreel_task = None
if generate_showreels and versions:
best_playable = versions[0].playable_file
if best_playable:
abs_playable = (
str(Path(media_root) / best_playable)
if media_root
else best_playable
)
media_folder = get_media_folder_path(
display_title, year, "movie", cover_dir
)
showreel_paths = get_expected_showreel_paths(
media_folder, media_root=Path(media_root) if media_root else None
)
showreel_task = (abs_playable, media_folder, display_title)
# Download backdrop
backdrop_path = None
if fetch_covers and tmdb_info.backdrop_path:
backdrop_path = await download_backdrop_image(
tmdb_info.backdrop_path, display_title, year, "movie", cover_dir
)
different_titles = [
t for t in torrent_titles if t.lower() != display_title.lower()
]
version_timestamps = [v.newest for v in versions if v.newest]
newest = max(version_timestamps) if version_timestamps else None
movie = Movie(
id=item_id,
title=display_title,
original_title=tmdb_info.original_title,
alternative_titles=tmdb_info.alternative_titles,
torrent_titles=different_titles if different_titles else None,
year=year,
newest=newest,
cover_path=make_relative_path(cover_path, media_root),
backdrop_path=make_relative_path(backdrop_path, media_root),
showreel_images=showreel_paths if showreel_paths else None,
versions=versions,
tmdb_id=tmdb_info.tmdb_id,
tmdb_title=tmdb_info.title,
rating=tmdb_info.rating,
vote_count=tmdb_info.vote_count,
overview=tmdb_info.overview,
genres=tmdb_info.genres,
release_date=tmdb_info.release_date,
runtime=tmdb_info.runtime,
status=tmdb_info.status,
tagline=tmdb_info.tagline,
poster_path=tmdb_info.poster_path,
similar=tmdb_info.similar,
keywords=tmdb_info.keywords,
cast=tmdb_info.cast,
director=tmdb_info.director,
)
yield movie, showreel_task
# Process movies without TMDb info
for key, group_data in no_tmdb_movie_groups.items():
items = group_data["items"]
title = group_data["title"]
year = group_data["year"]
item_id = hashlib.md5(f"movie:{title}:{year}".encode()).hexdigest()[:12]
cover_path = (
find_cover_image(title, year, "movie", cover_dir) if fetch_covers else None
)
versions = [_build_version_info(item, media_root) for item in items]
sort_by_quality(versions)
showreel_paths = []
showreel_task = None
if generate_showreels and versions:
best_playable = versions[0].playable_file
if best_playable:
abs_playable = (
str(Path(media_root) / best_playable)
if media_root
else best_playable
)
if not abs_playable.endswith(".bdmv"):
media_folder = get_media_folder_path(
title, year, "movie", cover_dir
)
showreel_paths = get_expected_showreel_paths(
media_folder,
media_root=Path(media_root) if media_root else None,
)
showreel_task = (abs_playable, media_folder, title)
version_timestamps = [v.newest for v in versions if v.newest]
newest = max(version_timestamps) if version_timestamps else None
movie = Movie(
id=item_id,
title=title,
year=year,
newest=newest,
cover_path=make_relative_path(cover_path, media_root),
showreel_images=showreel_paths if showreel_paths else None,
versions=versions,
)
yield movie, showreel_task
async def _process_series(
categories: dict,
cover_dir: Path,
fetch_covers: bool,
generate_showreels: bool,
media_root: Optional[str] = None,
) -> AsyncIterator[Tuple[Series, List[Tuple[str, Path, int, int, str]]]]:
"""
Async generator that processes all series.
Yields (Series, episode_reel_tasks) for each series as it is processed.
"""
# In-memory cache for TMDb lookups
series_tmdb_cache: Dict[str, Optional[TMDbInfo]] = {}
season_cache: Dict[Tuple[int, int], Optional[TMDbSeasonInfo]] = {}
async def get_series_tmdb(title: str) -> Optional[TMDbInfo]:
cache_key = title.lower()
if cache_key in series_tmdb_cache:
return series_tmdb_cache[cache_key]
tmdb_info = await fetch_series_info(title)
series_tmdb_cache[cache_key] = tmdb_info
return tmdb_info
def has_video_content(item: ParsedContent) -> bool:
if find_playable_file(item.path):
return True
return len(find_episode_files(item.path)) > 0
# Filter series with video content
valid_series = [
item for item in categories[ContentType.SERIES] if has_video_content(item)
]
skipped = len(categories[ContentType.SERIES]) - len(valid_series)
if skipped > 0:
logger.info(
" Skipped %d series torrents with no playable video files", skipped
)
# Group by title
series_groups: Dict[str, List[ParsedContent]] = {}
for item in valid_series:
key = item.title.lower()
if key not in series_groups:
series_groups[key] = []
series_groups[key].append(item)
# Re-group by TMDb ID
tmdb_groups: Dict[int, Dict] = {}
no_tmdb_groups: Dict[str, Dict] = {}
logger.info(
" Processing %d unique series (%d total entries)...",
len(series_groups),
len(categories[ContentType.SERIES]),
)
for idx, (series_key, items) in enumerate(series_groups.items(), 1):
first_item = items[0]
logger.info(" [%d/%d] %s", idx, len(series_groups), first_item.title)
tmdb_info = await get_series_tmdb(first_item.title)
if tmdb_info and tmdb_info.tmdb_id:
if tmdb_info.tmdb_id not in tmdb_groups:
tmdb_groups[tmdb_info.tmdb_id] = {
"tmdb_info": tmdb_info,
"items": [],
"torrent_titles": set(),
}
tmdb_groups[tmdb_info.tmdb_id]["items"].extend(items)
tmdb_groups[tmdb_info.tmdb_id]["torrent_titles"].add(first_item.title)
else:
key = first_item.title.lower()
if key not in no_tmdb_groups:
no_tmdb_groups[key] = {"items": [], "title": first_item.title}
no_tmdb_groups[key]["items"].extend(items)
# Process series with TMDb info — yield each as ready
for series_idx, (tmdb_id, group_data) in enumerate(tmdb_groups.items(), 1):
tmdb_info = group_data["tmdb_info"]
items = group_data["items"]
torrent_titles = group_data["torrent_titles"]
display_title = tmdb_info.title
series_id = hashlib.md5(f"series:{tmdb_id}".encode()).hexdigest()[:12]
logger.info(" [%d/%d] %s", series_idx, len(tmdb_groups), display_title)
series_folder = get_media_folder_path(display_title, None, "series", cover_dir)
# Find/download cover
cover_path = None
if fetch_covers:
cover_path = find_cover_image(display_title, None, "series", cover_dir)
if not cover_path:
for tt in torrent_titles:
cover_path = find_cover_image(tt, None, "series", cover_dir)
if cover_path:
break
if not cover_path and tmdb_info.poster_path:
cover_path = await download_cover_image(
tmdb_info.poster_path, display_title, None, "series", cover_dir
)
# Download backdrop
backdrop_path = None
if fetch_covers and tmdb_info.backdrop_path:
backdrop_path = await download_backdrop_image(
tmdb_info.backdrop_path, display_title, None, "series", cover_dir
)
# Collect and build episode data
all_episode_files = _collect_episode_files(items)
ep_reel_tasks: List[Tuple[str, Path, int, int, str]] = []
seasons_data = await _build_seasons_data(
all_episode_files,
tmdb_id,
series_folder,
display_title,
fetch_covers,
generate_showreels,
season_cache,
ep_reel_tasks,
media_root,
)
if not seasons_data:
logger.info(" Skipping %s - no episodes found", display_title)
continue
different_titles = [
t for t in torrent_titles if t.lower() != display_title.lower()
]
item_timestamps = [get_added_timestamp(item.path) for item in items]
item_timestamps = [t for t in item_timestamps if t is not None]
newest = max(item_timestamps) if item_timestamps else None
series = Series(
id=series_id,
title=display_title,
original_title=tmdb_info.original_title,
torrent_titles=different_titles if different_titles else None,
newest=newest,
cover_path=make_relative_path(cover_path, media_root),
backdrop_path=make_relative_path(backdrop_path, media_root),
seasons=seasons_data,
tmdb_id=tmdb_info.tmdb_id,
tmdb_title=tmdb_info.title,
rating=tmdb_info.rating,
vote_count=tmdb_info.vote_count,
overview=tmdb_info.overview,
genres=tmdb_info.genres,
release_date=tmdb_info.release_date,
status=tmdb_info.status,
tagline=tmdb_info.tagline,
poster_path=tmdb_info.poster_path,
similar=tmdb_info.similar,
keywords=tmdb_info.keywords,
cast=tmdb_info.cast,
creators=tmdb_info.creators,
number_of_seasons=tmdb_info.number_of_seasons,
number_of_episodes=tmdb_info.number_of_episodes,
networks=tmdb_info.networks,
)
yield series, ep_reel_tasks
# Process series without TMDb info
for key, group_data in no_tmdb_groups.items():
items = group_data["items"]
title = group_data["title"]
series_id = hashlib.md5(f"series:{title}".encode()).hexdigest()[:12]
cover_path = (
find_cover_image(title, None, "series", cover_dir) if fetch_covers else None
)
series_folder = get_media_folder_path(title, None, "series", cover_dir)
all_episode_files = _collect_episode_files(items)
ep_reel_tasks: List[Tuple[str, Path, int, int, str]] = []
seasons_data = await _build_seasons_data(
all_episode_files,
None,
series_folder,
title,
fetch_covers,
generate_showreels,
season_cache,
ep_reel_tasks,
media_root,
)
if not seasons_data:
logger.info(" Skipping %s - no episodes found", title)
continue
item_timestamps = [get_added_timestamp(item.path) for item in items]
item_timestamps = [t for t in item_timestamps if t is not None]
newest = max(item_timestamps) if item_timestamps else None
series = Series(
id=series_id,
title=title,
newest=newest,
cover_path=make_relative_path(cover_path, media_root),
seasons=seasons_data,
)
yield series, ep_reel_tasks