# -*- coding: utf-8 -*-
# plugin.video.alkoflix
# resources/lib/catalogue/catalogs.py

import json
import re
import unicodedata
from urllib.parse import quote

import xbmcgui
from datetime import timedelta

from alko import client
from modules import kodi_utils as ku, user_ratings
from modules import settings as ks
from indexers import tmdb_api
from caches.main_cache import MainCache
from caches.favourites_cache import favourites_context_label, episode_favourite_type_from_tv, collection_favourite_type_from_saga_type, COLLECTION_FAVOURITE_TYPES
from catalogue import french_stream

# Aucun catalogue externe n'est activé par défaut.
# L'utilisateur choisit son manifest via le réglage "Catalogue personnalisé".
# Le manifest local French Stream reste seulement une référence embarquée,
# jamais un fallback automatique sur un autre catalogue personnalisé.
CATALOGUE_PAGE_SIZE = 20
LOCAL_MANIFEST = 'special://home/addons/plugin.video.alkoflix/resources/data/catalogues/french_stream_public.json'

build_url, make_listitem, add_item, add_items, add_dir = ku.build_url, ku.make_listitem, ku.add_item, ku.add_items, ku.add_dir
end_directory, set_content, set_category, set_view_mode = ku.end_directory, ku.set_content, ku.set_category, ku.set_view_mode
media_path, notification, logger = ku.media_path, ku.notification, ku.logger

CACHE_PREFIX = 'alkoflix_catalogue_'


def _cache_get(key):
	try: return MainCache().get(CACHE_PREFIX + key)
	except: return None


def _cache_set(key, data, hours=24):
	try: MainCache().set(CACHE_PREFIX + key, data, expiration=timedelta(hours=hours))
	except: pass
	return data


def _translate(path):
	try: return ku.translate_path(path)
	except: return path


def _read_json_file(path):
	try:
		with open(_translate(path), 'r', encoding='utf-8') as fh:
			return json.load(fh)
	except Exception as e:
		logger('catalogues local manifest error', e)
		return {}


def _json_request(url, cache_hours=6):
	# Les catalogues externes peuvent être lents. On garde une petite mise en cache
	# pour éviter de recharger le manifest et la même page à chaque ouverture de menu.
	cache_key = 'json_%s' % url
	cached = _cache_get(cache_key)
	if cached is not None: return cached
	try:
		result = client.request(url, headers={'Accept': 'application/json'}, timeout='20')
		data = json.loads(result) if result else {}
		# Certains endpoints de catalogue peuvent répondre JSON null.
		# On normalise en dictionnaire vide pour éviter les .get() sur None.
		if data is None:
			data = {}
		return _cache_set(cache_key, data, hours=cache_hours)
	except Exception as e:
		logger('catalogues request error', e)
		return {}


def _manifest_url():
	# Le catalogue Stremio n'est plus utilisé comme catalogue intégré.
	# Il reste disponible uniquement si l'utilisateur configure un manifest personnalisé.
	try: custom_url = ku.get_setting('catalogue.custom.manifest_url', '')
	except: custom_url = ''
	if not custom_url:
		try: custom_url = ku.get_setting('catalogue.french_stream_public.manifest_url', '')
		except: custom_url = ''
	return (custom_url or '').strip()


def has_custom_manifest():
	return bool(_manifest_url())


def custom_manifest_label():
	try:
		manifest = _manifest()
		return french_stream.manifest_label(manifest)
	except:
		return 'Catalogue personnalisé'


def custom_manifest_icon():
	try:
		manifest = _manifest()
		return french_stream.manifest_icon(manifest)
	except:
		return 'folder.png'


def clear_catalogue_cache():
	# Les menus et pages de catalogues externes sont mis en cache dans MainCache.
	# Lorsqu'un manifest personnalisé est changé ou supprimé, ces entrées doivent
	# être vidées afin que le menu racine reflète immédiatement le nouvel état.
	try:
		cache = MainCache()
		cache.dbcur.execute("SELECT id FROM maincache WHERE id LIKE ?", (CACHE_PREFIX + '%',))
		remove_list = [str(i[0]) for i in cache.dbcur.fetchall()]
		if remove_list:
			cache.dbcur.execute("DELETE FROM maincache WHERE id LIKE ?", (CACHE_PREFIX + '%',))
			cache.dbcur.execute("VACUUM")
			for item in remove_list: cache.delete_memory_cache(item)
		return True
	except Exception as e:
		logger('catalogues clear cache error', e)
		return False


def _base_url(manifest_url=''):
	manifest_url = (manifest_url or _manifest_url()).strip()
	if not manifest_url: return ''
	if manifest_url.endswith('/manifest.json'): return manifest_url[:-14].rstrip('/')
	return manifest_url.rsplit('/', 1)[0].rstrip('/')


def _fold(value):
	value = str(value or '').strip()
	try:
		value = ''.join(char for char in unicodedata.normalize('NFD', value) if unicodedata.category(char) != 'Mn')
	except:
		pass
	return value.lower()


def _catalogue_media_type(catalog_type='', catalog_id='', catalog_name=''):
	# Certains manifests personnalisés utilisent des types d'affichage non standards
	# (ex. "Films" / "Séries") dans les catalogues, alors que le reste d'alkoFlix
	# attend les types Stremio/Kodi internes movie/series.
	ctype = _fold(catalog_type)
	cid = _fold(catalog_id)
	cname = _fold(catalog_name)
	if ctype in ('series', 'serie', 'series tv', 'tvshow', 'tvshows', 'anime.series'):
		return 'series'
	if ctype in ('series', 'serie') or ctype.startswith('serie'):
		return 'series'
	if ctype in ('movie', 'movies', 'film', 'films', 'anime.movie'):
		return 'movie'
	if ctype == 'anime':
		# AIOMetadata expose plusieurs catalogues MAL sous le type générique "anime".
		# Quand le nom/ID indique explicitement "movies", on garde une navigation film;
		# le reste reste côté séries/fiche meta, ce qui couvre les saisons/épisodes.
		if 'movie' in cid or 'movies' in cname or 'films' in cname:
			return 'movie'
		return 'series'
	if 'series' in cid or '.series' in cid or '_series' in cid or 'shows' in cname:
		return 'series'
	if 'movie' in cid or '.movie' in cid or '_movie' in cid or 'movies' in cname:
		return 'movie'
	return 'movie'


def _catalogue_type_label(catalog_type='', catalog_id='', catalog_name=''):
	return 'Séries' if _catalogue_media_type(catalog_type, catalog_id, catalog_name) == 'series' else 'Films'


def _catalogue_page_size(cat=None):
	try:
		return max(1, int((cat or {}).get('pageSize') or CATALOGUE_PAGE_SIZE))
	except:
		return CATALOGUE_PAGE_SIZE


def _catalogue_allows_options_folder(cat):
	# Certains manifests Stremio/AIO exposent un extra "genre" optionnel sur presque
	# tous les catalogues. Si on transforme automatiquement cet extra en dossier,
	# chaque liste/plateforme devient inutilement un sous-menu de genres.
	# On ne garde donc les dossiers d'options que pour les catalogues qui sont
	# explicitement des catalogues de filtres (genres, années, langues, studios, etc.).
	if not isinstance(cat, dict):
		return False
	text = ' '.join(str(cat.get(k) or '') for k in ('id', 'name', 'type', 'source')).lower()
	try:
		metadata = cat.get('metadata') or {}
		if isinstance(metadata, dict):
			text = '%s %s' % (text, ' '.join(str(metadata.get(k) or '') for k in ('description', 'url')).lower())
	except:
		pass
	keywords = (
		'genre', 'genres', 'by genre', 'par genre',
		'year', 'years', 'by year', 'par année', 'annee', 'année',
		'language', 'languages', 'by language', 'par langue',
		'studio', 'studios', 'by studio',
		'season', 'seasons', 'decade', 'decades',
		'collection', 'collections'
	)
	return any(keyword in text for keyword in keywords)


def _catalogue_options_extra(cat):
	allow_options_folder = _catalogue_allows_options_folder(cat)
	for extra in (cat or {}).get('extra', []) or []:
		if not isinstance(extra, dict):
			continue
		name = str(extra.get('name') or '').strip()
		if not name or name == 'skip' or name == 'search':
			continue
		options = extra.get('options') or []
		if isinstance(options, list) and options and allow_options_folder:
			return extra
	return {}


def _catalogue_has_unsupported_required_extra(cat):
	# Les extras requis sans options statiques (ex. calendarVideosIds) ne peuvent pas
	# être peuplés proprement depuis un simple menu Kodi. On les masque plutôt que
	# d'afficher des dossiers condamnés à rester vides.
	for extra in (cat or {}).get('extra', []) or []:
		if not isinstance(extra, dict):
			continue
		name = str(extra.get('name') or '').strip()
		if name in ('skip', 'search'):
			continue
		if extra.get('isRequired') and not extra.get('options') and not extra.get('default'):
			return True
	return False


def _catalogue_has_required_search(cat):
	return any(isinstance(extra, dict) and extra.get('name') == 'search' and extra.get('isRequired') for extra in (cat or {}).get('extra', []) or [])


def _catalogue_route_params(cat, name, mode='catalogue.catalog'):
	catalog_type = cat.get('type', 'movie')
	catalog_id = cat.get('id')
	raw_name = cat.get('name') or catalog_id
	params = {
		'mode': mode,
		'catalog_type': catalog_type,
		'catalog_media_type': _catalogue_media_type(catalog_type, catalog_id, raw_name),
		'catalog_id': catalog_id,
		'name': name,
		'iconImage': french_stream.icon_for_catalog(cat)
	}
	return french_stream.patch_route_params(cat, params)




CATALOGUE_GROUPS = (
	('movie', 'Films'),
	('series', 'Séries'),
	('anime', 'Animés japonais'),
	('lists', 'Listes')
)


NATIVE_MOVIE_GENRES = (
	('28', 'Action'), ('12', 'Aventure'), ('16', 'Animation'), ('35', 'Comédie'), ('80', 'Crime'),
	('99', 'Documentaire'), ('18', 'Drame'), ('10751', 'Famille'), ('14', 'Fantastique'), ('36', 'Histoire'),
	('27', 'Horreur'), ('10402', 'Musique'), ('9648', 'Mystère'), ('10749', 'Romance'), ('878', 'Science-fiction'),
	('10770', 'Téléfilm'), ('53', 'Thriller'), ('10752', 'Guerre'), ('37', 'Western')
)

NATIVE_SERIES_GENRES = (
	('10759', 'Action et aventure'), ('16', 'Animation'), ('35', 'Comédie'), ('80', 'Crime'),
	('18', 'Drame'), ('10751', 'Famille'), ('10762', 'Jeunesse'), ('9648', 'Mystère'),
	('10765', 'Science-fiction et fantastique'), ('10766', 'Soap'),
	('10768', 'Guerre et politique'), ('37', 'Western')
)

NATIVE_LANGUAGES = (
	('fr', 'Français'), ('en', 'Anglais'), ('ja', 'Japonais'), ('ko', 'Coréen'), ('es', 'Espagnol'),
	('it', 'Italien'), ('de', 'Allemand'), ('pt', 'Portugais'), ('zh', 'Chinois'), ('hi', 'Hindi'),
	('ar', 'Arabe'), ('ru', 'Russe'), ('tr', 'Turc'), ('pl', 'Polonais'), ('nl', 'Néerlandais')
)

# Chaque entrée peut définir une région TMDb précise.
# Les plateformes mondiales ne forcent pas de région afin de respecter le pays
# configuré par l'utilisateur. Les services vraiment locaux gardent leur région.
NATIVE_PROVIDERS = (
	# Plateformes populaires / mondiales
	('8', 'Netflix'), ('119', 'Amazon Prime Video'), ('337', 'Disney+'),
	('1899', 'Max / HBO Max'), ('350', 'Apple TV+'), ('531', 'Paramount Plus'),
	('3', 'Google Play Films'), ('2', 'Apple TV Store'), ('192', 'YouTube'),
	('300', 'Pluto TV'), ('538', 'Plex'), ('559', 'Filmzie'), ('701', 'FilmBox+'),

	# Plateformes surtout américaines
	# Région forcée à US afin d'éviter des dossiers vides chez les utilisateurs FR/CA.
	('15', 'Hulu', 'US'), ('386', 'Peacock', 'US'), ('43', 'STARZ', 'US'),
	('34', 'MGM+', 'US'), ('528', 'AMC+', 'US'),
	('99', 'Shudder', 'US'), ('73', 'Tubi', 'US'), ('207', 'The Roku Channel', 'US'),
	('87', 'Acorn TV', 'US'), ('151', 'BritBox', 'US'), ('524', 'Discovery+', 'US'),

	# Plateformes québécoises / canadiennes
	('230', 'Crave', 'CA'), ('469', 'Club illico', 'CA'),

	# Plateformes françaises / francophones
	('234', 'Arte', 'FR'), ('2671', 'ARTE Boutique', 'FR'), ('147', 'M6+', 'FR'),
	('1754', 'TF1+', 'FR'), ('58', 'Canal VOD', 'FR'), ('61', 'Orange VOD', 'FR'),
	('1967', 'Molotov TV', 'FR'), ('2601', 'Pathé Home', 'FR'), ('310', 'LaCinetek', 'FR'),
	('415', 'Animation Digital Network', 'FR'), ('513', 'Shadowz', 'FR'), ('692', 'Cultpix', 'FR'),
	('550', 'Tenk', 'FR'), ('324', 'Cinémas à la Demande', 'FR'), ('475', 'DOCSVILLE', 'FR'),
	('569', 'DocAlliance Films', 'FR'), ('2286', 'VIVA by videofutur', 'FR'), ('1860', 'Univer Video', 'FR'),
	('567', 'True Story', 'FR'), ('1875', 'Runtime', 'FR'), ('2077', 'Plex Channel', 'FR')
)

NATIVE_ANIME_PROVIDERS = (
	('283', 'Crunchyroll'), ('415', 'Animation Digital Network', 'FR'), ('8', 'Netflix'),
	('119', 'Amazon Prime Video'), ('337', 'Disney+'), ('350', 'Apple TV+'),
	('531', 'Paramount Plus')
)


