# -*- coding: utf-8 -*-
# plugin.video.alkoflix
# resources/lib/modules/private_torznab.py

import re
import time
import unicodedata
from threading import Lock
import xml.etree.ElementTree as ET
from urllib.parse import quote

import requests

from alko import log_utils, source_utils
from alko.control import setting as getSetting
from modules.source_objects import _is_blocked_file_source


_RATE_LIMIT_LOCK = Lock()
_RATE_LIMIT_STATE = {}


class PrivateTorznabSource:
	"""
	Base prudente pour les sources privées Torznab/API.

	Objectifs :
	- une seule requête API par type de recherche utile ;
	- aucune clé API écrite dans les logs ;
	- aucun lien de téléchargement privé envoyé au débrideur ;
	- utilisation du hash pour générer un magnet propre quand possible ;
	- compatibilité avec le cache externe alkoFlix.
	"""

	provider_key = ''
	provider_name = ''
	api_setting = ''
	base_url = ''
	response_mode = 'xml'
	auth_mode = 'query_apikey'
	auth_modes = None
	prefer_query_search = False
	prefer_id_then_query_search = False
	timeout = 8
	priority = 2
	max_requests_per_minute = 0
	pack_capable = True
	trust_api_results = False
	try_next_auth_on_empty = False
	hasMovies = True
	hasEpisodes = True

	def __init__(self):
		self.language = ['fr', 'en']

	def _api_key(self):
		try: return (getSetting(self.api_setting) or '').strip()
		except: return ''

	def _safe_int(self, value, default=0):
		try:
			if value in ('', None): return default
			return int(str(value).strip())
		except:
			return default

	def _size_gb(self, value):
		try:
			value = float(value or 0)
			if value <= 0: return 0
			return round(value / 1073741824.0, 2)
		except:
			return 0

	def _size_bytes(self, *values):
		"""Retourne une taille en octets depuis les formats Torznab courants.

		Certains indexeurs exposent la taille dans <size>, d'autres dans
		<enclosure length="..."> ou via torznab:attr name="size".
		On accepte aussi les chaînes lisibles (ex: 1.4 GB) pour éviter
		de perdre l'affichage de la taille dans le sélecteur.
		"""
		for value in values:
			try:
				if value in ('', None):
					continue
				if isinstance(value, (int, float)):
					if float(value) > 0: return int(float(value))
					continue
				text = str(value or '').strip()
				if not text:
					continue
				clean = text.replace(',', '.').replace(' ', '')
				if re.match(r'^\d+(?:\.\d+)?$', clean):
					number = float(clean)
					if number > 0: return int(number)
				match = re.search(r'(\d+(?:[\.,]\d+)?)\s*(TB|TiB|GB|GiB|MB|MiB|KB|KiB|B)?', text, re.I)
				if not match:
					continue
				number = float(match.group(1).replace(',', '.'))
				unit = (match.group(2) or 'B').lower()
				mult = 1
				if unit in ('tb', 'tib'):
					mult = 1099511627776
				elif unit in ('gb', 'gib'):
					mult = 1073741824
				elif unit in ('mb', 'mib'):
					mult = 1048576
				elif unit in ('kb', 'kib'):
					mult = 1024
				if number > 0: return int(number * mult)
			except:
				continue
		return 0

	def _hash_from_text(self, value):
		try:
			if not value: return ''
			match = re.search(r'btih:([A-Fa-f0-9]{40})', str(value), re.I)
			if match: return match.group(1).lower()
			match = re.search(r'\b([A-Fa-f0-9]{40})\b', str(value), re.I)
			if match: return match.group(1).lower()
		except:
			pass
		return ''

	def _make_magnet(self, info_hash, name):
		try:
			if not info_hash: return ''
			return 'magnet:?xt=urn:btih:%s&dn=%s' % (info_hash, quote(name or self.provider_name or 'Torrent'))
		except:
			return ''

	def _safe_preview(self, value, limit=220):
		try:
			text = str(value or '').replace('\n', ' ').replace('\r', ' ').strip()
			text = re.sub(r'(apikey=)[^&\s]+', r'\1***', text, flags=re.I)
			text = re.sub(r'(Authorization[^A-Za-z0-9]+Bearer\s+)[A-Za-z0-9._\-]+', r'\1***', text, flags=re.I)
			text = re.sub(r'(X-Api-Key[^A-Za-z0-9]+)[A-Za-z0-9._\-]+', r'\1***', text, flags=re.I)
			return text[:limit]
		except:
			return ''

	def _request_attempts(self, params, api_key):
		modes = getattr(self, 'auth_modes', None) or (getattr(self, 'auth_mode', 'query_apikey'),)
		attempts = []
		for mode in modes:
			request_params = dict(params or {})
			headers = {}
			if mode == 'bearer':
				headers['Authorization'] = 'Bearer %s' % api_key
			elif mode in ('x_api_key', 'x-api-key', 'header_apikey'):
				headers['X-Api-Key'] = api_key
			else:
				request_params['apikey'] = api_key
			if self.response_mode == 'json':
				request_params['o'] = 'json'
			attempts.append((self.base_url, request_params, headers, mode))
		return attempts

	def _rate_limit_wait(self):
		limit = self._safe_int(getattr(self, 'max_requests_per_minute', 0), 0)
		if limit <= 0: return
		key = getattr(self, 'provider_key', '') or getattr(self, 'provider_name', '') or self.__class__.__name__
		while True:
			wait = 0
			try:
				with _RATE_LIMIT_LOCK:
					now = time.monotonic()
					window = [i for i in _RATE_LIMIT_STATE.get(key, []) if now - i < 60.0]
					if len(window) >= limit:
						wait = max(0.05, 60.0 - (now - window[0]))
					else:
						window.append(now)
						_RATE_LIMIT_STATE[key] = window
						return
			except:
				return
			if wait > 0:
				time.sleep(wait)

	def _parse_response(self, response):
		mode = getattr(self, 'response_mode', 'xml')
		if mode == 'json':
			return self._parse_json(response.json())
		if mode == 'auto':
			content_type = (response.headers.get('content-type') or '').lower()
			text = response.text or ''
			if 'json' in content_type or text.lstrip().startswith(('{', '[')):
				try: return self._parse_json(response.json())
				except: pass
			return self._parse_xml(text)
		return self._parse_xml(response.text)

	def _request(self, params):
		api_key = self._api_key()
		if not api_key: return []
		last_status = None
		attempts = self._request_attempts(params, api_key)
		for index, (url, request_params, headers, mode) in enumerate(attempts):
			try:
				self._rate_limit_wait()
				log_params = dict(request_params or {})
				if 'apikey' in log_params: log_params['apikey'] = '***'
				log_utils.log('%s private request -> auth=%s | params=%s' % (self.provider_name, mode, log_params), caller='private_torznab')
				response = requests.get(
					url,
					params=request_params,
					headers=headers,
					timeout=max(5, min(int(self.timeout or 8), 10))
				)
				last_status = response.status_code
				if response.status_code != 200:
					log_utils.log('%s private request failed -> status=%s | auth=%s | body=%s' % (self.provider_name, response.status_code, mode, self._safe_preview(response.text)), caller='private_torznab', level=log_utils.LOGWARNING)
					continue
				results = self._parse_response(response)
				log_utils.log('%s private request OK -> auth=%s | results=%s' % (self.provider_name, mode, len(results or [])), caller='private_torznab')
				if results or not getattr(self, 'try_next_auth_on_empty', False) or index >= len(attempts) - 1:
					return results
				log_utils.log('%s private request empty -> trying next auth mode' % self.provider_name, caller='private_torznab')
				continue
			except Exception as exc:
				log_utils.log('%s private request exception -> auth=%s | %s: %s' % (self.provider_name, mode, exc.__class__.__name__, self._safe_preview(exc)), caller='private_torznab', level=log_utils.LOGWARNING)
				continue
		if last_status is not None:
			return []
		source_utils.scraper_error(self.provider_name.upper())
		return []

	def _request_chain(self, params_or_chain):
		"""Exécute une chaîne de requêtes en mode fallback.

		Pour les sources privées, on évite de multiplier les appels inutilement :
		la requête suivante n'est lancée que si la précédente ne retourne rien.
		"""
		try:
			if not isinstance(params_or_chain, (list, tuple)):
				return self._request(params_or_chain)
			aggregated = []
			seen = set()
			for params in params_or_chain:
				results = self._request(params)
				if not results:
					continue
				for raw in results:
					try:
						key = raw.get('hash') or raw.get('url') or raw.get('name')
						if key and key in seen:
							continue
						if key:
							seen.add(key)
						aggregated.append(raw)
					except:
						aggregated.append(raw)
				# Important : fallback seulement si zéro résultat.
				# Dès qu'une requête utile répond, on s'arrête pour respecter l'API privée.
				if aggregated:
					return aggregated
			return []
		except:
			source_utils.scraper_error(self.provider_name.upper())
			return []

	def _query_params(self, query):
		query = str(query or '').strip()
		return {'t': 'search', 'q': query} if query else {'t': 'search'}

	def _append_unique_params(self, params_list, params):
		try:
			if not params:
				return params_list
			cleaned = {k: v for k, v in dict(params).items() if v not in ('', None)}
			if not cleaned:
				return params_list
			marker = tuple(sorted(cleaned.items()))
			if marker not in [tuple(sorted(i.items())) for i in params_list]:
				params_list.append(cleaned)
		except:
			pass
		return params_list

	def _parse_xml(self, xml_text):
		try:
			root = ET.fromstring(xml_text)
		except:
			source_utils.scraper_error(self.provider_name.upper())
			return []
		namespaces = (
			{'torznab': 'http://torznab.com/schemas/2015/feed'},
			{'torznab': 'http://www.newznab.com/DTD/2010/feeds/attributes/'}
		)
		items = root.findall('.//item')
		results = []
		for item in items:
			try:
				title = (item.findtext('title') or '').strip()
				if not title: continue
				guid = (item.findtext('guid') or '').strip()
				link = (item.findtext('link') or '').strip()
				enclosure = item.find('enclosure')
				enclosure_url = (enclosure.get('url') or '').strip() if enclosure is not None else ''
				enclosure_length = (enclosure.get('length') or '').strip() if enclosure is not None else ''
				attrs = []
				for ns in namespaces:
					attrs.extend(item.findall('torznab:attr', ns))
				size = self._size_bytes(item.findtext('size'), item.findtext('length'), enclosure_length)
				seeders, leechers = 0, 0
				info_hash, magnet_url = '', ''
				for attr in attrs:
					name = (attr.get('name') or '').strip().lower()
					value = (attr.get('value') or '').strip()
					if name == 'infohash': info_hash = self._hash_from_text(value)
					elif name == 'seeders': seeders = self._safe_int(value, 0)
					elif name in ('peers', 'leechers'): leechers = self._safe_int(value, 0)
					elif name == 'magneturl': magnet_url = value
					elif name in ('size', 'filesize', 'file_size', 'length') and not size: size = self._size_bytes(value)
				if not info_hash:
					info_hash = self._hash_from_text(magnet_url or guid or enclosure_url or link)
				result = self._raw_result(title, info_hash, size, seeders, leechers, magnet_url)
				if result: results.append(result)
			except:
				source_utils.scraper_error(self.provider_name.upper())
		return results

	def _parse_json(self, data):
		try:
			channel = data.get('channel', {}) if isinstance(data, dict) else {}
			items = channel.get('item', []) if isinstance(channel, dict) else []
			if isinstance(items, dict): items = [items]
		except:
			items = []
		results = []
		for item in items or []:
			try:
				title = item.get('title') or item.get('name') or ''
				if not title: continue
				size = self._size_bytes(item.get('size'), item.get('length'), item.get('filesize'), item.get('file_size'))
				seeders, leechers = 0, 0
				info_hash, magnet_url = '', ''
				attrs = item.get('torznab:attr', [])
				if isinstance(attrs, dict): attrs = [attrs]
				for attr in attrs or []:
					attr_data = attr.get('@attributes', attr) if isinstance(attr, dict) else {}
					name = (attr_data.get('name') or '').strip().lower()
					value = (attr_data.get('value') or '').strip()
					if name == 'infohash': info_hash = self._hash_from_text(value)
					elif name == 'seeders': seeders = self._safe_int(value, 0)
					elif name in ('peers', 'leechers'): leechers = self._safe_int(value, 0)
					elif name == 'magneturl': magnet_url = value
					elif name in ('size', 'filesize', 'file_size', 'length') and not size: size = self._size_bytes(value)
				guid = item.get('guid') or item.get('id') or ''
				if isinstance(guid, dict): guid = guid.get('#text') or guid.get('@attributes', {}).get('isPermaLink') or ''
				enclosure = item.get('enclosure') or {}
				if isinstance(enclosure, dict):
					enclosure = enclosure.get('@attributes', enclosure)
				enclosure_url = enclosure.get('url') if isinstance(enclosure, dict) else ''
				if isinstance(enclosure, dict) and not size:
					size = self._size_bytes(enclosure.get('length'), enclosure.get('size'))
				if not info_hash:
					info_hash = self._hash_from_text(magnet_url or guid or enclosure_url or item.get('link'))
				result = self._raw_result(title, info_hash, size, seeders, leechers, magnet_url)
				if result: results.append(result)
			except:
				source_utils.scraper_error(self.provider_name.upper())
		return results

	def _raw_result(self, title, info_hash, size, seeders, leechers, magnet_url=''):
		try:
			if not info_hash: return None
			magnet = magnet_url if str(magnet_url or '').startswith('magnet:') else self._make_magnet(info_hash, title)
			if not magnet: return None
			item = {
				'name': str(title or '').strip(), 'url': magnet, 'hash': info_hash,
				'size_bytes': size, 'size': self._size_gb(size),
				'seeders': seeders, 'leechers': leechers
			}
			return None if _is_blocked_file_source(item) else item
		except:
			return None

	def _id_params(self, data, search_type, include_episode=False, season_only=False):
		params = {'t': search_type}
		try:
			imdb = str(data.get('imdb') or '').strip()
			if imdb and not imdb.startswith('tt'): imdb = 'tt%s' % imdb
			if imdb: params['imdbid'] = imdb
			elif data.get('tmdb'): params['tmdbid'] = str(data.get('tmdb'))
		except:
			pass
		try:
			if include_episode or season_only:
				season = data.get('season')
				if season not in ('', None): params['season'] = str(season)
			if include_episode:
				episode = data.get('episode')
				if episode not in ('', None): params['episode'] = str(episode)
		except:
			pass
		return params

	def _movie_params(self, data):
		if getattr(self, 'prefer_id_then_query_search', False):
			params_list = []
			id_params = self._id_params(data, 'movie')
			if 'imdbid' in id_params or 'tmdbid' in id_params:
				self._append_unique_params(params_list, id_params)
			query_with_year = '%s %s' % (data.get('title') or '', data.get('year') or '')
			self._append_unique_params(params_list, self._query_params(query_with_year.strip()))
			self._append_unique_params(params_list, self._query_params(data.get('title') or ''))
			return params_list
		if getattr(self, 'prefer_query_search', False):
			query = '%s %s' % (data.get('title') or '', data.get('year') or '')
			return {'t': 'search', 'q': query.strip()}
		params = self._id_params(data, 'movie')
		if len(params) == 1:
			query = '%s %s' % (data.get('title') or '', data.get('year') or '')
			params['q'] = query.strip()
		return params

	def _episode_params(self, data):
		if getattr(self, 'prefer_id_then_query_search', False):
			params_list = []
			id_params = self._id_params(data, 'tvsearch', include_episode=True)
			if 'imdbid' in id_params or 'tmdbid' in id_params:
				self._append_unique_params(params_list, id_params)
			try: query = '%s S%02dE%02d' % (data.get('tvshowtitle') or '', int(data.get('season') or 0), int(data.get('episode') or 0))
			except: query = str(data.get('tvshowtitle') or '').strip()
			self._append_unique_params(params_list, self._query_params(query.strip()))
			return params_list
		if getattr(self, 'prefer_query_search', False):
			try: query = '%s S%02dE%02d' % (data.get('tvshowtitle') or '', int(data.get('season') or 0), int(data.get('episode') or 0))
			except: query = str(data.get('tvshowtitle') or '').strip()
			return {'t': 'search', 'q': query.strip()}
		params = self._id_params(data, 'tvsearch', include_episode=True)
		if len(params) <= 3 and 'imdbid' not in params and 'tmdbid' not in params:
			params['q'] = str(data.get('tvshowtitle') or '').strip()
		return params

	def _season_params(self, data):
		if getattr(self, 'prefer_id_then_query_search', False):
			params_list = []
			id_params = self._id_params(data, 'tvsearch', season_only=True)
			if 'imdbid' in id_params or 'tmdbid' in id_params:
				self._append_unique_params(params_list, id_params)
			try: query = '%s S%02d' % (data.get('tvshowtitle') or '', int(data.get('season') or 0))
			except: query = str(data.get('tvshowtitle') or '').strip()
			self._append_unique_params(params_list, self._query_params(query.strip()))
			return params_list
		if getattr(self, 'prefer_query_search', False):
			try: query = '%s S%02d' % (data.get('tvshowtitle') or '', int(data.get('season') or 0))
			except: query = str(data.get('tvshowtitle') or '').strip()
			return {'t': 'search', 'q': query.strip()}
		params = self._id_params(data, 'tvsearch', season_only=True)
		if len(params) <= 2 and 'imdbid' not in params and 'tmdbid' not in params:
			params['q'] = '%s S%02d' % (data.get('tvshowtitle') or '', int(data.get('season') or 0))
		return params

	def _show_params(self, data):
		# Recherche globale volontairement limitée et cachée pour les sources privées.
		if getattr(self, 'prefer_id_then_query_search', False):
			params_list = []
			id_params = self._id_params(data, 'tvsearch')
			if 'imdbid' in id_params or 'tmdbid' in id_params:
				self._append_unique_params(params_list, id_params)
			self._append_unique_params(params_list, self._query_params(str(data.get('tvshowtitle') or '').strip()))
			return params_list
		if getattr(self, 'prefer_query_search', False):
			return {'t': 'search', 'q': str(data.get('tvshowtitle') or '').strip()}
		params = self._id_params(data, 'tvsearch')
		if len(params) == 1:
			params['q'] = str(data.get('tvshowtitle') or '').strip()
		return params

	def _language_rank_from_text(self, text):
		try:
			text = str(text or '')
			if re.search(r'(?<![a-z0-9])(?:true[ ._-]?french|french|vf[ ._-]?fr|vf[ ._-]?fq|vf[ ._-]?i|vfi|vff|vfq|vf2|vof|vf|fr|fra|fre|frn|multi)(?![a-z0-9])', text, re.I): return 0
			if re.search(r'(?<![a-z0-9])vo[ ._-]?stfr(?![a-z0-9])|(?<![a-z0-9])vostfr(?![a-z0-9])|(?<![a-z0-9])subfrench(?![a-z0-9])', text, re.I): return 1
		except:
			pass
		return 2

	def _language_from_text(self, text):
		# Ces sources privées sont principalement francophones ; on évite donc de classer
		# les releases sans tag explicite comme EN par défaut.
		return 'fr'

	def _normalize_match_text(self, value):
		try:
			value = unicodedata.normalize('NFKD', str(value or ''))
			value = ''.join(c for c in value if not unicodedata.combining(c))
			value = re.sub(r'[^a-z0-9]+', ' ', value.lower())
			return re.sub(r'\s+', ' ', value).strip()
		except:
			return ''

	def _match_tokens(self, values):
		stop = set(('the', 'and', 'avec', 'les', 'des', 'une', 'pour', 'dans', 'tom', 'de', 'du', 'la', 'le', 'un', 'of', 'a'))
		tokens = []
		try:
			if not isinstance(values, (list, tuple, set)): values = [values]
			for value in values:
				if isinstance(value, dict):
					value = value.get('title') or value.get('name') or value.get('label') or ''
				for token in self._normalize_match_text(value).split():
					if len(token) < 3 or token in stop: continue
					if token not in tokens: tokens.append(token)
		except:
			pass
		return tokens

	def _loose_private_match(self, name, data, hdlr, year):
		try:
			name_norm = self._normalize_match_text(name)
			if not name_norm: return False
			aliases = data.get('aliases', []) or []
			title_value = data.get('tvshowtitle') if 'tvshowtitle' in data else data.get('title')
			tokens = self._match_tokens([title_value] + list(aliases))
			if not tokens: return False

			# Films : on garde le millésime comme garde-fou pour éviter d'accepter
			# des résultats de séries ou de sagas portant un titre voisin.
			if 'tvshowtitle' not in data and year:
				if str(year) not in name_norm: return False
				return sum(1 for token in tokens if token in name_norm) >= min(2, len(tokens))

			# Séries : fallback volontairement plus strict, pour éviter les titres trop courts.
			if 'tvshowtitle' in data:
				hdlr_norm = self._normalize_match_text(hdlr)
				if hdlr_norm and hdlr_norm not in name_norm: return False
				if len(tokens) < 2: return False
				return sum(1 for token in tokens if token in name_norm) >= 2
		except:
			pass
		return False


	def _trust_api_results_for_data(self, data):
		"""Retourne True uniquement lorsque les résultats API peuvent être acceptés tels quels.

		Pour C411, la confiance est utile sur les films recherchés par ID IMDb/TMDb,
		car les titres peuvent être localisés ou différents. Pour les épisodes et
		packs de séries, on garde toujours les validations saison/épisode afin
		d'éviter qu'une requête S04E06 retourne un autre épisode ou un pack hors cible.
		"""
		try:
			if not getattr(self, 'trust_api_results', False):
				return False
			return 'tvshowtitle' not in (data or {})
		except:
			return False

	def _fr_rank(self, item):
		try: return self._language_rank_from_text('%s %s %s' % (item.get('name', ''), item.get('name_info', ''), item.get('info', '')))
		except: return 3

	def _result_item(self, raw, data, package=None, episode_start=0, episode_end=0, last_season=None):
		try:
			title = data['tvshowtitle'] if 'tvshowtitle' in data else data['title']
			year = str(data.get('year') or '')
			season = data.get('season') if 'tvshowtitle' in data else None
			episode_title = data.get('title') if 'tvshowtitle' in data else None
			hdlr = 'S%02dE%02d' % (int(data['season']), int(data['episode'])) if 'tvshowtitle' in data and not package else year
			name = source_utils.clean_name(raw.get('name') or '')
			if not name: return None
			if _is_blocked_file_source({'name': name, 'url': raw.get('url'), 'raw': raw}): return None
			pack = package if package in ('season', 'show') else None
			name_info = source_utils.info_from_name(name, title, year, hdlr, episode_title, season=str(season or ''), pack=pack)
			if not self._trust_api_results_for_data(data) and source_utils.remove_lang(name_info, source_utils.check_foreign_audio()): return None
			quality, info = source_utils.get_release_quality(name_info, raw.get('url'))
			if raw.get('size'):
				info.insert(0, '%.2f GB' % raw.get('size'))
			info = ' | '.join(info)
			item = {
				'provider': self.provider_key, 'provider_name': self.provider_name,
				'source': 'torrent', 'language': self._language_from_text('%s %s' % (name_info, name)),
				'direct': False, 'debridonly': True, 'personal_source': True,
				# Les sources privées retournent des magnets/hash non vérifiés ; aucun appel débrideur automatique.
				'cache_provider': 'Unchecked alldebrid', 'bypass_filter': bool(self._trust_api_results_for_data(data)),
				'hash': raw.get('hash'), 'url': raw.get('url'), 'name': name, 'name_info': name_info,
				'quality': quality, 'info': info, 'size': raw.get('size', 0), 'seeders': raw.get('seeders', 0),
				'tracker': self.provider_name, 'true_size': bool(raw.get('size'))
			}
			if package:
				item.update({'package': package, 'true_size': True})
			if package == 'show':
				item.update({'last_season': last_season or data.get('total_seasons') or 0})
			if episode_start:
				item.update({'episode_start': episode_start, 'episode_end': episode_end})
			return item if not _is_blocked_file_source(item) else None
		except:
			source_utils.scraper_error(self.provider_name.upper())
			return None

	def _filter_single(self, raw_results, data):
		sources = []
		raw_results = raw_results or []
		try:
			title = data['tvshowtitle'] if 'tvshowtitle' in data else data['title']
			aliases = data.get('aliases', [])
			year = str(data.get('year') or '')
			hdlr = 'S%02dE%02d' % (int(data['season']), int(data['episode'])) if 'tvshowtitle' in data else year
		except:
			return sources

		# Certaines APIs privées, notamment C411 en mode Torznab générique, filtrent
		# déjà les résultats côté serveur selon la requête envoyée. Dans ce cas, on
		# évite d'ajouter un deuxième filtre TMDb strict qui peut éliminer des titres
		# valides mais localisés/abrégés/nommés différemment par le tracker.
		if self._trust_api_results_for_data(data):
			for raw in raw_results:
				try:
					item = self._result_item(raw, data)
					if item: sources.append(item)
				except:
					source_utils.scraper_error(self.provider_name.upper())
			log_utils.log('%s private trusted API results accepted -> raw=%s | accepted=%s' % (self.provider_name, len(raw_results or []), len(sources or [])), caller='private_torznab')
			return self._sort_results(sources)

		for raw in raw_results:
			try:
				name = source_utils.clean_name(raw.get('name') or '')
				if not name: continue
				if not source_utils.check_title(title, aliases, name, hdlr, year): continue
				item = self._result_item(raw, data)
				if item: sources.append(item)
			except:
				source_utils.scraper_error(self.provider_name.upper())

		# Certaines APIs privées retournent bien des résultats pertinents en recherche
		# générique, mais avec des titres localisés/alternatifs qui ne passent pas
		# toujours le filtre strict TMDb. On applique alors un fallback local, sans
		# nouvelle requête API et sans appel débrideur automatique.
		if not sources and getattr(self, 'allow_loose_private_match', False):
			for raw in raw_results:
				try:
					name = source_utils.clean_name(raw.get('name') or '')
					if not name: continue
					if not self._loose_private_match(name, data, hdlr, year): continue
					item = self._result_item(raw, data)
					if item: sources.append(item)
				except:
					source_utils.scraper_error(self.provider_name.upper())
			if sources:
				log_utils.log('%s private loose filter accepted -> results=%s' % (self.provider_name, len(sources)), caller='private_torznab')
		return self._sort_results(sources)

	def _sort_results(self, sources):
		try: sources.sort(key=lambda i: (self._fr_rank(i), -int(i.get('seeders', 0) or 0), -float(i.get('size', 0) or 0)))
		except: pass
		return sources

	def sources(self, data, hostDict):
		if not data or not self._api_key(): return []
		try:
			params = self._episode_params(data) if 'tvshowtitle' in data else self._movie_params(data)
			return self._filter_single(self._request_chain(params), data)
		except:
			source_utils.scraper_error(self.provider_name.upper())
			return []

	def sources_packs(self, data, hostDict, search_series=False, total_seasons=None):
		if not data or 'tvshowtitle' not in data or not self._api_key(): return []
		sources = []
		try:
			title = data['tvshowtitle']
			aliases = data.get('aliases', [])
			year = str(data.get('year') or '')
			season = data['season']
			params = self._show_params(data) if search_series else self._season_params(data)
			raw_results = self._request_chain(params)
		except:
			source_utils.scraper_error(self.provider_name.upper())
			return sources
		for raw in raw_results or []:
			try:
				name = source_utils.clean_name(raw.get('name') or '')
				if not name: continue
				if search_series:
					valid, last_season = source_utils.filter_show_pack(title, aliases, data.get('imdb'), year, season, name, total_seasons or data.get('total_seasons'))
					if not valid: continue
					item = self._result_item(raw, data, package='show', last_season=last_season)
				else:
					valid, episode_start, episode_end = source_utils.filter_season_pack(title, aliases, year, season, name)
					if not valid: continue
					item = self._result_item(raw, data, package='season', episode_start=episode_start, episode_end=episode_end)
				if item: sources.append(item)
			except:
				source_utils.scraper_error(self.provider_name.upper())
		return self._sort_results(sources)
