stream-fusion-better/stream_fusion/web/root/catalog/views.py
LimeDrive 8468bae024 feat(admin): add metadata mappings table and complete admin UI redesign
Introduce a metadata_mappings table with DAO/model layers for admin-managed
IMDB→TMDB/title overrides, and overhaul the admin panel with a modern
Bootstrap 5 sidebar UI.

- Add metadata_mappings table with migrations (search_titles, year_override)
- Add MetadataMappingDAO with standalone session helpers for use outside FastAPI
- Add new admin pages: dashboard, mappings CRUD, maintenance, config, hash/TMDB search
- Integrate DB mapping lookups into Cinemeta and TMDB metadata providers
- Update catalog views to use DB mappings before hitting external APIs
- Rewrite all admin templates with Bootstrap 5, sidebar navigation, French labels
- Add session-based auth for admin routes with Redis session store
2026-03-29 04:46:51 +02:00

462 lines
19 KiB
Python

import asyncio
import pickle
from datetime import datetime, timedelta
from redis import Redis
from tmdbv3api import TMDb, Movie, TV, Season, Discover, Find
from fastapi import APIRouter, Depends, HTTPException, Request
from stream_fusion.services.postgresql.dao.apikey_dao import APIKeyDAO
from stream_fusion.services.postgresql.dao.torrentitem_dao import TorrentItemDAO
from stream_fusion.settings import settings
from stream_fusion.utils.parse_config import parse_config
from stream_fusion.utils.security.security_api_key import check_api_key
from stream_fusion.web.root.catalog.schemas import (
ErrorResponse,
MetaItem,
Metas,
Meta,
Video,
)
from stream_fusion.services.redis.redis_config import get_redis
from stream_fusion.logging_config import logger
router = APIRouter()
tmdb = TMDb()
tmdb.api_key = settings.tmdb_api_key
tmdb.language = settings.tmdb_language
movie = Movie()
tv = TV()
season = Season()
discover = Discover()
find = Find()
async def get_movie_details(tmdb_id):
return await asyncio.to_thread(movie.details, tmdb_id)
async def get_tv_details(tmdb_id):
return await asyncio.to_thread(tv.details, tmdb_id)
async def get_tv_season_details(tmdb_id, season_number):
return await asyncio.to_thread(season.details, tmdb_id, season_number)
async def validate_config_and_api_key(config: str, apikey_dao: APIKeyDAO):
config_data = parse_config(config)
api_key = config_data.get("apiKey")
if api_key:
await check_api_key(api_key, apikey_dao)
return api_key
async def get_cached_item(redis_client: Redis, cache_key: str):
cached_item = await asyncio.to_thread(redis_client.get, cache_key)
if cached_item:
return pickle.loads(cached_item)
return None
async def cache_item(
redis_client: Redis, cache_key: str, item, duration: int = 7 * 24 * 60 * 60
):
await asyncio.to_thread(
redis_client.set, cache_key, pickle.dumps(item), ex=duration
)
def extract_year(date_string):
if date_string and len(date_string) >= 4:
return date_string[:4]
return None
async def create_meta_object(details, item_type: str, imdb_id: str, include_episodes: bool = True):
"""
Crée un objet Meta à partir des détails TMDB.
Args:
include_episodes: Si False, ne charge pas les épisodes (pour le catalogue).
Si True, charge tous les épisodes (pour le endpoint /meta).
"""
meta = Meta(
id=imdb_id,
name=getattr(details, "title", None) or getattr(details, "name", None),
type=item_type,
poster=(
f"https://image.tmdb.org/t/p/w500{details.poster_path}"
if details.poster_path
else None
),
background=(
f"https://image.tmdb.org/t/p/original{details.backdrop_path}"
if details.backdrop_path
else None
),
country=(
details.production_countries[0].name
if details.production_countries
else None
),
tv_language=details.original_language,
logo=None,
genres=[genre.name for genre in details.genres] if details.genres else None,
description=details.overview,
runtime=(
f"{str(details.runtime)} minutes" if item_type == "movie" and details.runtime else None
),
website=details.homepage,
imdb_rating=str(details.vote_average) if details.vote_average else None,
year=extract_year(
details.release_date if item_type == "movie" else details.first_air_date
),
)
if item_type == "movie":
meta.stream = {
"id": imdb_id
}
elif item_type == "series" and include_episodes and hasattr(details, "seasons"):
# Charger les épisodes seulement si demandé (pour /meta, pas pour /catalog)
meta.videos = []
for season in details.seasons:
season_details = await get_tv_season_details(
details.id, season.season_number
)
for episode in season_details.episodes:
meta.videos.append(
Video(
id=f"{imdb_id}:{season.season_number}:{episode.episode_number}",
title=episode.name,
released=str(episode.air_date),
season=season.season_number,
episode=episode.episode_number,
)
)
return meta
async def get_tmdb_id_from_imdb(imdb_id: str) -> str:
# Check DB mapping first to avoid unnecessary TMDB API calls
try:
from stream_fusion.services.postgresql.dao.metadatamapping_dao import lookup_mapping_by_imdb_id
db_mapping = await lookup_mapping_by_imdb_id(imdb_id)
if db_mapping and db_mapping.tmdb_id:
logger.debug(f"catalog: DB mapping {imdb_id} → tmdb_id={db_mapping.tmdb_id}")
return db_mapping.tmdb_id
except Exception as e:
logger.warning(f"catalog: DB mapping lookup failed for {imdb_id}: {e}")
results = await asyncio.to_thread(find.find_by_imdb_id, imdb_id)
if results.movie_results:
return results.movie_results[0]["id"]
elif results.tv_results:
return results.tv_results[0]["id"]
return None
@router.get(
"/{config}/catalog/{type}/{id}.json", responses={500: {"model": ErrorResponse}}
)
@router.get(
"/{config}/catalog/{type}/{id}/skip={skip}.json", responses={500: {"model": ErrorResponse}}
)
async def get_catalog(
config: str,
type: str,
id: str,
request: Request,
skip: int = 0,
redis_client: Redis = Depends(get_redis),
apikey_dao: APIKeyDAO = Depends(),
torrentitem_dao: TorrentItemDAO = Depends()
):
try:
config_data = parse_config(config)
api_key = config_data.get("apiKey")
if api_key:
await check_api_key(api_key, apikey_dao)
logger.debug(
f"Received catalog request from api_key: {api_key}, type: {type}, id: {id}, config: {config_data}"
)
if type not in {"movie", "series"} or id not in {
"latest_movies",
"recently_added_movies",
"latest_tv_shows",
"recently_added_tv_shows",
}:
raise HTTPException(status_code=400, detail="Invalid type or catalog id")
cache_key = f"catalog:{type}:{id}"
cached_catalog = await get_cached_item(redis_client, cache_key)
if cached_catalog:
logger.info(f"Catalog found in cache for key: {cache_key}")
full_catalog = Metas.model_validate(cached_catalog)
return Metas(metas=full_catalog.metas[skip:])
logger.info(f"Catalog not found in cache for key: {cache_key}. Generating...")
item_ids = []
episode_info_map = {} # Mapping tmdb_id -> episode info pour les séries
# Map catalog IDs to item types for PostgreSQL queries
catalog_type_map = {
"latest_movies": "movie",
"recently_added_movies": "movie",
"latest_tv_shows": "series",
"recently_added_tv_shows": "series",
}
item_type = catalog_type_map.get(id)
# For "latest_*": Different logic for movies vs series
if id.startswith("latest_"):
try:
if item_type == "movie":
# Films: TMDB discover (films récents des 6 derniers mois) + filtre FR disponible en PostgreSQL
logger.info(f"Fetching latest movies from TMDB discover (last 6 months) and filtering by PostgreSQL availability")
today = datetime.now().strftime('%Y-%m-%d')
six_months_ago = (datetime.now() - timedelta(days=180)).strftime('%Y-%m-%d')
tmdb_results = await asyncio.gather(
*[asyncio.to_thread(discover.discover_movies, {
'sort_by': 'popularity.desc',
'primary_release_date.gte': six_months_ago,
'primary_release_date.lte': today,
'page': page,
}) for page in range(1, 11)]
)
all_tmdb_ids = []
for results in tmdb_results:
for item in results:
if hasattr(item, 'id'):
all_tmdb_ids.append(item.id)
logger.info(f"Fetched {len(all_tmdb_ids)} TMDB IDs from TMDB discover for {id}")
# Filtre par disponibilité FR/MULTI, trié par date d'ajout en base (plus récent en premier)
item_ids = await torrentitem_dao.filter_existing_tmdb_ids(all_tmdb_ids, item_type, sort_by_added=True)
logger.info(f"Filtered to {len(item_ids)} available TMDB IDs (FR/MULTI, sorted by added date) for {id}")
else:
# Séries: TMDB discover avec air_date récent (7 derniers jours) + filtre FR en PostgreSQL
# Utilise discover au lieu de on_the_air car on_the_air vire trop vite les séries binge-release
logger.info(f"Fetching latest series from TMDB discover (air_date last 7 days) and filtering by PostgreSQL availability")
today = datetime.now().strftime('%Y-%m-%d')
week_ago = (datetime.now() - timedelta(days=7)).strftime('%Y-%m-%d')
tmdb_results = await asyncio.gather(
*[asyncio.to_thread(discover.discover_tv_shows, {
'air_date.gte': week_ago,
'without_genres': '10763,10764,10766,10767',
'air_date.lte': today,
'sort_by': 'popularity.desc',
'page': page
}) for page in range(1, 11)]
)
all_tmdb_ids = []
for results in tmdb_results:
for item in results:
if hasattr(item, 'id'):
all_tmdb_ids.append(item.id)
logger.info(f"Fetched {len(all_tmdb_ids)} series TMDB IDs from TMDB discover (air_date) for {id}")
# Filtre par disponibilité FR/MULTI, trié par dernier nouvel épisode en base
# Récupère aussi les infos d'épisode pour l'afficher dans le titre
episode_data = await torrentitem_dao.filter_existing_tmdb_ids(all_tmdb_ids, item_type, sort_by_added=True, return_episode_info=True)
item_ids = [ep['tmdb_id'] for ep in episode_data]
# Créer un mapping tmdb_id -> episode info
episode_info_map = {ep['tmdb_id']: ep for ep in episode_data}
logger.info(f"Filtered to {len(item_ids)} available series TMDB IDs (FR/MULTI, sorted by new episode date) for {id}")
except Exception as e:
logger.warning(f"Failed to fetch latest catalog for {id}: {e}. Falling back to PostgreSQL only.")
item_ids = await torrentitem_dao.get_latest_tmdb_ids(item_type, limit=50)
# For "recently_added_*": Use PostgreSQL directly (recent uploads)
elif id.startswith("recently_added_"):
try:
logger.info(f"Fetching recently added TMDB IDs from PostgreSQL for {item_type}")
item_ids = await torrentitem_dao.get_recently_added_tmdb_ids(item_type, limit=50)
logger.info(f"Fetched {len(item_ids)} TMDB IDs from PostgreSQL for {id}")
except Exception as pg_error:
logger.warning(f"Failed to fetch catalog from PostgreSQL for {id}: {pg_error}")
item_ids = []
# Fallback to TMDb discover if no results
if not item_ids:
logger.info(f"Fallback: Fetching catalog from TMDb discover for type: {type}, id: {id}")
try:
tmdb_params = {"page": 1}
if type == "movie":
discover_func = discover.discover_movies
else:
discover_func = discover.discover_tv_shows
results = await asyncio.to_thread(discover_func, tmdb_params)
item_ids = [item.id for item in results if hasattr(item, 'id')]
logger.info(f"Fetched {len(item_ids)} IDs from TMDb discover for {type}/{id}")
except Exception as tmdb_error:
logger.error(f"Failed to fetch catalog from TMDb for {type}/{id}: {tmdb_error}", exc_info=True)
item_ids = []
pipeline = redis_client.pipeline()
process_limit = 50
def _apply_episode_prefix(meta: Meta, tmdb_id: int) -> None:
if tmdb_id not in episode_info_map:
return
ep_info = episode_info_map[tmdb_id]
ep_season = ep_info.get('season', '').strip('[]') or ''
ep_episode = ep_info.get('episode', '').strip('[]') or ''
if ep_season:
ep_prefix = f"S{ep_season.zfill(2)}E{ep_episode.zfill(2)}" if ep_episode else f"S{ep_season.zfill(2)}"
meta.name = f"{ep_prefix} - {meta.name}"
async def process_one(tmdb_id: int):
item_cache_key_tmdb = f"tmdbid_item:{tmdb_id}"
cached_item = await get_cached_item(redis_client, item_cache_key_tmdb)
if cached_item:
try:
meta = Meta.model_validate(cached_item)
_apply_episode_prefix(meta, tmdb_id)
return meta
except Exception as validation_error:
logger.warning(f"Failed to validate cached meta for TMDB ID {tmdb_id}: {validation_error}")
try:
if type == "movie":
details = await get_movie_details(tmdb_id)
item_type = "movie"
imdb_id = getattr(details, 'imdb_id', None)
else:
item_type = "series"
details, cached_imdb_id = await asyncio.gather(
get_tv_details(tmdb_id),
asyncio.to_thread(redis_client.get, f"tmdbid_to_imdbid:{tmdb_id}"),
)
if cached_imdb_id:
imdb_id = cached_imdb_id.decode('utf-8') if isinstance(cached_imdb_id, bytes) else cached_imdb_id
logger.debug(f"IMDb ID found in cache for TMDB ID {tmdb_id}: {imdb_id}")
else:
# Check DB mapping before hitting TMDB API
try:
from stream_fusion.services.postgresql.dao.metadatamapping_dao import lookup_mapping_by_tmdb_id
db_mapping = await lookup_mapping_by_tmdb_id(str(tmdb_id))
if db_mapping and db_mapping.imdb_id:
imdb_id = db_mapping.imdb_id
logger.debug(f"catalog: DB mapping tmdb_id={tmdb_id}{imdb_id}")
else:
external_ids = await asyncio.to_thread(tv.external_ids, tmdb_id)
imdb_id = external_ids.get("imdb_id")
except Exception:
external_ids = await asyncio.to_thread(tv.external_ids, tmdb_id)
imdb_id = external_ids.get("imdb_id")
if not imdb_id:
logger.warning(f"No IMDb ID found for TMDB ID: {tmdb_id}")
return None
meta = await create_meta_object(details, item_type, imdb_id, include_episodes=False)
try:
pipeline.set(item_cache_key_tmdb, pickle.dumps(meta), ex=7 * 24 * 60 * 60)
pipeline.set(f"imdbid_item:{imdb_id}", pickle.dumps(meta), ex=7 * 24 * 60 * 60)
pipeline.set(f"tmdbid_to_imdbid:{tmdb_id}", imdb_id, ex=7 * 24 * 60 * 60)
except Exception as cache_err:
logger.error(f"Error adding item TMDB:{tmdb_id}/IMDB:{imdb_id} to cache pipeline: {cache_err}")
_apply_episode_prefix(meta, tmdb_id)
return meta
except Exception as e:
logger.error(f"Error processing item with TMDB ID {tmdb_id}: {str(e)}")
return None
results = await asyncio.gather(*[process_one(tmdb_id) for tmdb_id in item_ids[:process_limit]])
metas = [m for m in results if m is not None]
try:
await asyncio.to_thread(pipeline.execute)
except Exception as pipe_exec_err:
logger.error(f"Error executing cache pipeline: {pipe_exec_err}")
catalog = Metas(metas=metas)
try:
await cache_item(redis_client, cache_key, catalog, 1800)
except Exception as cat_cache_err:
logger.error(f"Error caching final catalog {cache_key}: {cat_cache_err}")
logger.info(f"Catalog generated and cached for key: {cache_key} with {len(metas)} items.")
return Metas(metas=catalog.metas[skip:])
except Exception as e:
logger.error(f"Catalog error: {str(e)}", exc_info=True)
raise HTTPException(
status_code=500, detail="An error occurred while processing the request."
)
@router.get(
"/{config}/meta/{type}/{id}.json", responses={500: {"model": ErrorResponse}}
)
async def get_meta(
config: str,
type: str,
id: str,
request: Request,
redis_client: Redis = Depends(get_redis),
apikey_dao: APIKeyDAO = Depends()
):
try:
config_data = parse_config(config)
api_key = config_data.get("apiKey")
if api_key:
await check_api_key(api_key, apikey_dao)
logger.debug(
f"Received meta request from api_key: {api_key}, type: {type}, id: {id}"
)
if type not in {"movie", "series"}:
raise HTTPException(status_code=400, detail="Invalid type")
cache_key = f"imdbid_item:{id}"
cached_meta = await get_cached_item(redis_client, cache_key)
if cached_meta:
logger.info(f"Meta found in cache for IMDB ID: {id}")
meta = Meta.model_validate(cached_meta)
return MetaItem(meta=meta)
logger.info(f"Meta not found in cache for IMDB ID: {id}, fetching from TMDB")
tmdb_id = await get_tmdb_id_from_imdb(id)
if not tmdb_id:
logger.warning(f"No TMDB ID found for IMDB ID: {id}")
raise HTTPException(status_code=404, detail="Item not found")
if type == "movie":
details = await get_movie_details(tmdb_id)
item_type = "movie"
else:
details = await get_tv_details(tmdb_id)
item_type = "series"
logger.debug(f"Creating Meta object for {item_type} with IMDB ID: {id}")
meta = await create_meta_object(details, item_type, id)
await cache_item(redis_client, cache_key, meta)
await cache_item(redis_client, f"tmdbid_item:{tmdb_id}", meta)
await cache_item(redis_client, f"tmdbid_to_imdbid:{tmdb_id}", id)
logger.info(f"Meta generated and cached for IMDB ID: {id}, TMDB ID: {tmdb_id}")
return MetaItem(meta=meta)
except Exception as e:
logger.error(f"Meta error: {str(e)}", exc_info=True)
raise HTTPException(
status_code=500, detail="An error occurred while processing the request."
)