# Chaînes disponibles via Prime Video Channels / Amazon Channels.
# Les chaînes US forcent la région US, car elles sont souvent absentes en région FR/CA.
# Lionsgate+ Amazon Channels est conservé en région FR, car AIOMetadata l'expose dans son manifest francophone.
NATIVE_AMAZON_CHANNELS = (
	('196', 'Acorn TV Amazon Channel', 'US'),
	('197', 'BritBox Amazon Channel', 'US'),
	('199', 'Fandor Amazon Channel', 'US'),
	('201', 'MUBI Amazon Channel', 'US'),
	('204', 'Shudder Amazon Channel', 'US'),
	('205', 'Sundance Now Amazon Channel', 'US'),
	('528', 'AMC+ Amazon Channel', 'US'),
	('582', 'Paramount+ Amazon Channel', 'US'),
	('583', 'MGM+ Amazon Channel', 'US'),
	('584', 'Discovery+ Amazon Channel', 'US'),
	('1794', 'STARZ Amazon Channel', 'US'),
	('1825', 'Max / HBO Max Amazon Channel', 'US'),
	('2358', 'Lionsgate+ Amazon Channels', 'FR')
)


# Diffuseurs TV : utilisés uniquement pour les séries via le filtre TMDb with_networks.
# Contrairement aux plateformes, ces entrées représentent des chaînes/réseaux de diffusion.
# Les entrées ci-dessous sont affichées dans une seule liste pour simplifier la navigation.
NATIVE_BROADCASTER_GROUPS = (
	('ca_qc', 'Diffuseurs québécois'),
	('fr', 'Diffuseurs français'),
	('us', 'Diffuseurs américains / internationaux')
)

NATIVE_BROADCASTERS = {
	'us': (
		('2', 'ABC'), ('16', 'CBS'), ('6', 'NBC'), ('19', 'FOX'), ('71', 'The CW'),
		('49', 'HBO'), ('174', 'AMC'), ('88', 'FX'), ('77', 'Syfy'), ('129', 'A&E'),
		('80', 'Adult Swim'), ('64', 'Discovery'), ('43', 'National Geographic'), ('13', 'Nickelodeon'),
		('67', 'Showtime'), ('318', 'Starz'), ('3353', 'Peacock'), ('453', 'Hulu'),
		('4330', 'Paramount+'), ('213', 'Netflix'), ('1024', 'Prime Video'), ('2552', 'Apple TV+'),
		('2739', 'Disney+'), ('4', 'BBC One'), ('21', 'Warner Bros')
	),
	'ca_qc': (
		('141', 'ICI Radio-Canada Télé'), ('302', 'TVA'), ('4161', 'Noovo'),
		('1131', 'Télé-Québec'), ('1312', 'ICI TOU.TV'), ('3529', 'ICI TOU.TV - EXTRA'),
		('1290', 'Club Illico'), ('7846', 'illico+'), ('1344', 'Crave'), ('5071', 'Vrai')
	),
	'fr': (
		('290', 'TF1'), ('361', 'France 2'), ('249', 'France 3'), ('255', 'France 5'),
		('285', 'Canal+'), ('712', 'M6'), ('1628', 'ARTE'), ('2638', 'Gulli'), ('1133', 'TMC'),
		('1899', 'OCS Max'), ('2058', '13e rue')
	)
}


# Plateformes pertinentes pour le menu « Enfants & Famille ».
# Les services mondiaux suivent la région utilisateur; les services locaux gardent
# leur région afin de limiter les dossiers vides et les résultats hors zone.
NATIVE_FAMILY_PROVIDERS = (
	# Plateformes mondiales / familiales
	('337', 'Disney+'), ('175', 'Netflix Kids'), ('8', 'Netflix'), ('119', 'Amazon Prime Video'),
	('350', 'Apple TV+'), ('531', 'Paramount Plus'), ('1899', 'Max / HBO Max'),
	('192', 'YouTube'), ('300', 'Pluto TV'), ('538', 'Plex'), ('73', 'Tubi', 'US'),

	# Plateformes québécoises / canadiennes
	('230', 'Crave', 'CA'), ('469', 'Club illico', 'CA'),

	# Plateformes françaises / francophones
	('234', 'Arte', 'FR'), ('147', 'M6+', 'FR'), ('1754', 'TF1+', 'FR'),
	('415', 'Animation Digital Network', 'FR'), ('1967', 'Molotov TV', 'FR')
)

# Chaînes Prime Video/Amazon Channels à tendance jeunesse ou familiale.
# Les chaînes US forcent US; elles seront donc clairement isolées dans leur tiroir.
NATIVE_FAMILY_AMAZON_CHANNELS = (
	('582', 'Paramount+ Amazon Channel', 'US'),
	('584', 'Discovery+ Amazon Channel', 'US'),
	('1825', 'Max / HBO Max Amazon Channel', 'US')
)

# Réseaux jeunesse : utilisés pour les séries TV seulement via le filtre TMDb with_networks.
# Ce sont des réseaux de diffusion/production, pas des plateformes de streaming.
NATIVE_FAMILY_KIDS_NETWORKS = (
	('56', 'Cartoon Network'),
	('13', 'Nickelodeon'),
	('35', 'Nick Jr.'),
	('54', 'Disney Channel'),
	('44', 'Disney XD'),
	('281', 'Disney Junior'),
	('14', 'PBS Kids'),
	('523', 'Boomerang'),
	('2638', 'Gulli'),
	('83', 'Télétoon')
)


FAMILY_TYPE_LABELS = {
	'movie_family': 'Films familiaux',
	'series_family': 'Séries familiales',
	'movie_family_kids': 'Films',
	'series_family_kids': 'Séries',
	'movie_animation': 'Films d’animation',
	'series_animation': 'Séries d’animation'
}

FAMILY_ACTIONS = {
	'movie_family': {
		'mode': 'build_movie_list', 'popular': 'tmdb_movies_family_popular', 'random': 'tmdb_movies_family_random',
		'latest': 'tmdb_movies_family_latest_releases', 'year': 'tmdb_movies_family_year',
		'provider': 'tmdb_movies_family_watch_provider', 'genre': 'tmdb_movies_family_genres'
	},
	'series_family': {
		'mode': 'build_tvshow_list', 'popular': 'tmdb_tv_family_popular', 'random': 'tmdb_tv_family_random',
		'latest': 'tmdb_tv_family_airing_this_week', 'year': 'tmdb_tv_family_year',
		'provider': 'tmdb_tv_family_watch_provider', 'genre': 'tmdb_tv_family_genres'
	},
	'movie_family_kids': {
		'mode': 'build_movie_list', 'provider': 'tmdb_movies_family_kids_watch_provider'
	},
	'series_family_kids': {
		'mode': 'build_tvshow_list', 'provider': 'tmdb_tv_family_kids_watch_provider'
	},
	'movie_animation': {
		'mode': 'build_movie_list', 'popular': 'tmdb_movies_family_animation_popular', 'random': 'tmdb_movies_family_animation_random',
		'latest': 'tmdb_movies_family_animation_latest_releases', 'year': 'tmdb_movies_family_animation_year',
		'provider': 'tmdb_movies_family_animation_watch_provider', 'genre': 'tmdb_movies_family_animation_genres'
	},
	'series_animation': {
		'mode': 'build_tvshow_list', 'popular': 'tmdb_tv_family_animation_popular', 'random': 'tmdb_tv_family_animation_random',
		'latest': 'tmdb_tv_family_animation_airing_this_week', 'year': 'tmdb_tv_family_animation_year',
		'provider': 'tmdb_tv_family_animation_watch_provider', 'genre': 'tmdb_tv_family_animation_genres'
	}
}


FAMILY_MOVIE_GENRES = (
	('10751', 'Famille'), ('16', 'Animation'), ('12', 'Aventure'), ('35', 'Comédie'),
	('14', 'Fantastique'), ('10402', 'Musique'), ('878', 'Science-fiction'),
	('99', 'Documentaire'), ('10770', 'Téléfilm')
)

FAMILY_SERIES_GENRES = (
	('10751', 'Famille'), ('10762', 'Jeunesse'), ('16', 'Animation'),
	('35', 'Comédie'), ('99', 'Documentaire'), ('10759', 'Action et aventure'),
	('10765', 'Science-fiction et fantastique')
)

FAMILY_ANIMATION_MOVIE_GENRES = (
	('16', 'Animation'), ('10751', 'Famille'), ('12', 'Aventure'), ('35', 'Comédie'),
	('14', 'Fantastique'), ('10402', 'Musique'), ('878', 'Science-fiction'),
	('99', 'Documentaire')
)

FAMILY_ANIMATION_SERIES_GENRES = (
	('16', 'Animation'), ('10762', 'Jeunesse'), ('10751', 'Famille'),
	('35', 'Comédie'), ('10759', 'Action et aventure'), ('10765', 'Science-fiction et fantastique'),
	('99', 'Documentaire')
)


def _family_genres_for_type(family_type):
	if family_type == 'movie_animation': return FAMILY_ANIMATION_MOVIE_GENRES
	if family_type == 'series_animation': return FAMILY_ANIMATION_SERIES_GENRES
	if family_type == 'series_family': return FAMILY_SERIES_GENRES
	return FAMILY_MOVIE_GENRES


DOCUMENTARY_TYPE_LABELS = {
	'movie_documentary': 'Films documentaires',
	'movie_tv_movie': 'Téléfilms',
	'series_documentary': 'Séries documentaires',
	'series_reality': 'Téléréalité',
	'series_talkshow': 'Talk-shows',
	'series_news': 'Actualités'
}

DOCUMENTARY_GENRES = {
	'movie_documentary': ('99', 'genre_documentary.png'),
	'movie_tv_movie': ('10770', 'genre_tv.png'),
	'series_documentary': ('99', 'genre_documentary.png'),
	'series_reality': ('10764', 'genre_reality.png'),
	'series_talkshow': ('10767', 'genre_talk.png'),
	'series_news': ('10763', 'genre_news.png')
}

DOCUMENTARY_MOVIE_GENRES = (
	('28', 'Action'), ('12', 'Aventure'), ('16', 'Animation'), ('35', 'Comédie'), ('80', 'Crime'),
	('18', 'Drame'), ('10751', 'Famille'), ('14', 'Fantastique'), ('36', 'Histoire'),
	('27', 'Horreur'), ('10402', 'Musique'), ('9648', 'Mystère'), ('10749', 'Romance'),
	('878', 'Science-fiction'), ('53', 'Thriller'), ('10752', 'Guerre'), ('37', 'Western')
)

DOCUMENTARY_TV_MOVIE_GENRES = (
	('28', 'Action'), ('12', 'Aventure'), ('16', 'Animation'), ('35', 'Comédie'), ('80', 'Crime'),
	('99', 'Documentaire'), ('18', 'Drame'), ('10751', 'Famille'), ('14', 'Fantastique'), ('36', 'Histoire'),
	('27', 'Horreur'), ('10402', 'Musique'), ('9648', 'Mystère'), ('10749', 'Romance'),
	('878', 'Science-fiction'), ('53', 'Thriller'), ('10752', 'Guerre'), ('37', 'Western')
)

DOCUMENTARY_SERIES_GENRES = (
	('10759', 'Action et aventure'), ('16', 'Animation'), ('35', 'Comédie'), ('80', 'Crime'),
	('18', 'Drame'), ('10751', 'Famille'), ('10762', 'Jeunesse'), ('9648', 'Mystère'),
	('10765', 'Science-fiction et fantastique'), ('10766', 'Soap'), ('10768', 'Guerre et politique'), ('37', 'Western')
)

DOCUMENTARY_REALITY_GENRES = (
	('99', 'Documentaire'), ('10759', 'Action et aventure'), ('16', 'Animation'), ('35', 'Comédie'),
	('80', 'Crime'), ('18', 'Drame'), ('10751', 'Famille'), ('10762', 'Jeunesse'),
	('9648', 'Mystère'), ('10765', 'Science-fiction et fantastique'), ('10766', 'Soap'),
	('10768', 'Guerre et politique'), ('37', 'Western')
)


def _documentary_genres_for_type(documentary_type):
	if documentary_type == 'movie_tv_movie': return DOCUMENTARY_TV_MOVIE_GENRES
	if documentary_type == 'movie_documentary': return DOCUMENTARY_MOVIE_GENRES
	if documentary_type in ('series_reality', 'series_talkshow', 'series_news'): return DOCUMENTARY_REALITY_GENRES
	return DOCUMENTARY_SERIES_GENRES


DOCUMENTARY_ACTIONS = {
	'movie_documentary': {
		'mode': 'build_movie_list', 'popular': 'tmdb_movies_documentary_popular', 'random': 'tmdb_movies_documentary_random',
		'trending': 'tmdb_movies_documentary_trending', 'year': 'tmdb_movies_documentary_year',
		'language': 'tmdb_movies_documentary_language', 'provider': 'tmdb_movies_documentary_watch_provider', 'genre': 'tmdb_movies_documentary_genres'
	},
	'movie_tv_movie': {
		'mode': 'build_movie_list', 'popular': 'tmdb_movies_tv_movie_popular', 'random': 'tmdb_movies_tv_movie_random',
		'trending': 'tmdb_movies_tv_movie_trending', 'year': 'tmdb_movies_tv_movie_year',
		'language': 'tmdb_movies_tv_movie_language', 'provider': 'tmdb_movies_tv_movie_watch_provider', 'genre': 'tmdb_movies_tv_movie_genres'
	},
	'series_documentary': {
		'mode': 'build_tvshow_list', 'popular': 'tmdb_tv_documentary_popular', 'random': 'tmdb_tv_documentary_random',
		'trending': 'tmdb_tv_documentary_trending', 'airing_week': 'tmdb_tv_documentary_airing_this_week', 'year': 'tmdb_tv_documentary_year',
		'language': 'tmdb_tv_documentary_language', 'provider': 'tmdb_tv_documentary_watch_provider', 'network': 'tmdb_tv_documentary_networks', 'genre': 'tmdb_tv_documentary_genres'
	},
	'series_reality': {
		'mode': 'build_tvshow_list', 'popular': 'tmdb_tv_reality_popular', 'random': 'tmdb_tv_reality_random',
		'trending': 'tmdb_tv_reality_trending', 'airing_week': 'tmdb_tv_reality_airing_this_week', 'year': 'tmdb_tv_reality_year',
		'language': 'tmdb_tv_reality_language', 'provider': 'tmdb_tv_reality_watch_provider', 'network': 'tmdb_tv_reality_networks', 'genre': 'tmdb_tv_reality_genres'
	},
	'series_talkshow': {
		'mode': 'build_tvshow_list', 'popular': 'tmdb_tv_talkshow_popular', 'random': 'tmdb_tv_talkshow_random',
		'trending': 'tmdb_tv_talkshow_trending', 'airing_week': 'tmdb_tv_talkshow_airing_this_week', 'year': 'tmdb_tv_talkshow_year',
		'language': 'tmdb_tv_talkshow_language', 'provider': 'tmdb_tv_talkshow_watch_provider', 'network': 'tmdb_tv_talkshow_networks', 'genre': 'tmdb_tv_talkshow_genres'
	},
	'series_news': {
		'mode': 'build_tvshow_list', 'popular': 'tmdb_tv_news_popular', 'random': 'tmdb_tv_news_random',
		'trending': 'tmdb_tv_news_trending', 'airing_week': 'tmdb_tv_news_airing_this_week', 'year': 'tmdb_tv_news_year',
		'language': 'tmdb_tv_news_language', 'provider': 'tmdb_tv_news_watch_provider', 'network': 'tmdb_tv_news_networks', 'genre': 'tmdb_tv_news_genres'
	}
}


def _media_kind_from_documentary_type(documentary_type):
	return 'tv' if str(documentary_type or '').startswith('series') else 'movie'


def _documentary_content_params(documentary_type, action_key, label=''):
	documentary_type = str(documentary_type or '').strip()
	config = DOCUMENTARY_ACTIONS.get(documentary_type, {})
	action = config.get(action_key)
	if not action: return {}
	return {'mode': config.get('mode'), 'action': action, 'name': label, 'documentary_type': documentary_type}


def _native_years():
	try: current_year = __import__('datetime').datetime.now().year
	except: current_year = 2026
	return [str(year) for year in range(current_year, 1899, -1)]


def _native_content_params(native_type, item_id='', label=''):
	native_type = str(native_type or '').strip()
	if native_type == 'movie':
		return {'mode': 'build_movie_list', 'action': 'tmdb_movies_%s' % item_id, 'name': label}
	if native_type == 'series':
		return {'mode': 'build_tvshow_list', 'action': 'tmdb_tv_%s' % item_id, 'name': label}
	if native_type == 'anime_movie':
		return {'mode': 'build_movie_list', 'action': 'tmdb_moviesanime_%s' % item_id, 'name': label, 'native_type': native_type, 'fav_type': 'anime_movie'}
	if native_type == 'anime_series':
		return {'mode': 'build_tvshow_list', 'action': 'tmdb_tvanime_%s' % item_id, 'name': label, 'native_type': native_type, 'fav_type': 'anime_tv'}
	return {}


def _family_content_params(family_type, action_key, label=''):
	family_type = str(family_type or '').strip()
	config = FAMILY_ACTIONS.get(family_type, {})
	action = config.get(action_key)
	if not action: return {}
	return {'mode': config.get('mode'), 'action': action, 'name': label, 'family_type': family_type, 'fav_type': {'movie_family': 'movie_family', 'movie_family_kids': 'movie_family', 'series_family': 'tv_family', 'series_family_kids': 'tv_family', 'movie_animation': 'movie_animation', 'series_animation': 'tv_animation'}.get(family_type, '')}



def _media_kind_from_native_type(native_type):
	return 'tv' if str(native_type or '') in ('series', 'anime_series') else 'movie'


def _media_kind_from_family_type(family_type):
	return 'tv' if str(family_type or '').startswith('series') else 'movie'


def _provider_results(media_kind='movie', region=''):
	media_kind = 'tv' if str(media_kind or '') in ('tv', 'series', 'tvshow') else 'movie'
	region = str(region or '').strip().upper()
	if not region:
		try: region = ks.certification_country()
		except: region = 'FR'
	cache_key = 'provider_results_%s_%s' % (media_kind, region)
	cached = _cache_get(cache_key)
	if cached is not None: return cached or []
	try:
		url = '%s/watch/providers/%s?api_key=%s&watch_region=%s' % (tmdb_api.base_url, media_kind, tmdb_api.tmdb_api_key(), region)
		data = tmdb_api.get_tmdb(url, errors=False) or {}
		results = data.get('results', []) or []
		return _cache_set(cache_key, results, hours=168) or []
	except Exception as e:
		logger('catalogue provider results error', 'media=%s | region=%s | error=%s' % (media_kind, region, e))
		_cache_set(cache_key, [], hours=24)
	return []


def _provider_logo(provider_id, media_kind='movie', region=''):
	# Logo officiel TMDb des plateformes de streaming / providers.
	# Si TMDb ne retourne rien pour la région courante, on tente quelques régions
	# de secours avant de revenir à l'icône locale générique.
	provider_id = str(provider_id or '').strip()
	if not provider_id: return 'networks.png'
	media_kind = 'tv' if str(media_kind or '') in ('tv', 'series', 'tvshow') else 'movie'
	regions = []
	region = str(region or '').strip().upper()
	if region: regions.append(region)
	try:
		current_region = ks.certification_country()
	except:
		current_region = 'FR'
	for item in (current_region, 'FR', 'US', 'CA', 'GB'):
		item = str(item or '').strip().upper()
		if item and item not in regions: regions.append(item)
	for lookup_media in (media_kind, 'movie' if media_kind == 'tv' else 'tv'):
		for candidate_region in regions:
			cache_key = 'provider_logo_%s_%s_%s' % (lookup_media, candidate_region, provider_id)
			cached = _cache_get(cache_key)
			if cached:
				return cached
			if cached == '':
				continue
			try:
				for provider in _provider_results(lookup_media, candidate_region):
					if str(provider.get('provider_id')) == provider_id:
						logo_path = provider.get('logo_path') or ''
						logo = tmdb_api.tmdb_image_base % ('w185', logo_path) if logo_path else ''
						return _cache_set(cache_key, logo, hours=168) or 'networks.png'
			except Exception as e:
				logger('catalogue provider logo error', 'provider_id=%s | region=%s | error=%s' % (provider_id, candidate_region, e))
			_cache_set(cache_key, '', hours=24)
	return 'networks.png'


def _network_logo(network_id):
	# Logo officiel TMDb des réseaux TV, utilisé notamment pour Chaînes jeunesse.
	network_id = str(network_id or '').strip()
	if not network_id: return 'networks.png'
	cache_key = 'network_logo_%s' % network_id
	cached = _cache_get(cache_key)
	if cached is not None: return cached or 'networks.png'
	try:
		url = '%s/network/%s?api_key=%s' % (tmdb_api.base_url, network_id, tmdb_api.tmdb_api_key())
		data = tmdb_api.get_tmdb(url, errors=False) or {}
		logo_path = data.get('logo_path') or ''
		logo = tmdb_api.tmdb_image_base % ('w185', logo_path) if logo_path else ''
		return _cache_set(cache_key, logo, hours=168) or _network_logo_fallback(network_id)
	except Exception as e:
		logger('catalogue network logo error', 'network_id=%s | error=%s' % (network_id, e))
		_cache_set(cache_key, '', hours=24)
	return _network_logo_fallback(network_id)


def _network_logo_fallback(network_id):
	try:
		from modules.meta_lists import networks
		for item in networks:
			if str(item.get('id')) == str(network_id) and item.get('logo'):
				return item.get('logo')
	except:
		pass
	return 'networks.png'



def _square_menu_logo(icon, cache_group='logo'):
	try:
		from modules.artwork_utils import square_logo
		return square_logo(icon, cache_group) or icon
	except Exception as e:
		try: logger('catalogue square menu logo error', e)
		except: pass
		return icon


def _network_menu_logo(network_id):
	return _square_menu_logo(_network_logo(network_id), 'network')


def _provider_menu_logo(provider_id, media_kind='movie', region=''):
	return _square_menu_logo(_provider_logo(provider_id, media_kind, region), 'provider')

def _genre_icon(media_kind='movie', genre_id=''):
	# Réutilise les icônes locales déjà maintenues dans meta_lists.py.
	genre_id = str(genre_id or '').strip()
	try:
		from modules.meta_lists import movie_genres, tvshow_genres
		genre_list = tvshow_genres if str(media_kind or '') in ('tv', 'series', 'tvshow') else movie_genres
		for value in genre_list.values():
			if str(value[0]) == genre_id:
				return value[1]
	except:
		pass
	return 'genres.png'

CATALOGUE_NAME_TRANSLATIONS = {
	'TMDB Popular': 'Populaires',
	'TMDB Trending': 'Tendances',
	'TMDB Top Rated': 'Les mieux notés',
	'TMDB Airing Today': 'Diffusées aujourd’hui',
	'TMDB By Year': 'Par année',
	'TMDB By Language': 'Par langue',
	'TVDB Trending': 'Tendances TVDB',
	'TVDB Genres': 'Genres TVDB',
	'TVDB Collections': 'Collections TVDB',
	'TVmaze Daily Schedule': 'Programmation du jour TVmaze',
	'Movies Search': 'Recherche films',
	'Series Search': 'Recherche séries',
	'Anime Movies Search': 'Recherche films animés',
	'Anime Series Search': 'Recherche séries animées',
	'MAL Airing Now': 'En cours de diffusion',
	'MAL Upcoming Season': 'Prochaine saison',
	'MAL Airing Schedule': 'Calendrier de diffusion',
	'MAL Seasons': 'Par saison',
	'MAL Best of 80s': 'Meilleurs des années 80',
	'MAL Best of 90s': 'Meilleurs des années 90',
	'MAL Best of 2000s': 'Meilleurs des années 2000',
	'MAL Best of 2010s': 'Meilleurs des années 2010',
	'MAL Best of 2020s': 'Meilleurs des années 2020',
	'MAL Genres': 'Par genre',
	'MAL By Studio': 'Par studio',
	'MAL Top Movies': 'Meilleurs films animés',
	'MAL Top Series': 'Meilleures séries animées',
	'MAL Most Favorites': 'Les plus ajoutés aux favoris',
	'MAL Most Popular': 'Les plus populaires',
	'MAL Top Anime': 'Les mieux notés',
	'Airing Today': 'Diffusées aujourd’hui',
}

OPTION_TRANSLATIONS = {
	'None': 'Tous',
	'Day': 'Jour', 'Week': 'Semaine',
	'Monday': 'Lundi', 'Tuesday': 'Mardi', 'Wednesday': 'Mercredi', 'Thursday': 'Jeudi', 'Friday': 'Vendredi', 'Saturday': 'Samedi', 'Sunday': 'Dimanche',
	'Winter': 'Hiver', 'Spring': 'Printemps', 'Summer': 'Été', 'Fall': 'Automne',
	'English': 'Anglais', 'French': 'Français', 'Spanish': 'Espagnol', 'Italian': 'Italien', 'German': 'Allemand', 'Portuguese': 'Portugais',
	'Japanese': 'Japonais', 'Korean': 'Coréen', 'Mandarin': 'Mandarin', 'Arabic': 'Arabe', 'Russian': 'Russe', 'Hindi': 'Hindi',
	'Dutch': 'Néerlandais', 'Danish': 'Danois', 'Swedish': 'Suédois', 'Norwegian': 'Norvégien', 'Finnish': 'Finnois', 'Polish': 'Polonais',
	'Turkish': 'Turc', 'Ukrainian': 'Ukrainien', 'Greek': 'Grec', 'Hebrew': 'Hébreu', 'Hungarian': 'Hongrois', 'Romanian': 'Roumain',
	'Czech': 'Tchèque', 'Slovak': 'Slovaque', 'Slovenian': 'Slovène', 'Croatian': 'Croate', 'Serbian': 'Serbe', 'Bulgarian': 'Bulgare',
	'Action': 'Action', 'Adventure': 'Aventure', 'Action & Adventure': 'Action et aventure', 'Animation': 'Animation', 'Anime': 'Anime',
	'Comedy': 'Comédie', 'Crime': 'Crime', 'Documentary': 'Documentaire', 'Drama': 'Drame', 'Family': 'Famille', 'Fantasy': 'Fantastique',
	'History': 'Histoire', 'Horror': 'Horreur', 'Music': 'Musique', 'Mystery': 'Mystère', 'Romance': 'Romance', 'Science Fiction': 'Science-fiction',
	'TV Movie': 'Téléfilm', 'Thriller': 'Thriller', 'War': 'Guerre', 'Western': 'Western', 'Kids': 'Jeunesse', 'News': 'Actualités',
	'Reality': 'Téléréalité', 'Sci-Fi & Fantasy': 'Science-fiction et fantastique', 'Soap': 'Soap', 'Talk': 'Talk-show', 'War & Politics': 'Guerre et politique',
	'Children': 'Enfants', 'Food': 'Cuisine', 'Home and Garden': 'Maison et jardin', 'Indie': 'Indépendant', 'Martial Arts': 'Arts martiaux',
	'Mini-Series': 'Mini-série', 'Musical': 'Comédie musicale', 'Sport': 'Sport', 'Suspense': 'Suspense', 'Talk Show': 'Talk-show', 'Travel': 'Voyage',
	'Adult Cast': 'Distribution adulte', 'Anthropomorphic': 'Anthropomorphe', 'Avant Garde': 'Avant-garde', 'Award Winning': 'Primés',
	'Boys Love': 'Boys Love', 'Girls Love': 'Girls Love', 'CGDCT': 'Tranches de vie moe', 'Childcare': 'Garde d’enfants',
	'Combat Sports': 'Sports de combat', 'Crossdressing': 'Travestissement', 'Delinquents': 'Délinquants', 'Detective': 'Détective',
	'Ecchi': 'Ecchi', 'Educational': 'Éducatif', 'Erotica': 'Érotique', 'Gag Humor': 'Humour absurde', 'Gore': 'Gore', 'Gourmet': 'Gastronomie',
	'Harem': 'Harem', 'High Stakes Game': 'Jeux à enjeux élevés', 'Historical': 'Historique', 'Idols (Female)': 'Idols féminines',
	'Idols (Male)': 'Idols masculines', 'Isekai': 'Isekai', 'Iyashikei': 'Iyashikei', 'Josei': 'Josei', 'Love Polygon': 'Triangle amoureux',
	'Love Status Quo': 'Romance au point mort', 'Magical Sex Shift': 'Changement de genre magique', 'Mahou Shoujo': 'Mahou shoujo',
	'Mecha': 'Mecha', 'Medical': 'Médical', 'Military': 'Militaire', 'Mythology': 'Mythologie', 'Organized Crime': 'Crime organisé',
	'Otaku Culture': 'Culture otaku', 'Parody': 'Parodie', 'Performing Arts': 'Arts de la scène', 'Pets': 'Animaux', 'Psychological': 'Psychologique',
	'Racing': 'Course', 'Reincarnation': 'Réincarnation', 'Reverse Harem': 'Harem inversé', 'Samurai': 'Samouraï', 'School': 'École',
	'Sci-Fi': 'Science-fiction', 'Seinen': 'Seinen', 'Shoujo': 'Shoujo', 'Shounen': 'Shounen', 'Showbiz': 'Showbiz',
	'Slice of Life': 'Tranche de vie', 'Space': 'Espace', 'Sports': 'Sports', 'Strategy Game': 'Jeu de stratégie', 'Super Power': 'Super-pouvoirs',
	'Supernatural': 'Surnaturel', 'Survival': 'Survie', 'Team Sports': 'Sports d’équipe', 'Time Travel': 'Voyage dans le temps',
	'Urban Fantasy': 'Fantastique urbain', 'Vampire': 'Vampire', 'Video Game': 'Jeu vidéo', 'Villainess': 'Méchant(e)', 'Visual Arts': 'Arts visuels',
	'Workplace': 'Milieu professionnel',
	'AU': 'Australie', 'BR': 'Brésil', 'CA': 'Canada', 'DE': 'Allemagne', 'FR': 'France', 'GB': 'Royaume-Uni', 'IN': 'Inde',
	'NL': 'Pays-Bas', 'PL': 'Pologne', 'PT': 'Portugal', 'SE': 'Suède', 'US': 'États-Unis', 'ES': 'Espagne'
}

GENERIC_TITLE_REPLACEMENTS = (
	('Today\'s Most Popular Movies', 'Films les plus populaires aujourd’hui'),
	('Today\'s Most Popular Shows', 'Séries les plus populaires aujourd’hui'),
	('Trakt\'s Trending Movies', 'Films tendance sur Trakt'),
	('Trakt\'s Trending Shows', 'Séries tendance sur Trakt'),
	('Trending Kids Movies', 'Films jeunesse tendance'),
	('Trending Anime Movies', 'Films animés tendance'),
	('Trending Anime Shows', 'Séries animées tendance'),
	('Upcoming Movies (Theaters)', 'Films à venir au cinéma'),
	('Latest Streaming Movies', 'Derniers films en streaming'),
	('Latest Miniseries', 'Dernières mini-séries'),
	('Latest Shows', 'Dernières séries'),
	('Most Popular Movies', 'Films les plus populaires'),
	('Most Popular Shows', 'Séries les plus populaires'),
	('Must-See Modern Horror', 'Horreur moderne incontournable'),
	('Top 250 Movies (iMDB)', 'Top 250 films IMDb'),
	('Top 250 Shows (iMDB)', 'Top 250 séries IMDb'),
	('Top 100 Movies (JustWatch)', 'Top 100 films JustWatch'),
	('All Pyjama films in one place', 'Tous les films Pyjama'),
	('MCU Movies', 'Films MCU'),
	('MCU Shows', 'Séries MCU'),
)

GENERIC_WORD_REPLACEMENTS = (
	(r'\bMovies\b', 'Films'), (r'\bMovie\b', 'Film'),
	(r'\bShows\b', 'Séries'), (r'\bShow\b', 'Série'),
	(r'\bLatest\b', 'Derniers'), (r'\bPopular\b', 'Populaires'),
	(r'\bTop\b', 'Top'), (r'\bTrending\b', 'Tendances'),
	(r'\bAnimated\b', 'animation'), (r'\bAnimation/Anime\b', 'animation/anime'),
	(r'\bAction\b', 'action'), (r'\bComedy\b', 'comédie'), (r'\bDrama\b', 'drame'),
	(r'\bDocumentary\b', 'documentaire'), (r'\bHorror\b', 'horreur'), (r'\bRomance\b', 'romance'),
	(r'\bSci-Fi\b', 'science-fiction'), (r'\bThriller\b', 'thriller'), (r'\bCrime\b', 'crime'),
	(r'\bMiniseries\b', 'mini-séries'), (r'\bReality\b', 'téléréalité'),
	(r'\bin the US\b', 'aux États-Unis'), (r'\bin Canada\b', 'au Canada'), (r'\bIn Canada\b', 'au Canada'),
	(r'\bon iMDB\b', 'sur IMDb'), (r'\bOn Rotten Tomatoes\b', 'sur Rotten Tomatoes'), (r'\bon Rotten Tomatoes\b', 'sur Rotten Tomatoes'),
)


def _catalogue_visible(cat):
	if not isinstance(cat, dict): return False
	if french_stream.is_search_catalog(cat):
		return bool(cat.get('id') and (cat.get('name') or cat.get('id')))
	if _catalogue_has_required_search(cat) or _catalogue_has_unsupported_required_extra(cat):
		return False
	return bool(cat.get('id') and (cat.get('name') or cat.get('id')))


def _is_list_catalogue(cat):
	cid = _fold((cat or {}).get('id', ''))
	ctype = _fold((cat or {}).get('type', ''))
	cname = _fold((cat or {}).get('name', ''))
	return cid.startswith('mdblist.') or cid.startswith('trakt.') or ctype in ('trakt', 'collection') or 'mdblist' in cid or 'watchlist' in cname


def _is_anime_catalogue(cat):
	cid = _fold((cat or {}).get('id', ''))
	ctype = _fold((cat or {}).get('type', ''))
	cname = _fold((cat or {}).get('name', ''))
	return ctype.startswith('anime') or cid.startswith('mal.') or cname.startswith('mal ')


def _catalogue_group(cat):
	if _is_list_catalogue(cat): return 'lists'
	if _is_anime_catalogue(cat): return 'anime'
	return _catalogue_media_type(cat.get('type', 'movie'), cat.get('id', ''), cat.get('name', ''))


def _catalogue_list_subgroup(cat):
	return _catalogue_media_type(cat.get('type', 'movie'), cat.get('id', ''), cat.get('name', ''))


def _catalogues_for_group(catalogs, group='', subgroup=''):
	results = []
	for cat in catalogs or []:
		if not _catalogue_visible(cat): continue
		cat_group = _catalogue_group(cat)
		if group and cat_group != group: continue
		if group == 'lists' and subgroup and _catalogue_list_subgroup(cat) != subgroup: continue
		results.append(cat)
	return results


def _translate_option_label(value):
	value = str(value or '').strip()
	if not value: return value
	season_match = re.match(r'^(Winter|Spring|Summer|Fall)\s+(\d{4})$', value, flags=re.I)
	if season_match:
		return '%s %s' % (OPTION_TRANSLATIONS.get(season_match.group(1).title(), season_match.group(1)), season_match.group(2))
	return OPTION_TRANSLATIONS.get(value, value)


def _translate_catalogue_name(name, group='', cat=None):
	original = str(name or '').strip()
	if not original: return original
	translated = CATALOGUE_NAME_TRANSLATIONS.get(original)
	if translated: return translated
	translated = original
	for source, target in GENERIC_TITLE_REPLACEMENTS:
		if translated == source:
			return target
	if translated.startswith('Latest ') and translated.endswith(' Movies'):
		return 'Derniers films %s' % translated[len('Latest '):-len(' Movies')]
	if translated.startswith('Latest ') and translated.endswith(' Shows'):
		return 'Dernières séries %s' % translated[len('Latest '):-len(' Shows')]
	if translated.startswith('Top 10 ') and translated.endswith(' Movies in Canada'):
		return 'Top 10 films %s au Canada' % translated[len('Top 10 '):-len(' Movies in Canada')]
	if translated.startswith('Top 10 ') and translated.endswith(' Shows in Canada'):
		return 'Top 10 séries %s au Canada' % translated[len('Top 10 '):-len(' Shows in Canada')]
	if translated.startswith('Top ') and translated.endswith(' Movies'):
		return 'Top films %s' % translated[len('Top '):-len(' Movies')]
	if translated.startswith('Top ') and translated.endswith(' Shows'):
		return 'Top séries %s' % translated[len('Top '):-len(' Shows')]
	if translated.startswith('Popular ') and translated.endswith(' Movies'):
		return 'Films %s populaires' % translated[len('Popular '):-len(' Movies')].lower()
	if translated.startswith('Popular ') and translated.endswith(' Shows'):
		return 'Séries %s populaires' % translated[len('Popular '):-len(' Shows')].lower()
	for pattern, replacement in GENERIC_WORD_REPLACEMENTS:
		translated = re.sub(pattern, replacement, translated)
	return translated


def _display_catalogue_name(name, group='', cat=None):
	# Nettoie et traduit uniquement l’affichage des entrées du menu Catalogue.
	# Les identifiants internes du manifest restent inchangés.
	name = str(name or '').strip()
	for prefix in ('FS - ', 'FS- ', 'FS – ', 'FS– '):
		if name.startswith(prefix):
			name = name[len(prefix):].strip()
			break
	return _translate_catalogue_name(name, group, cat)



def _clean_catalogue_title_suffix(title):
	# Certains catalogues ajoutent une info de navigation à la fin du titre
	# (ex. "Saison" / "Saison 1"). Cette info ne fait pas partie du vrai
	# titre TMDb/IMDb et peut empêcher la résolution exacte avant le scrape.
	title = str(title or '').strip()
	if not title: return title
	patterns = (
		r'\s*[-–—:]\s*saison\s*\d*\s*$',
		r'\s+saison\s*\d*\s*$',
		r'\s*\(\s*saison\s*\d*\s*\)\s*$',
		r'\s+season\s*\d*\s*$',
		r'\s*\(\s*season\s*\d*\s*\)\s*$',
	)
	for pattern in patterns:
		title = re.sub(pattern, '', title, flags=re.I).strip()
	return title

def _folder_icon():
	return media_path('folder.png')



def _catalogue_folder_context(params, name, icon_name='folder.png', is_folder=True):
	return ku.menu_editor_context(params, name, icon_name, is_folder) + ku.tmdb_genre_sort_context(params, name, icon_name, is_folder) + ku.list_sort_context(params, name, icon_name, is_folder)



def _catalogue_label(value):
	try:
		if isinstance(value, int):
			return ku.local_string(value)
	except:
		pass
	try:
		return str(value or '').strip()
	except:
		return ''


def _add_catalogue_dir(handle, params, name, icon_name='folder.png', fanart=None, is_folder=True):
	# Variante locale de add_dir(), avec les context menus attendus sur les dossiers.
	params = dict(params or {})
	name = _catalogue_label(name)
	icon_name = _catalogue_label(icon_name) or 'folder.png'
	params['name'] = name
	params['iconImage'] = icon_name
	icon = media_path(icon_name) if icon_name and '://' not in icon_name else icon_name
	url = build_url(params)
	display_name = name
	try:
		if ku._is_numeric_page(params.get('new_page')): display_name = '%s > %s' % (name, params.get('new_page'))
	except: pass
	listitem = make_listitem()
	listitem.setLabel(display_name)
	try:
		neutral_fanart = '' if ku._is_numeric_page(params.get('new_page')) else (fanart or '')
	except:
		neutral_fanart = fanart or ''
	listitem.setArt(ku.clean_art({'icon': icon, 'poster': icon, 'thumb': icon, 'fanart': neutral_fanart, 'banner': icon, 'landscape': icon}))
	try:
		cm = list(_catalogue_folder_context(params, name, icon_name, is_folder)) + ku.next_page_context(params)
		if cm: listitem.addContextMenuItems(cm, replaceItems=False)
	except: pass
	add_item(handle, url, listitem, is_folder)

def _manifest():
	remote = _manifest_url()
	if not remote:
		return {}
	data = _json_request(remote)
	if isinstance(data, dict):
		return data
	return {}


def _catalog_url(catalog_type, catalog_id, page_no=1, search='', extra_name='', extra_value='', page_size=CATALOGUE_PAGE_SIZE):
	base = _base_url()
	if not base: return ''
	extra = []
	try:
		page_no = int(page_no)
	except: page_no = 1
	try:
		page_size = max(1, int(page_size or CATALOGUE_PAGE_SIZE))
	except:
		page_size = CATALOGUE_PAGE_SIZE
	if extra_name and _valid_param(extra_value):
		extra.append('%s=%s' % (quote(str(extra_name), safe=''), quote(str(extra_value), safe='')))
	if search:
		extra.append('search=%s' % quote(search.encode('utf-8') if False else search, safe=''))
	if page_no > 1:
		extra.append('skip=%s' % ((page_no - 1) * page_size))
	catalog_type = quote(str(catalog_type or 'movie'), safe='')
	catalog_id = quote(str(catalog_id or ''), safe='')
	if extra:
		return '%s/catalog/%s/%s/%s.json' % (base, catalog_type, catalog_id, '&'.join(extra))
	return '%s/catalog/%s/%s.json' % (base, catalog_type, catalog_id)


def _meta_url(item_type, item_id):
	base = _base_url()
	if not base: return ''
	return '%s/meta/%s/%s.json' % (base, quote(str(item_type or 'series'), safe=''), quote(str(item_id or ''), safe=':'))


def _safe_title(item):
	raw_title = item.get('name') or item.get('title') or item.get('id') or 'Sans titre'
	return _clean_catalogue_title_suffix(raw_title)


def _year(item):
	try: return str(item.get('releaseInfo') or item.get('year') or '')[:4]
	except: return ''


def _catalogue_id_parts(item_id):
	item_id = str(item_id or '')
	fs_tmdb_id, fs_imdb_id = french_stream.id_parts(item_id)
	if fs_tmdb_id or fs_imdb_id:
		return fs_tmdb_id, fs_imdb_id
	if item_id.startswith('tmdb:'):
		for part in item_id.split(':')[1:]:
			if str(part).isdigit():
				return str(part), ''
		return item_id.replace('tmdb:', '', 1).split(':')[0], ''
	# Certains catalogues retournent des identifiants enrichis du type
	# tt1234567:1:2 ou fs:... avec l'IMDb inclus ailleurs dans la chaîne.
	# Pour le scrape, on conserve uniquement l'identifiant IMDb pur.
	match = re.search(r'(tt\d+)', item_id)
	if match: return '', match.group(1)
	return '', ''


def _custom_meta(item, media_type='movie'):
	title = _safe_title(item)
	year = _year(item)
	poster = item.get('poster') or item.get('posterShape') or ''
	fanart = item.get('background') or item.get('poster') or ''
	tmdb_id, imdb_id = _catalogue_id_parts(item.get('id'))
	meta = {
		'title': title,
		'rootname': '%s (%s)' % (title, year) if year else title,
		'original_title': title,
		'year': year or '0',
		'premiered': item.get('released') or '',
		'plot': item.get('description') or item.get('overview') or '',
		'genre': ', '.join(item.get('genres', [])) if isinstance(item.get('genres'), list) else item.get('genres', ''),
		'poster': poster,
		'fanart': fanart,
		'landscape': item.get('background') or poster,
		'banner': '',
		'clearart': '',
		'clearlogo': '',
		'tmdblogo': '',
		'discart': '',
		'imdb_id': imdb_id,
		'tmdb_id': tmdb_id or '0',
		'tvdb_id': '',
		'mediatype': media_type,
		'cast': [],
		'country': [],
		'director': '',
		'duration': 5400,
		'mpaa': '',
		'rating': item.get('imdbRating') or 0,
		'studio': '',
		'tagline': '',
		'trailer': '',
		'votes': '',
		'writer': '',
		'alternative_titles': [],
		'country_codes': [],
		'extra_info': {}
	}
	return meta




def _clean_search_title(title):
	# Nettoie seulement les préfixes d'affichage fréquents avant une recherche TMDb.
	# Le titre original reste transmis aux scrapers si aucun identifiant TMDb n'est trouvé.
	title = str(title or '').strip()
	for prefix in ('FS - ', 'FS- ', 'FS – ', 'FS– '):
		if title.startswith(prefix):
			title = title[len(prefix):].strip()
	return _clean_catalogue_title_suffix(title)


def _best_tmdb_match(results, title, year=''):
	if not results: return ''
	wanted = _clean_search_title(title).lower()
	year = str(year or '')[:4]
	first_id = ''
	for item in results:
		try:
			if not first_id and item.get('id'): first_id = str(item.get('id'))
			item_title = (item.get('title') or item.get('name') or item.get('original_title') or item.get('original_name') or '').lower()
			item_year = str(item.get('release_date') or item.get('first_air_date') or '')[:4]
			if item_title == wanted and (not year or item_year == year):
				return str(item.get('id'))
		except: pass
	return first_id


def _search_tmdb_id(item, media_type='movie'):
	# Les catalogues publics peuvent retourner des IDs internes (fs:...).
	# Dans ce cas, on tente de retrouver la fiche TMDb pour garder l'affichage uniforme avec alkoFlix.
	title = _clean_search_title(_safe_title(item))
	year = _year(item)
	if not title: return ''
	cache_key = 'tmdb_%s_%s_%s' % (media_type, title.lower(), year)
	cached = _cache_get(cache_key)
	if cached is not None: return cached
	try:
		query = quote(title.encode('utf-8') if False else title, safe='')
		if media_type == 'series': data = tmdb_api.tmdb_tv_search(query, 1) or {}
		else: data = tmdb_api.tmdb_movies_search(query, 1) or {}
		result = _best_tmdb_match(data.get('results', []), title, year)
		return _cache_set(cache_key, result, hours=168)
	except Exception as e:
		logger('catalogues tmdb search error', e)
		return ''


def _item_imdb_id(item):
	# IMDb est prioritaire pour le scrape, car les catalogues externes le fournissent
	# souvent directement et les scrapers externes le comprennent très bien.
	for key in ('imdb_id', 'imdbId', 'imdb', 'imdbID'):
		value = item.get(key)
		if value:
			match = re.search(r'(tt\d+)', str(value))
			if match: return match.group(1)
	_, imdb_id = _catalogue_id_parts(item.get('id', ''))
	return imdb_id


def _resolve_media_id(item, media_type='movie'):
	catalogue_id = item.get('id', '')
	tmdb_id, imdb_id = _catalogue_id_parts(catalogue_id)
	# Pour les catalogues, IMDb passe avant TMDb afin de transmettre aux scrapers
	# l'identifiant le plus précis possible. TMDb reste utilisé pour l'affichage.
	imdb_id = _item_imdb_id(item) or imdb_id
	if imdb_id: return 'imdb_id', imdb_id
	if item.get('imdb_id') or item.get('imdbId'):
		return 'imdb_id', str(item.get('imdb_id') or item.get('imdbId'))
	if item.get('tmdb_id') or item.get('tmdbId'):
		return 'tmdb_id', str(item.get('tmdb_id') or item.get('tmdbId'))
	if tmdb_id: return 'tmdb_id', tmdb_id
	tmdb_id = _search_tmdb_id(item, media_type)
	if tmdb_id: return 'tmdb_id', tmdb_id
	return '', ''

def _item_art(item):
	poster = item.get('poster') or media_path('movies.png')
	fanart = item.get('background') or poster or ''
	return {'icon': poster, 'poster': poster, 'thumb': poster, 'fanart': fanart, 'landscape': item.get('background') or poster, 'banner': poster}


def _valid_param(value):
	if value is None: return ''
	value = str(value).strip()
	if not value or value.lower() in ('none', 'null', 'false', '0'): return ''
	return value


def _append_param_to_url(url, key, value):
	value = _valid_param(value)
	if not value or ('%s=' % key) in str(url): return url
	separator = '&' if '?' in str(url) else '?'
	return '%s%s%s=%s' % (url, separator, key, quote(str(value), safe=':'))


def _append_catalogue_context_to_items(items, imdb_id='', catalogue_id='', catalogue_base_url=''):
	# Les items construits par les builders natifs gardent l'affichage TMDb,
	# mais les scrapeurs doivent aussi recevoir l'identifiant brut du catalogue.
	# Certains catalogues publics utilisent des IDs internes (fs:...) qui sont
	# nécessaires pour retrouver les liens, même quand TMDb affiche la bonne fiche.
	if not imdb_id and not catalogue_id and not catalogue_base_url: return items
	patched = []
	for item in items:
		try:
			url, listitem, is_folder = item[:3]
			url = _append_param_to_url(url, 'imdb_id', imdb_id)
			url = _append_param_to_url(url, 'catalogue_id', catalogue_id)
			url = _append_param_to_url(url, 'catalogue_base_url', catalogue_base_url)
			patched.append((url, listitem, is_folder))
		except:
			patched.append(item)
	return patched



def _media_click_info_url(media_type, tmdb_id='', season='', episode=''):
	params = {'mode': 'info_extra_choice', 'media_type': media_type, 'tmdb_id': tmdb_id or '0'}
	try:
		if ku.external_browse(): params['is_widget'] = 'true'
	except Exception: pass
	if season not in ('', None): params['season'] = season
	if episode not in ('', None): params['episode'] = episode
	return build_url(params)


def _media_click_url(play_url, media_type, tmdb_id='', season='', episode=''):
	# Le réglage concerne les fiches principales (films/séries).
	# Les épisodes doivent toujours lancer la recherche de liens.
	try:
		if media_type in ('movie', 'tvshow', 'series') and ks.media_click_opens_info():
			return _media_click_info_url('tvshow' if media_type == 'series' else media_type, tmdb_id, season, episode)
	except: pass
	return play_url


def _context_menu(media_type='movie', tmdb_id='', imdb_id='', tvdb_id='', title='', year='', catalogue_id='', is_widget=False, fav_type='', season='', episode=''):
	# Menu contextuel minimal utilisé seulement par les items Catalogue construits
	# en fallback. Quand le builder natif alkoFlix est disponible, son menu complet
	# reste prioritaire.
	cm = []
	tmdb_id = _valid_param(tmdb_id)
	imdb_id = _valid_param(imdb_id)
	tvdb_id = _valid_param(tvdb_id)
	title = str(title or '')
	media_type = 'tvshow' if media_type in ('series', 'tvshow') else media_type
	if not fav_type: fav_type = 'tvshow' if media_type == 'tvshow' else 'movie' if media_type == 'movie' else fav_type
	run_plugin = 'RunPlugin(%s)'
	container_update = 'Container.Update(%s)'
	try: cm_sort = ks.context_menu_sort()
	except: cm_sort = {'options': 10, 'extras': 20, 'mark': 30, 'trakt': 40, 'mdblist': 50, 'tmdblist': 60, 'rating': 70, 'favourites': 21, 'exit': 100}
	try: cm_services = ks.context_menu_services()
	except: cm_services = {'trakt': False, 'mdblist': False, 'tmdblist': False}
	def _add(sort_key, label, command):
		try: priority = cm_sort.get(sort_key, 0)
		except: priority = 0
		if priority and command: cm.append((priority, label, command))
	def _add_service(sort_key, label, command):
		if cm_services.get(sort_key): _add(sort_key, label, command)
	def _add_favourites(label, command):
		try: enabled = ks.context_favourites_enabled()
		except: enabled = True
		if enabled: _add('favourites', label, command)

	if media_type == 'episode':
		if tmdb_id:
			_add('options', 'Options (alkoFlix)', run_plugin % build_url({'mode': 'options_menu_choice', 'content': 'episode', 'tmdb_id': tmdb_id, 'is_widget': is_widget}))
			_add('options', 'Bande annonce', run_plugin % build_url({'mode': 'play_trailer_choice', 'media_type': 'tvshow', 'tmdb_id': tmdb_id}))
			_add('extras', 'Info & Extra', run_plugin % build_url({'mode': 'info_extra_choice', 'media_type': media_type, 'tmdb_id': tmdb_id}))
			_add_service('trakt', 'Gestion Trakt', run_plugin % build_url({'mode': 'trakt_manager_choice', 'media_type': 'episode', 'tmdb_id': tmdb_id, 'imdb_id': imdb_id, 'tvdb_id': tvdb_id}))
			_add_service('mdblist', 'Gestion MDBList', run_plugin % build_url({'mode': 'mdbl_manager_choice', 'media_type': 'episode', 'tmdb_id': tmdb_id, 'imdb_id': imdb_id, 'tvdb_id': tvdb_id}))
			_add_service('tmdblist', 'Gestion des listes TMDb', run_plugin % build_url({'mode': 'tmdb_manager_choice', 'media_type': 'episode', 'tmdb_id': tmdb_id, 'imdb_id': imdb_id, 'tvdb_id': tvdb_id}))
			if season not in ('', None) and episode not in ('', None):
				fav_id = '%s:%s:%s' % (tmdb_id, season, episode)
				episode_fav_type = episode_favourite_type_from_tv(fav_type)
				_add_favourites(favourites_context_label(episode_fav_type, fav_id), run_plugin % build_url({'mode': 'favourites_choice', 'media_type': 'episode', 'fav_type': episode_fav_type, 'parent_fav_type': fav_type, 'tmdb_id': tmdb_id, 'imdb_id': imdb_id, 'tvdb_id': tvdb_id, 'season': season, 'episode': episode, 'title': title}))
	elif media_type == 'tvshow':
		if tmdb_id:
			_add('options', 'Options (alkoFlix)', run_plugin % build_url({'mode': 'options_menu_choice', 'content': 'tvshow', 'tmdb_id': tmdb_id, 'is_widget': is_widget}))
			_add('options', 'Bande annonce', run_plugin % build_url({'mode': 'play_trailer_choice', 'media_type': 'tvshow', 'tmdb_id': tmdb_id}))
			_add('extras', 'Info & Extra', run_plugin % build_url({'mode': 'info_extra_choice', 'media_type': media_type, 'tmdb_id': tmdb_id}))
			_add('extras', 'Parcourir...', container_update % build_url({'mode': 'build_season_list', 'tmdb_id': tmdb_id, 'parent_fav_type': fav_type}))
			_add_service('trakt', 'Gestion Trakt', run_plugin % build_url({'mode': 'trakt_manager_choice', 'media_type': 'tvshow', 'tmdb_id': tmdb_id, 'imdb_id': imdb_id, 'tvdb_id': tvdb_id}))
			_add_service('mdblist', 'Gestion MDBList', run_plugin % build_url({'mode': 'mdbl_manager_choice', 'media_type': 'tvshow', 'tmdb_id': tmdb_id, 'imdb_id': imdb_id, 'tvdb_id': tvdb_id}))
			_add_service('tmdblist', 'Gestion des listes TMDb', run_plugin % build_url({'mode': 'tmdb_manager_choice', 'media_type': 'tvshow', 'tmdb_id': tmdb_id, 'imdb_id': imdb_id, 'tvdb_id': tvdb_id}))
			_add_favourites(favourites_context_label(fav_type, tmdb_id), run_plugin % build_url({'mode': 'favourites_choice', 'media_type': 'tvshow', 'tmdb_id': tmdb_id, 'imdb_id': imdb_id, 'tvdb_id': tvdb_id, 'title': title, 'fav_type': fav_type}))
	else:
		play_params = {'mode': 'play_media', 'media_type': 'movie', 'custom_title': title, 'custom_year': year}
		if tmdb_id: play_params['tmdb_id'] = tmdb_id
		if imdb_id: play_params['imdb_id'] = imdb_id
		if catalogue_id: play_params['catalogue_id'] = catalogue_id
		_add('extras', 'Lire...', run_plugin % build_url(play_params))
		if tmdb_id:
			_add('options', 'Options (alkoFlix)', run_plugin % build_url({'mode': 'options_menu_choice', 'content': 'movie', 'tmdb_id': tmdb_id, 'is_widget': is_widget}))
			_add('options', 'Bande annonce', run_plugin % build_url({'mode': 'play_trailer_choice', 'media_type': 'movie', 'tmdb_id': tmdb_id}))
			_add('extras', 'Info & Extra', run_plugin % build_url({'mode': 'info_extra_choice', 'media_type': media_type, 'tmdb_id': tmdb_id}))
			_add_service('trakt', 'Gestion Trakt', run_plugin % build_url({'mode': 'trakt_manager_choice', 'media_type': 'movie', 'tmdb_id': tmdb_id, 'imdb_id': imdb_id, 'tvdb_id': tvdb_id or 'None'}))
			_add_service('mdblist', 'Gestion MDBList', run_plugin % build_url({'mode': 'mdbl_manager_choice', 'media_type': 'movie', 'tmdb_id': tmdb_id, 'imdb_id': imdb_id, 'tvdb_id': tvdb_id or 'None'}))
			_add_service('tmdblist', 'Gestion des listes TMDb', run_plugin % build_url({'mode': 'tmdb_manager_choice', 'media_type': 'movie', 'tmdb_id': tmdb_id, 'imdb_id': imdb_id, 'tvdb_id': tvdb_id or 'None'}))
			_add_favourites(favourites_context_label(fav_type, tmdb_id), run_plugin % build_url({'mode': 'favourites_choice', 'media_type': 'movie', 'tmdb_id': tmdb_id, 'imdb_id': imdb_id, 'tvdb_id': tvdb_id or 'None', 'title': title, 'fav_type': fav_type}))
	try:
		cm.sort(key=lambda k: k[0])
		return [v for k, *v in cm if k]
	except:
		return []


class Catalogues:
	def __init__(self, params):
		params['handle'] = int(__import__('sys').argv[1])
		self.params = params
		self.params_get = params.get
		self.handle = params['handle']
		self.fanart = ''

	def _catalog_definition(self, catalog_type='', catalog_id=''):
		for cat in _manifest().get('catalogs', []) or []:
			if str(cat.get('id', '')) == str(catalog_id) and str(cat.get('type', '')) == str(catalog_type):
				return cat
		return {}

	def _add_native_action(self, params, name, icon_name='folder.png', is_folder=True):
		params = dict(params or {})
		name = _catalogue_label(name)
		icon_name = _catalogue_label(icon_name) or 'folder.png'
		params['name'] = name
		params['iconImage'] = icon_name
		_add_catalogue_dir(self.handle, params, name, icon_name, self.fanart, is_folder)

	def clear_custom_manifest(self):
		# Action appelée depuis les réglages pour supprimer proprement le manifest
		# personnalisé et rafraîchir le menu sans nécessiter un redémarrage de Kodi.
		try: ku.set_setting('catalogue.custom.manifest_url', '')
		except: pass
		try: ku.set_setting('catalogue.french_stream_public.manifest_url', '')
		except: pass
		try:
			ku.clear_property('alkoflix_settings')
			ku.make_settings_dict()
		except: pass
		clear_catalogue_cache()
		try:
			from caches.navigator_cache import navigator_cache
			navigator_cache.delete_memory_cache('RootList', 'default')
			navigator_cache.delete_memory_cache('RootList', 'edited')
		except: pass
		try: ku.container_refresh()
		except: pass
		notification(33218, 2500)


	def sagas_root(self):
		# Dossier commun pour les collections/sagas de films.
		# Utilisé depuis Films, Films familiaux, Films d’animation et Films animés japonais.
		saga_type = self.params_get('saga_type', 'movie')
		fav_type = collection_favourite_type_from_saga_type(saga_type)
		self._add_native_action({'mode': 'search_history', 'action': 'tmdb_collections', 'name': 'Recherche', 'saga_type': saga_type, 'fav_type': fav_type}, 'Recherche', 'search.png')
		self._add_native_action({'mode': 'build_movie_list', 'action': 'tmdb_movies_curated_collections', 'saga_type': saga_type, 'fav_type': fav_type, 'name': 'Sagas de films'}, 'Sagas de films', 'sets.png')
		self._add_native_action({'mode': 'catalogue.sagas_favourites', 'saga_type': saga_type, 'fav_type': fav_type}, 'Favoris (alkoFlix)', 'favourites.png')
		self._finish('Sagas', content='')


	def sagas_favourites(self):
		from caches.favourites_cache import favourites_cache
		image_resolution = ks.get_resolution()
		fav_type = self.params_get('fav_type') or collection_favourite_type_from_saga_type(self.params_get('saga_type'))
		favourite_types = COLLECTION_FAVOURITE_TYPES if fav_type == 'collection' and not self.params_get('saga_type') else (fav_type,)
		seen = set()
		favourites = []
		for favourite_type in favourite_types:
			for favourite in favourites_cache.get_favourites(favourite_type) or []:
				collection_id = favourite.get('tmdb_id')
				if not collection_id or str(collection_id) in seen: continue
				seen.add(str(collection_id))
				favourites.append(favourite)
		for item in favourites:
			collection_id = item.get('tmdb_id')
			name = item.get('title') or 'Saga'
			poster, fanart, plot = media_path('sets.png'), self.fanart, ''
			try:
				data = tmdb_api.tmdb_movies_collection(collection_id) or {}
				name = data.get('name') or name
				plot = data.get('overview') or ''
				if data.get('poster_path'): poster = tmdb_api.tmdb_image_base % (image_resolution.get('poster', 'w780'), data.get('poster_path'))
				if data.get('backdrop_path'): fanart = tmdb_api.tmdb_image_base % (image_resolution.get('fanart', 'w1280'), data.get('backdrop_path'))
			except Exception as e:
				logger('catalogues sagas favourites art error', e)
			url = build_url({'mode': 'build_movie_list', 'action': 'tmdb_movies_collection', 'collection_id': collection_id, 'name': name})
			listitem = make_listitem()
			listitem.setLabel(name)
			try: ku.set_listitem_video_info(listitem, {'plot': plot, 'mediatype': 'set', 'title': name})
			except: pass
			listitem.setArt(ku.clean_art({'icon': poster, 'poster': poster, 'thumb': poster, 'fanart': fanart, 'landscape': fanart or poster, 'banner': poster}))
			try:
				if ks.context_favourites_enabled():
					listitem.addContextMenuItems([(favourites_context_label(fav_type, collection_id), 'RunPlugin(%s)' % build_url({'mode': 'favourites_choice', 'media_type': 'collection', 'fav_type': fav_type, 'saga_type': self.params_get('saga_type', ''), 'tmdb_id': collection_id, 'title': name}))])
			except: pass
			add_item(self.handle, url, listitem, True)
		self._finish('Favoris (alkoFlix)', content='sets')

	def family_animation_root(self):
		self._add_native_action({'mode': 'catalogue.family_section', 'family_type': 'movie_animation'}, 'Films d’animation', 'movies.png')
		self._add_native_action({'mode': 'catalogue.family_section', 'family_type': 'series_animation'}, 'Séries d’animation', 'tv.png')
		self._add_native_action({'mode': 'navigator.favourites_animation'}, 'Favoris', 'favourites.png')
		self._finish('Dessins animés', content='')

	def family_genre_groups(self):
		for family_type in ('movie_family', 'series_family', 'movie_animation', 'series_animation'):
			self._add_native_action({'mode': 'catalogue.family_genres', 'family_type': family_type}, FAMILY_TYPE_LABELS.get(family_type, family_type), 'genres.png')
		self._finish('Famille - Par genre', content='')

	def family_provider_groups(self):
		for family_type in ('movie_family', 'series_family', 'movie_animation', 'series_animation'):
			self._add_native_action({'mode': 'catalogue.family_providers', 'family_type': family_type}, FAMILY_TYPE_LABELS.get(family_type, family_type), 'tv.png')
		self._finish('Famille - Par plateforme', content='')

	def family_amazon_channel_groups(self):
		# Menu volontairement simplifié : les Amazon Channels jeunesse/famille
		# sont regroupés par type de média plutôt que séparés en 4 sous-sections.
		self._add_native_action({'mode': 'catalogue.family_amazon_channels', 'family_type': 'movie_family_kids'}, 'Films', 'movies.png')
		self._add_native_action({'mode': 'catalogue.family_amazon_channels', 'family_type': 'series_family_kids'}, 'Séries', 'tv.png')
		self._finish('Amazon Channels famille & Enfants', content='')

	def family_kids_networks(self):
		for network_id, label in NATIVE_FAMILY_KIDS_NETWORKS:
			params = {'mode': 'build_tvshow_list', 'action': 'tmdb_tv_networks', 'network_id': network_id, 'name': label, 'family_type': 'series_family_kids', 'fav_type': 'tv_family'}
			self._add_native_action(params, label, _network_menu_logo(network_id))
		self._finish('Chaînes jeunesse', content='')

	def family_section(self):
		family_type = self.params_get('family_type', 'movie_family')
		label = FAMILY_TYPE_LABELS.get(family_type, 'Famille')
		latest_label = 'Nouveautés récentes' if family_type.startswith('movie') else 'En diffusion cette semaine'
		random_label = 'Films aléatoires' if family_type.startswith('movie') else 'Séries aléatoires'
		entries = [
			('Populaires', 'popular', 'popular.png'),
			(random_label, 'random', 'shuffle.png'),
			('Par genre', 'genres', 'genres.png')
		]
		if family_type not in ('movie_family', 'movie_animation'):
			entries.append((latest_label, 'latest', 'fresh.png'))
		if family_type == 'movie_animation':
			entries.extend((
				('Collection Pixar', 'pixar', 'collection.png'),
				('Classiques de Disney', 'disney_classics', 'collection.png')
			))
		entries.extend((
			('Sagas', 'sagas', 'sets.png'),
			('Par genre', 'genres', 'genres.png'),
			('Par année', 'years', 'calender.png'),
			('Par plateforme', 'providers', 'tv.png')
		))
		if family_type == 'series_family':
			entries.append(('Chaînes jeunesse', 'kids_networks', 'networks.png'))
		# Les Amazon Channels jeunesse/famille restent disponibles dans Famille,
		# mais sont volontairement masqués dans Dessins animés (films + séries).
		if family_type not in ('movie_animation', 'series_animation'):
			entries.append(('Amazon Channels famille & Enfants', 'amazon_channels', 'amazon.png'))
		for name, action_key, icon in entries:
			if action_key == 'sagas' and not family_type.startswith('movie'):
				continue
			if action_key == 'genres':
				params = {'mode': 'catalogue.family_genres', 'family_type': family_type}
			elif action_key == 'years':
				params = {'mode': 'catalogue.family_years', 'family_type': family_type}
			elif action_key == 'providers':
				params = {'mode': 'catalogue.family_providers', 'family_type': family_type}
			elif action_key == 'kids_networks':
				params = {'mode': 'catalogue.family_kids_networks'}
			elif action_key == 'amazon_channels':
				params = {'mode': 'catalogue.family_amazon_channels', 'family_type': family_type}
			elif action_key == 'sagas':
				params = {'mode': 'catalogue.sagas_root', 'saga_type': 'animation' if family_type == 'movie_animation' else 'family'}
			elif action_key == 'pixar':
				params = {'mode': 'build_movie_list', 'action': 'tmdb_movies_company', 'company_id': '3', 'name': name, 'family_type': 'movie_animation', 'fav_type': 'movie_animation'}
			elif action_key == 'disney_classics':
				params = {'mode': 'build_movie_list', 'action': 'tmdb_movies_public_list', 'list_id': '338', 'name': name, 'family_type': 'movie_animation', 'fav_type': 'movie_animation'}
			else:
				params = _family_content_params(family_type, action_key, name)
			self._add_native_action(params, name, icon)
		self._finish(label, content='')

	def family_genres(self):
		family_type = self.params_get('family_type', 'movie_family')
		media_kind = _media_kind_from_family_type(family_type)
		self._add_native_action({'mode': 'catalogue.family_multiselect_genres', 'family_type': family_type}, 'Filtre multi-genres', 'filter.png', is_folder=False)
		for genre_id, label in _family_genres_for_type(family_type):
			params = _family_content_params(family_type, 'genre', label)
			params['genre_id'] = genre_id
			self._add_native_action(params, label, _genre_icon(media_kind, genre_id))
		self._finish('%s - Par genre' % FAMILY_TYPE_LABELS.get(family_type, 'Enfants & Famille'), content='')

	def family_multiselect_genres(self):
		family_type = self.params_get('family_type', 'movie_family')
		media_kind = _media_kind_from_family_type(family_type)
		genre_list = _family_genres_for_type(family_type)
		icon_path = media_path()
		function_list = []
		list_items = []
		for genre_id, label in genre_list:
			function_list.append(str(genre_id))
			list_items.append({'line1': label, 'icon': '%s%s' % (icon_path, _genre_icon(media_kind, genre_id))})
		kwargs = {'items': json.dumps(list_items), 'heading': 'Choisir les genres', 'enumerate': 'false', 'multi_choice': 'true', 'multi_line': 'false'}
		genre_ids = ku.select_dialog(function_list, **kwargs)
		if genre_ids is None: return
		params = _family_content_params(family_type, 'genre', 'Genres')
		params['genre_id'] = ','.join(genre_ids)
		return ku.execute_builtin('Container.Update(%s)' % build_url(params))

	def family_years(self):
		family_type = self.params_get('family_type', 'movie_family')
		for year in _native_years():
			params = _family_content_params(family_type, 'year', year)
			params['year'] = year
			self._add_native_action(params, year, 'calender.png')
		self._finish('%s - Par année' % FAMILY_TYPE_LABELS.get(family_type, 'Enfants & Famille'), content='')

	def family_providers(self):
		family_type = self.params_get('family_type', 'movie_family')
		media_kind = _media_kind_from_family_type(family_type)
		for provider in NATIVE_FAMILY_PROVIDERS:
			provider_id, label = provider[0], provider[1]
			provider_region = provider[2] if len(provider) > 2 else ''
			params = _family_content_params(family_type, 'provider', label)
			params['provider_id'] = provider_id
			if provider_region: params['provider_region'] = provider_region
			icon = _provider_menu_logo(provider_id, media_kind, provider_region)
			self._add_native_action(params, '%s (%s)' % (label, provider_region) if provider_region else label, icon)
		self._finish('%s - Par plateforme' % FAMILY_TYPE_LABELS.get(family_type, 'Enfants & Famille'), content='')

	def family_amazon_channels(self):
		family_type = self.params_get('family_type', 'movie_family')
		media_kind = _media_kind_from_family_type(family_type)
		for provider_id, label, provider_region in NATIVE_FAMILY_AMAZON_CHANNELS:
			params = _family_content_params(family_type, 'provider', label)
			params['provider_id'] = provider_id
			params['provider_region'] = provider_region
			icon = _provider_menu_logo(provider_id, media_kind, provider_region)
			self._add_native_action(params, '%s (%s)' % (label, provider_region), icon)
		self._finish('%s - Amazon Channels famille & Enfants' % FAMILY_TYPE_LABELS.get(family_type, 'Enfants & Famille'), content='')

	def documentary_movie_root(self):
		self._add_native_action({'mode': 'catalogue.documentary_section', 'documentary_type': 'movie_documentary'}, 'Films documentaires', 'genre_documentary.png')
		self._add_native_action({'mode': 'catalogue.documentary_section', 'documentary_type': 'movie_tv_movie'}, 'Téléfilms', 'genre_tv.png')
		self._finish('Documentaires - Films', content='')

	def documentary_series_root(self):
		for documentary_type in ('series_documentary', 'series_reality', 'series_talkshow', 'series_news'):
			label = DOCUMENTARY_TYPE_LABELS.get(documentary_type, documentary_type)
			icon = DOCUMENTARY_GENRES.get(documentary_type, ('', 'genre_documentary.png'))[1]
			self._add_native_action({'mode': 'catalogue.documentary_section', 'documentary_type': documentary_type}, label, icon)
		self._finish('Documentaires - Séries', content='')

	def documentary_section(self):
		documentary_type = self.params_get('documentary_type', 'movie_documentary')
		label = DOCUMENTARY_TYPE_LABELS.get(documentary_type, 'Documentaires')
		is_series = documentary_type.startswith('series')
		random_label = 'Séries aléatoires' if is_series else 'Films aléatoires'
		entries = [
			('Populaires', 'popular', 'popular.png'),
			(random_label, 'random', 'shuffle.png'),
			('Par genre', 'genres', 'genres.png')
		]
		if documentary_type not in ('movie_documentary', 'movie_tv_movie'):
			entries.append(('Tendances', 'trending', 'trending.png'))
		if is_series:
			entries.append(('En diffusion cette semaine', 'airing_week', 'calender.png'))
		entries.extend((
			('Par année', 'years', 'calender.png'),
			('Par langue', 'languages', 'languages.png'),
			('Par plateforme', 'providers', 'tv.png'),
			('Diffuseurs', 'networks', 'networks.png'),
			('Amazon Channels', 'amazon_channels', 'amazon.png')
		))
		for name, action_key, icon in entries:
			if action_key == 'networks' and not is_series:
				continue
			if action_key == 'genres':
				params = {'mode': 'catalogue.documentary_genres', 'documentary_type': documentary_type}
			elif action_key == 'years':
				params = {'mode': 'catalogue.documentary_years', 'documentary_type': documentary_type}
			elif action_key == 'languages':
				params = {'mode': 'catalogue.documentary_languages', 'documentary_type': documentary_type}
			elif action_key == 'providers':
				params = {'mode': 'catalogue.documentary_providers', 'documentary_type': documentary_type}
			elif action_key == 'networks':
				params = {'mode': 'catalogue.documentary_broadcasters', 'documentary_type': documentary_type}
			elif action_key == 'amazon_channels':
				params = {'mode': 'catalogue.documentary_amazon_channels', 'documentary_type': documentary_type}
			else:
				params = _documentary_content_params(documentary_type, action_key, name)
			self._add_native_action(params, name, icon)
		self._finish(label, content='')

	def documentary_genres(self):
		documentary_type = self.params_get('documentary_type', 'movie_documentary')
		media_kind = _media_kind_from_documentary_type(documentary_type)
		for genre_id, label in _documentary_genres_for_type(documentary_type):
			params = _documentary_content_params(documentary_type, 'genre', label)
			if not params: continue
			params['genre_id'] = genre_id
			self._add_native_action(params, label, _genre_icon(media_kind, genre_id))
		self._finish('%s - Par genre' % DOCUMENTARY_TYPE_LABELS.get(documentary_type, 'Documentaires'), content='')

	def documentary_years(self):
		documentary_type = self.params_get('documentary_type', 'movie_documentary')
		for year in _native_years():
			params = _documentary_content_params(documentary_type, 'year', year)
			params['year'] = year
			self._add_native_action(params, year, 'calender.png')
		self._finish('%s - Par année' % DOCUMENTARY_TYPE_LABELS.get(documentary_type, 'Documentaires'), content='')

	def documentary_languages(self):
		documentary_type = self.params_get('documentary_type', 'movie_documentary')
		for language_code, label in NATIVE_LANGUAGES:
			params = _documentary_content_params(documentary_type, 'language', label)
			params['language_code'] = language_code
			self._add_native_action(params, label, 'languages.png')
		self._finish('%s - Par langue' % DOCUMENTARY_TYPE_LABELS.get(documentary_type, 'Documentaires'), content='')

	def documentary_providers(self):
		documentary_type = self.params_get('documentary_type', 'movie_documentary')
		media_kind = _media_kind_from_documentary_type(documentary_type)
		for provider in NATIVE_PROVIDERS:
			provider_id, label = provider[0], provider[1]
			provider_region = provider[2] if len(provider) > 2 else ''
			params = _documentary_content_params(documentary_type, 'provider', label)
			params['provider_id'] = provider_id
			if provider_region: params['provider_region'] = provider_region
			icon = _provider_menu_logo(provider_id, media_kind, provider_region)
			self._add_native_action(params, '%s (%s)' % (label, provider_region) if provider_region else label, icon)
		self._finish('%s - Par plateforme' % DOCUMENTARY_TYPE_LABELS.get(documentary_type, 'Documentaires'), content='')

	def documentary_amazon_channels(self):
		documentary_type = self.params_get('documentary_type', 'movie_documentary')
		media_kind = _media_kind_from_documentary_type(documentary_type)
		for provider_id, label, provider_region in NATIVE_AMAZON_CHANNELS:
			params = _documentary_content_params(documentary_type, 'provider', label)
			params['provider_id'] = provider_id
			params['provider_region'] = provider_region
			icon = _provider_menu_logo(provider_id, media_kind, provider_region)
			self._add_native_action(params, '%s (%s)' % (label, provider_region), icon)
		self._finish('%s - Amazon Channels' % DOCUMENTARY_TYPE_LABELS.get(documentary_type, 'Documentaires'), content='')

	def documentary_broadcasters(self):
		documentary_type = self.params_get('documentary_type', 'series_documentary')
		action_params = DOCUMENTARY_ACTIONS.get(documentary_type, {})
		action = action_params.get('network')
		if not action:
			return self._finish('%s - Diffuseurs' % DOCUMENTARY_TYPE_LABELS.get(documentary_type, 'Documentaires'), content='')
		seen = set()
		for group_id, group_label in NATIVE_BROADCASTER_GROUPS:
			for network_id, label in NATIVE_BROADCASTERS.get(group_id, ()): 
				if network_id in seen: continue
				seen.add(network_id)
				params = {'mode': 'build_tvshow_list', 'action': action, 'network_id': network_id, 'name': label, 'documentary_type': documentary_type}
				self._add_native_action(params, label, _network_menu_logo(network_id))
		self._finish('%s - Diffuseurs' % DOCUMENTARY_TYPE_LABELS.get(documentary_type, 'Documentaires'), content='')

	def native_anime(self):
		native_type = self.params_get('native_type', 'anime_series')
		if native_type == 'anime_movie':
			entries = (
				(33163, {'mode': 'build_movie_list', 'action': 'trakt_moviesanime_most_watched', 'iconImage': 'trakt.png'}),
				(33164, {'mode': 'build_movie_list', 'action': 'tmdb_moviesanime_popular', 'iconImage': 'popular.png'}),
				('Films aléatoires', {'mode': 'build_movie_list', 'action': 'tmdb_moviesanime_random', 'iconImage': 'shuffle.png'}),
				(33165, {'mode': 'build_movie_list', 'action': 'tmdb_moviesanime_latest_releases', 'iconImage': 'dvd.png'}),
				('Studio Ghibli', {'mode': 'build_movie_list', 'action': 'tmdb_movies_company', 'company_id': '10342', 'native_type': 'anime_movie', 'fav_type': 'anime_movie', 'iconImage': 'collection.png'}),
				('Sagas', {'mode': 'catalogue.sagas_root', 'saga_type': 'anime', 'iconImage': 'sets.png'}),
				(33166, {'mode': 'catalogue.native_genres', 'native_type': 'anime_movie', 'iconImage': 'genres.png'}),
				(33167, {'mode': 'catalogue.native_years', 'native_type': 'anime_movie', 'iconImage': 'calendar.png'}),
				('Par langue', {'mode': 'catalogue.native_languages', 'native_type': 'anime_movie', 'iconImage': 'languages.png'}),
				('Par plateforme', {'mode': 'catalogue.native_providers', 'native_type': 'anime_movie', 'iconImage': 'tv.png'}),
			)
			title = 'Films animés japonais'
		else:
			entries = (
				(33155, {'mode': 'build_anime_calendar', 'iconImage': 'trakt.png'}),
				(33156, {'mode': 'build_tvshow_list', 'action': 'trakt_tvanime_trending', 'iconImage': 'trakt.png'}),
				(33157, {'mode': 'build_tvshow_list', 'action': 'trakt_tvanime_most_watched', 'iconImage': 'trakt.png'}),
				(33158, {'mode': 'build_tvshow_list', 'action': 'tmdb_tvanime_popular', 'iconImage': 'popular.png'}),
				('Séries aléatoires', {'mode': 'build_tvshow_list', 'action': 'tmdb_tvanime_random', 'iconImage': 'shuffle.png'}),
				(33159, {'mode': 'build_tvshow_list', 'action': 'tmdb_tvanime_premieres', 'iconImage': 'fresh.png'}),
				(33160, {'mode': 'catalogue.native_genres', 'native_type': 'anime_series', 'iconImage': 'genres.png'}),
				(33161, {'mode': 'catalogue.native_years', 'native_type': 'anime_series', 'iconImage': 'calendar.png'}),
				('Par langue', {'mode': 'catalogue.native_languages', 'native_type': 'anime_series', 'iconImage': 'languages.png'}),
				('Par plateforme', {'mode': 'catalogue.native_providers', 'native_type': 'anime_series', 'iconImage': 'tv.png'}),
			)
			title = 'Séries animées japonaises'
		for name, params in entries:
			self._add_native_action(params, name, params.get('iconImage', 'folder.png'))
		self._finish(title, content='')

	def native_anime_groups(self):
		for native_type, name in (('anime_series', 'Séries animées japonaises'), ('anime_movie', 'Films animés japonais')):
			self._add_native_action({'mode': 'catalogue.native_genres', 'native_type': native_type}, name, 'genres.png')
		self._finish('Animés japonais - Par genre', content='')

	def native_anime_year_groups(self):
		for native_type, name in (('anime_series', 'Séries animées japonaises'), ('anime_movie', 'Films animés japonais')):
			self._add_native_action({'mode': 'catalogue.native_years', 'native_type': native_type}, name, 'calendar.png')
		self._finish('Animés japonais - Par année', content='')

	def native_anime_provider_groups(self):
		for native_type, name in (('anime_series', 'Séries animées japonaises'), ('anime_movie', 'Films animés japonais')):
			self._add_native_action({'mode': 'catalogue.native_providers', 'native_type': native_type}, name, 'tv.png')
		self._finish('Animés japonais - Par plateforme', content='')

	def native_genres(self):
		native_type = self.params_get('native_type', 'movie')
		media_kind = _media_kind_from_native_type(native_type)
		genres = NATIVE_SERIES_GENRES if native_type in ('series', 'anime_series') else NATIVE_MOVIE_GENRES
		action = 'genres'
		for genre_id, label in genres:
			params = _native_content_params(native_type, action, label)
			params['genre_id'] = genre_id
			self._add_native_action(params, label, _genre_icon(media_kind, genre_id))
		self._finish(self.params_get('name', 'Par genre'), content='')

	def native_years(self):
		native_type = self.params_get('native_type', 'movie')
		for year in _native_years():
			params = _native_content_params(native_type, 'year', year)
			params['year'] = year
			self._add_native_action(params, year, 'calender.png')
		self._finish(self.params_get('name', 'Par année'), content='')

	def native_languages(self):
		native_type = self.params_get('native_type', 'movie')
		for language_code, label in NATIVE_LANGUAGES:
			params = _native_content_params(native_type, 'language', label)
			params['language_code'] = language_code
			self._add_native_action(params, label, 'languages.png')
		self._finish(self.params_get('name', 'Par langue'), content='')

	def native_broadcaster_groups(self):
		# Compatibilité : les anciens raccourcis qui pointent vers le menu groupé
		# affichent maintenant directement la liste unique des diffuseurs.
		self.native_broadcasters()

	def native_broadcasters(self):
		seen = set()
		for group_id, group_label in NATIVE_BROADCASTER_GROUPS:
			for network_id, label in NATIVE_BROADCASTERS.get(group_id, ()): 
				if network_id in seen: continue
				seen.add(network_id)
				params = {'mode': 'build_tvshow_list', 'action': 'tmdb_tv_networks', 'network_id': network_id, 'name': label, 'family_type': 'series_family_kids', 'fav_type': 'tv_family'}
				self._add_native_action(params, label, _network_menu_logo(network_id))
		self._finish('Séries - Diffuseurs', content='')

	def native_providers(self):
		native_type = self.params_get('native_type', 'movie')
		media_kind = _media_kind_from_native_type(native_type)
		providers = NATIVE_ANIME_PROVIDERS if native_type in ('anime_movie', 'anime_series') else NATIVE_PROVIDERS
		for provider in providers:
			provider_id, label = provider[0], provider[1]
			provider_region = provider[2] if len(provider) > 2 else ''
			params = _native_content_params(native_type, 'watch_provider', label)
			params['provider_id'] = provider_id
			if provider_region: params['provider_region'] = provider_region
			icon = _provider_menu_logo(provider_id, media_kind, provider_region)
			self._add_native_action(params, '%s (%s)' % (label, provider_region) if provider_region else label, icon)
		self._finish(self.params_get('name', 'Par plateforme'), content='')


	def amazon_channels(self):
		native_type = self.params_get('native_type', 'movie')
		media_kind = _media_kind_from_native_type(native_type)
		for provider_id, label, provider_region in NATIVE_AMAZON_CHANNELS:
			params = _native_content_params(native_type, 'watch_provider', label)
			params['provider_id'] = provider_id
			params['provider_region'] = provider_region
			icon = _provider_menu_logo(provider_id, media_kind, provider_region)
			self._add_native_action(params, '%s (%s)' % (label, provider_region), icon)
		title = 'Films - Amazon Channels' if native_type == 'movie' else 'Séries - Amazon Channels'
		self._finish(title, content='')

	def root(self):
		if not _manifest_url():
			notification('Aucun catalogue personnalisé configuré.', 3000)
			return self._finish('Catalogue personnalisé', content='')
		manifest = _manifest()
		catalogs = manifest.get('catalogs', [])
		title = french_stream.manifest_label(manifest)
		is_fs_manifest = french_stream.is_manifest(manifest)
		if not catalogs:
			notification('Aucun catalogue disponible.', 3000)
		for group_id, group_label in CATALOGUE_GROUPS:
			if _catalogues_for_group(catalogs, group_id):
				icon = french_stream.group_icon(group_id) if is_fs_manifest else 'folder.png'
				params = {'mode': 'catalogue.group', 'catalogue_group': group_id, 'name': group_label, 'iconImage': icon}
				self._add_native_action(params, group_label, icon, True)
		self._finish(title, content='')

	def group(self):
		manifest = _manifest()
		catalogs = manifest.get('catalogs', [])
		group = self.params_get('catalogue_group', '')
		subgroup = self.params_get('catalogue_subgroup', '')
		group_name = self.params_get('name', 'Catalogue')
		if group == 'lists' and not subgroup:
			is_fs_manifest = french_stream.is_manifest(manifest)
			for sub_id, sub_label in (('movie', 'Films'), ('series', 'Séries')):
				if _catalogues_for_group(catalogs, 'lists', sub_id):
					icon = french_stream.group_icon(sub_id) if is_fs_manifest else 'folder.png'
					params = {'mode': 'catalogue.group', 'catalogue_group': 'lists', 'catalogue_subgroup': sub_id, 'name': sub_label, 'iconImage': icon}
					self._add_native_action(params, sub_label, icon, True)
			self._finish(group_name, content='')
			return
		self._add_catalogue_entries(_catalogues_for_group(catalogs, group, subgroup), group)
		self._finish(group_name, content='')

	def _add_catalogue_entries(self, catalogs, group=''):
		name_count = {}
		for cat in catalogs:
			raw_name = cat.get('name') or cat.get('id')
			if not raw_name: continue
			base_name = _display_catalogue_name(raw_name, group, cat)
			name_count[base_name] = name_count.get(base_name, 0) + 1
		for cat in catalogs:
			raw_name = cat.get('name') or cat.get('id')
			if not raw_name: continue
			name = _display_catalogue_name(raw_name, group, cat)
			if name_count.get(name, 0) > 1:
				name = '%s - %s' % (name, _catalogue_type_label(cat.get('type', 'movie'), cat.get('id', ''), raw_name))
			mode = 'catalogue.options' if _catalogue_options_extra(cat) else 'catalogue.catalog'
			params = _catalogue_route_params(cat, name, mode)
			params['catalogue_group'] = group
			self._add_native_action(params, name, french_stream.icon_for_catalog(cat), True)

	def options(self):
		catalog_type = self.params_get('catalog_type', 'movie')
		catalog_id = self.params_get('catalog_id', '')
		cat = self._catalog_definition(catalog_type, catalog_id)
		extra = _catalogue_options_extra(cat)
		options = extra.get('options') or []
		extra_name = str(extra.get('name') or 'genre')
		if not options:
			return self.catalog()
		for value in options:
			value = str(value)
			label = _translate_option_label(value)
			params = dict(self.params)
			params.pop('handle', None)
			params.update({'mode': 'catalogue.catalog', 'extra_name': extra_name, 'extra_value': value, 'new_page': '1', 'name': label, 'iconImage': 'folder.png'})
			self._add_native_action(params, label, 'folder.png', True)
		self._finish(self.params_get('name', 'Catalogue'), content='')

	def catalog(self):
		catalog_type = self.params_get('catalog_type', 'movie')
		catalog_id = self.params_get('catalog_id', '')
		cat = self._catalog_definition(catalog_type, catalog_id)
		media_type = self.params_get('catalog_media_type') or _catalogue_media_type(catalog_type, catalog_id, cat.get('name', ''))
		try:
			page_no = max(1, int(self.params_get('new_page', '1')))
		except:
			page_no = 1
		search = self.params_get('search', '')
		extra_name = self.params_get('extra_name', '')
		extra_value = self.params_get('extra_value', '')
		if not extra_name:
			extra = _catalogue_options_extra(cat)
			if extra and extra.get('default'):
				extra_name = extra.get('name') or ''
				extra_value = extra.get('default') or ''
		content_type = 'tvshows' if media_type == 'series' else 'movies'
		view_type = 'view.tvshows' if media_type == 'series' else 'view.movies'
		if self.params_get('search_required') == 'true' and not search:
			keyboard = xbmcgui.Dialog().input('Recherche Catalogue')
			if not keyboard: return self._finish(self.params_get('name', 'Catalogue'), content_type, view_type)
			search = keyboard

		page_size = _catalogue_page_size(cat)
		url = _catalog_url(catalog_type, catalog_id, page_no, search, extra_name, extra_value, page_size)
		if not url:
			notification('URL du catalogue non configurée.', 4000)
			return self._finish(self.params_get('name', 'Catalogue'), content_type, view_type)
		data = _json_request(url)
		items = data.get('metas') or data.get('items') or []
		if not items: notification('Aucun élément retourné par ce catalogue.', 3000)

		if len(items) > page_size:
			start = (page_no - 1) * page_size
			end = start + page_size
			visible_items = items[start:end]
			has_next = len(items) > end
		else:
			visible_items = items
			has_next = len(items) >= page_size
		for item in visible_items:
			self._add_catalogue_item(item, catalog_type, media_type)

		if has_next:
			next_params = dict(self.params)
			next_params.pop('handle', None)
			next_params.update({'mode': 'catalogue.catalog', 'new_page': str(page_no + 1)})
			if search: next_params['search'] = search
			next_params.setdefault('iconImage', 'item_next.png')
			_add_catalogue_dir(self.handle, next_params, 'Page suivante', 'item_next.png', self.fanart, True)
		self._finish(self.params_get('name', 'Catalogue'), content_type, view_type)

	def meta(self):
		item_type = self.params_get('item_type', 'series')
		item_id = self.params_get('item_id', '')
		url = _meta_url(item_type, item_id)
		if not url:
			notification('URL du catalogue non configurée.', 4000)
			return self._finish(self.params_get('name', 'Catalogue'))
		response = _json_request(url) or {}
		data = response.get('meta') or {}
		if not isinstance(data, dict):
			data = {}
		# Fallback minimal : si le serveur retourne null pour une fiche, on garde
		# au moins un affichage propre et on évite de casser la navigation Kodi.
		if not data:
			data = {'id': item_id, 'name': _clean_catalogue_title_suffix(self.params_get('name', 'Catalogue'))}
		videos = data.get('videos') or []
		if not isinstance(videos, list):
			videos = []
		if not videos:
			notification('Aucun épisode disponible dans cette fiche.', 3000)
		for video in videos:
			if isinstance(video, dict):
				self._add_episode_item(data, video)
		self._finish(_safe_title(data), 'episodes', 'view.episodes')

	def _add_catalogue_item(self, item, catalog_type, media_type=''):
		# Priorité aux builders natifs d'alkoFlix pour obtenir les mêmes métadonnées,
		# menus contextuels, affiches, fanarts et types de média que les autres sections.
		media_type = media_type or _catalogue_media_type(catalog_type, self.params_get('catalog_id', ''), self.params_get('name', ''))
		id_type, media_id = _resolve_media_id(item, media_type)
		catalogue_id = item.get('id', '')
		imdb_for_scrape = _item_imdb_id(item) or (media_id if id_type == 'imdb_id' else '')
		# French Stream peut exposer un ID interne fs: en plus des IDs standards.
		# Si TMDb/IMDb est disponible, on conserve la navigation native alkoFlix et
		# on transporte seulement l'ID fs: jusqu'aux sources. Le fallback meta du
		# catalogue est réservé aux entrées qui n'ont aucun ID exploitable.
		if media_type == 'series' and french_stream.should_use_catalogue_meta_for_series(item, id_type, media_id):
			self._add_fallback_item(item, catalog_type, media_type)
			return
		if id_type and media_id:
			try:
				catalogue_group = self.params_get('catalogue_group', '')
				catalogue_action = 'catalogue_%s' % catalogue_group if catalogue_group else ('catalogue_tvshows' if media_type == 'series' else 'catalogue_movies')
				if media_type == 'series':
					from menus.tvshows import TVShows
					builder = TVShows({'id_type': id_type, 'list': [], 'action': catalogue_action, 'exit_list_params': self.params_get('exit_list_params', ''), 'catalogue_id': catalogue_id})
					# Évite les affiches RPDB 404 sur les catalogues publics : TMDb reste la source d'art principale.
					builder.rpdb_enabled = False
					builder.build_tvshow_content(0, media_id)
				else:
					from menus.movies import Movies
					builder = Movies({'id_type': id_type, 'list': [], 'action': catalogue_action, 'exit_list_params': self.params_get('exit_list_params', ''), 'catalogue_id': catalogue_id})
					# Évite les affiches RPDB 404 sur les catalogues publics : TMDb reste la source d'art principale.
					builder.rpdb_enabled = False
					builder.build_movie_content(0, media_id)
				if getattr(builder, 'parental_blocked', False): return
				if builder.items:
					add_items(self.handle, _append_catalogue_context_to_items(builder.items, imdb_for_scrape, catalogue_id, _base_url()))
					return
			except Exception as e:
				logger('catalogues native builder error', e)
		# Fallback : garde l'ancien comportement si TMDb ne trouve rien.
		self._add_fallback_item(item, catalog_type, media_type)

	def _add_fallback_item(self, item, catalog_type, media_type=''):
		label = _safe_title(item)
		catalogue_id = item.get('id', '')
		listitem = make_listitem()
		listitem.setLabel(label)
		listitem.setArt(ku.clean_art(_item_art(item)))
		year = _year(item)
		media_type = media_type or _catalogue_media_type(catalog_type, self.params_get('catalog_id', ''), self.params_get('name', ''))
		id_type, media_id = _resolve_media_id(item, media_type)
		tmdb_id = media_id if id_type == 'tmdb_id' else ''
		imdb_id = _item_imdb_id(item) or (media_id if id_type == 'imdb_id' else '')
		if media_type == 'series':
			item_type = catalog_type if _fold(catalog_type).startswith('anime') else 'series'
			nav_url = build_url({'mode': 'catalogue.meta', 'item_type': item_type, 'item_id': catalogue_id, 'name': label})
			click_opens_info = False
			try: click_opens_info = ks.media_click_opens_info()
			except: pass
			if click_opens_info and tmdb_id:
				url = _media_click_info_url('tvshow', tmdb_id)
				is_folder = False
			else:
				url = nav_url
				is_folder = True
			cm = _context_menu('tvshow', tmdb_id=tmdb_id, imdb_id=imdb_id, title=label, year=year, catalogue_id=catalogue_id)
		else:
			meta = _custom_meta(item, 'movie')
			if tmdb_id and (not meta.get('tmdb_id') or meta.get('tmdb_id') == '0'):
				meta['tmdb_id'] = tmdb_id
			if imdb_id and not meta.get('imdb_id'):
				meta['imdb_id'] = imdb_id
			params = {'mode': 'play_media', 'media_type': 'movie', 'custom_title': label, 'custom_year': year, 'meta': json.dumps(meta), 'catalogue_base_url': _base_url()}
			if imdb_id: params['imdb_id'] = imdb_id
			if catalogue_id: params['catalogue_id'] = catalogue_id
			if tmdb_id: params['tmdb_id'] = tmdb_id
			elif meta.get('tmdb_id') and meta.get('tmdb_id') != '0': params['tmdb_id'] = meta.get('tmdb_id')
			play_url = build_url(params)
			click_opens_info = False
			try: click_opens_info = ks.media_click_opens_info()
			except: pass
			url = _media_click_info_url('movie', params.get('tmdb_id') or tmdb_id or meta.get('tmdb_id') or '0') if click_opens_info else play_url
			is_folder = False
			cm = _context_menu('movie', tmdb_id=tmdb_id or meta.get('tmdb_id'), imdb_id=imdb_id, title=label, year=year, catalogue_id=catalogue_id)
			try:
				if not click_opens_info: listitem.setProperty('IsPlayable', 'true')
			except: pass
		try:
			if cm: listitem.addContextMenuItems(cm)
		except: pass
		try:
			info = {'title': label, 'plot': item.get('description') or '', 'year': int(year or 0), 'mediatype': 'tvshow' if media_type == 'series' else 'movie'}
			ratings_index = user_ratings.get_active_ratings_index()
			media_key = 'tvshow' if media_type == 'series' else 'movie'
			user_rating = user_ratings.get_user_rating(ratings_index, media_key, tmdb_id=tmdb_id if media_type == 'series' else (tmdb_id or meta.get('tmdb_id')), imdb_id=imdb_id)
			if user_rating: info['userrating'] = user_rating
			ku.set_listitem_video_info(listitem, info)
		except: pass
		add_item(self.handle, url, listitem, is_folder)

	def _add_episode_item(self, show_meta, video):
		show_title = _safe_title(show_meta)
		season = str(video.get('season') or 1)
		episode = str(video.get('episode') or 1)
		ep_title = video.get('title') or 'S%sE%s' % (season, episode)
		label = 'S%sE%s - %s' % (season.zfill(2), episode.zfill(2), ep_title)
		meta = _custom_meta(show_meta, 'episode')
		# Pour les épisodes de catalogues Stremio, l'ID à scraper est idéalement video.id,
		# pas seulement l'ID de la série. C'est ce que Stremio envoie au manifest.
		catalogue_video_id = str(video.get('id') or '').strip()
		catalogue_stream_id = catalogue_video_id or str(show_meta.get('id') or '').strip()
		meta.update({'mediatype': 'episode', 'season': int(season), 'episode': int(episode), 'ep_name': ep_title})
		if catalogue_stream_id:
			meta['catalogue_id'] = catalogue_stream_id
			meta['id'] = catalogue_stream_id
		params = {
			'mode': 'play_media', 'media_type': 'episode', 'tmdb_id': meta.get('tmdb_id') or '0',
			'season': season, 'episode': episode, 'custom_title': show_title, 'custom_season': season,
			'custom_episode': episode, 'meta': json.dumps(meta)
		}
		if meta.get('imdb_id'): params['imdb_id'] = meta.get('imdb_id')
		if _base_url(): params['catalogue_base_url'] = _base_url()
		if catalogue_stream_id:
			params['catalogue_id'] = catalogue_stream_id
			if catalogue_video_id: params['catalogue_video_id'] = 'true'
		listitem = make_listitem()
		listitem.setLabel(label)
		listitem.setArt(ku.clean_art(_item_art(show_meta)))
		try: listitem.setProperty('IsPlayable', 'true')
		except: pass
		try:
			cm = _context_menu('episode', tmdb_id=meta.get('tmdb_id'), imdb_id=meta.get('imdb_id'), tvdb_id=meta.get('tvdb_id'), title=show_title, catalogue_id=catalogue_stream_id)
			if cm: listitem.addContextMenuItems(cm)
		except: pass
		try:
			info = {'title': ep_title, 'tvshowtitle': show_title, 'season': int(season), 'episode': int(episode), 'plot': video.get('overview') or '', 'mediatype': 'episode'}
			ratings_index = user_ratings.get_active_ratings_index()
			user_rating = user_ratings.get_user_rating(ratings_index, 'episode', tmdb_id=meta.get('tmdb_id'), imdb_id=meta.get('imdb_id'), tvdb_id=meta.get('tvdb_id'), season=season, episode=episode)
			if user_rating: info['userrating'] = user_rating
			ku.set_listitem_video_info(listitem, info)
		except: pass
		play_url = build_url(params)
		add_item(self.handle, play_url, listitem, False)

	def _finish(self, name, content='videos', view_type='view.main'):
		set_category(self.handle, name)
		try: ku.set_sort_method(self.handle, content or '')
		except: pass
		set_content(self.handle, content or '')
		end_directory(self.handle)
		set_view_mode(view_type, content or '')
