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

import json
import time
import re, random
from concurrent.futures import ThreadPoolExecutor as TPE, as_completed
from threading import Thread
from windows import open_window, create_window
from scrapers import folders
from modules.debrid import debrid_enabled, debrid_type_enabled, debrid_valid_hosts, Source, DebridCheck
from modules import player, kodi_utils, settings, pack_reuse
from modules.source_objects import get_source_meta, ExternalSource, PERSONAL_EXTERNAL_PROVIDERS, _is_blocked_file_source, clean_file_name_preserve_extension
from modules.source_result_utils import normalize_source_results, is_personal_result as source_is_personal_result, is_direct_source_result, sort_personal_results as sort_source_personal_results, personal_result_order as source_personal_result_order, source_debug_value, source_debug_file, source_progress_line1
from modules.source_result_utils import QUALITY_RANKS, PERSONAL_PROVIDER_ORDER, set_sort_ranks as set_source_sort_ranks, get_quality_rank as source_quality_rank
from modules.source_result_utils import sort_french_streaming_priority as sort_sources_french_priority, sort_quality_then_language_priority as sort_sources_quality_then_language_priority, sort_uncached_torrents as sort_sources_uncached, source_passes_result_filters, apply_source_special_filter, filter_french_vostfr_results, filter_vfq_only_results, filter_vostfr_only_results, limit_results_with_comet_fallback, is_comet_result
from modules.source_utils import external_sources, internal_sources, internal_folders_import
from modules.source_utils import pack_enable_check, sources_quality_count, scraper_names
from modules.utils import string_to_float
#from modules.kodi_utils import logger

AlkoFlixPlayer, progressDialogBG, notification = player.AlkoFlixPlayer, kodi_utils.progressDialogBG, kodi_utils.notification
show_busy_dialog, hide_busy_dialog, close_all_dialog = kodi_utils.show_busy_dialog, kodi_utils.hide_busy_dialog, kodi_utils.close_all_dialog
get_property, set_property, clear_property = kodi_utils.get_property, kodi_utils.set_property, kodi_utils.clear_property
ls, monitor, sleep, get_setting = kodi_utils.local_string, kodi_utils.monitor, kodi_utils.sleep, kodi_utils.get_setting
check_prescrape_sources, quality_filter, sort_to_top = settings.check_prescrape_sources, settings.quality_filter, settings.sort_to_top
results_xml_style = settings.results_xml_style
default_internal_scrapers, cloud_scrapers = settings.default_internal_scrapers, settings.cloud_scrapers
folder_scrapers = ('folder1', 'folder2', 'folder3', 'folder4', 'folder5')
SOURCE_RESULTS_DEFAULT_STYLE = 'widelist'
SOURCE_RESULTS_WINDOW_CONFIG = {
	'list': {'xml': 'sources_results_list.xml', 'window_id': 2000, 'style': 'list default'},
	'infolist': {'xml': 'sources_results_infolist.xml', 'window_id': 2001, 'style': 'infolist default'},
	'widelist': {'xml': 'sources_results_widelist.xml', 'window_id': 2002, 'style': 'widelist default'}
}


def _results_window_style_key(window_style):
	try: style_key = str(window_style or '').split(' ')[0].lower()
	except: style_key = ''
	return style_key if style_key in SOURCE_RESULTS_WINDOW_CONFIG else SOURCE_RESULTS_DEFAULT_STYLE

def _strip_markup(text):
	try:
		return str(text).replace('[B]', '').replace('[/B]', '').replace('[I]', '').replace('[/I]', '')
	except:
		return str(text or '')


def _start_background_progress(message=None, percent=1):
	# Affiche le petit indicateur Kodi en haut à droite pendant les recherches et la résolution.
	# On démarre à 1 % pour éviter le vieux symptôme du "loading 0 %" qui peut rester collé
	# sur certains skins si le dialogue est créé puis fermé trop vite.
	try:
		message = _strip_markup(message or ls(32577))
		progressDialogBG.create('alkoFlix', message)
		set_property('alkoflix.progress_bg_active', 'true')
		progressDialogBG.update(max(1, min(99, int(percent))), 'alkoFlix', message)
		return time.monotonic()
	except Exception as e:
		try: clear_property('alkoflix.progress_bg_active')
		except: pass
		try: kodi_utils.logger('Lecture source', 'Création du dialogue de progression ignorée -> %s' % e)
		except: pass
		return None


def _update_background_progress(started_at, percent=None, message=None):
	if not started_at: return
	try:
		message = _strip_markup(message or ls(32577))
		if percent is None: percent = 1
		progressDialogBG.update(max(1, min(99, int(percent))), 'alkoFlix', message)
	except Exception as e:
		try: kodi_utils.logger('Lecture source', 'Mise à jour du dialogue de progression ignorée -> %s' % e)
		except: pass


def _safe_close_background_progress(started_at=None, min_visible_ms=350):
	# Ferme uniquement le DialogProgressBG que l'on a réellement créé dans ce cycle.
	# Appeler close() sans create() génère "Dialog not created" dans le log Kodi.
	if not started_at: return
	try:
		elapsed_ms = int((time.monotonic() - started_at) * 1000)
		if elapsed_ms < min_visible_ms: sleep(min_visible_ms - elapsed_ms)
		progressDialogBG.close()
	except Exception as e:
		try: kodi_utils.logger('Lecture source', 'Fermeture du dialogue de progression ignorée -> %s' % e)
		except: pass
	finally:
		try: clear_property('alkoflix.progress_bg_active')
		except: pass

def _external_progress_label(provider):
	try:
		provider_key = str(provider or '').lower()
		if provider_key in ('custom_1', 'custom_stremio1'):
			return get_setting('custom_1.name', '').strip() or provider
		if provider_key in ('custom_2', 'custom_stremio2'):
			return get_setting('custom_2.name', '').strip() or provider
	except: pass
	return provider
quality_ranks = QUALITY_RANKS
av1_filter_key, hevc_filter_key, hdr_filter_key, dolby_vision_filter_key = '[B]AV1[/B]', '[B]HEVC[/B]', '[B]HDR[/B]', '[B]D/VISION[/B]'
total_format, int_format, ext_format = '[COLOR %s][B]%s[/B][/COLOR]', '[COLOR %s][B]Int: [/B][/COLOR]%s', '[COLOR %s][B]Ext: [/B][/COLOR]%s'
ext_scr_format, unfinshed_import_format, format_line = '[COLOR %s][B]%s[/B][/COLOR]', '[COLOR red]+%s[/COLOR]', '%s[CR]%s[CR]%s'
diag_format, resolutions, pack_display = '4K: %s | 1080p: %s | 720p: %s | SD: %s | Total: %s', '4K 1080p 720p SD total', '%s (%s)'
dialog_format = '[COLOR %s][B]%s[/B][/COLOR] 4K: %s | 1080p: %s | 720p: %s | SD: %s | Total: %s'
remaining_format, season_str, show_str, nextep_str, nores_str = ls(32676), ls(32537), ls(32089), ls(32801), ls(32760)

class SourceSelect:
	def __init__(self):
		self.params = {}
		self.clear_properties, self.filters_ignored, self.active_folders = True, False, False
		self.progress_dialog, self.alkoflix_background_url = None, None
		self.threads, self.providers, self.sources, self.internal_scraper_names = [], [], [], []
		self.prescrape_scrapers, self.prescrape_threads, self.prescrape_sources = [], [], []
		self.remove_scrapers = ['external']# doit être modifiable, donc le laisser sous forme de liste.
		self.exclude_list = ['easynews', 'library']# doit être modifiable, donc le laisser sous forme de liste.
		self.internal_resolutions = dict.fromkeys('4K 1080p 720p SD total'.split(), 0)
		self.prescrape, self.disabled_ignored = 'true', 'false'
		self.results_apply_to_external_sources = False
		self.language = settings.get_language()

	def playback_prep(self, params=None):
		if self.clear_properties: self._clear_properties()
		# Sécurité : ferme uniquement une notification de progression réellement créée par alkoFlix.
		# Sans ce garde-fou, Kodi écrit parfois "EXCEPTION: Dialog not created" dans le log.
		try:
			if get_property('alkoflix.progress_bg_active') == 'true':
				progressDialogBG.close()
				clear_property('alkoflix.progress_bg_active')
		except: pass
		if params: self.params = params
		params_get = self.params.get
		self.prescrape = params_get('prescrape', self.prescrape) == 'true'
		self.background = params_get('background', 'false') == 'true'
		if self.background: hide_busy_dialog()
		else: show_busy_dialog()
		if 'autoplay' in self.params: self.autoplay = params_get('autoplay', 'false') == 'true'
		else: self.autoplay = settings.auto_play(params_get('media_type'))
		self.disabled_ignored = params_get('disabled_ignored', self.disabled_ignored) == 'true'
		self.ignore_scrape_filters = params_get('ignore_scrape_filters', 'false') == 'true'
		self.media_type = params_get('media_type')
		self.tmdb_id = params_get('tmdb_id')
		self.season = int(params_get('season')) if 'season' in self.params else ''
		self.episode = int(params_get('episode')) if 'episode' in self.params else ''
		self.custom_title = params_get('custom_title')
		self.custom_year = params_get('custom_year')
		self.custom_season = int(params_get('custom_season')) if 'custom_season' in self.params else None
		self.custom_episode = int(params_get('custom_episode')) if 'custom_episode' in self.params else None
		# Groupe de continuité transmis par Up Next : permet à l'autoplay de privilégier
		# un lien équivalent à celui choisi pour l'épisode précédent.
		self.binge_group = params_get('bingeGroup') or params_get('binge_group') or ''
		self.active_internal_scrapers = settings.active_internal_scrapers()
		self.active_external = 'external' in self.active_internal_scrapers
		self.display_uncached_torrents = settings.display_uncached_torrents()
		self.ignore_results_filter = settings.ignore_results_filter()
		self.provider_sort_ranks = settings.provider_sort_ranks()
		self.scraper_settings = settings.scraping_settings()
		self.sort_function = settings.results_sort_order()
		self.results_sort_priority = settings.results_sort_priority()
		self.results_priority_quality = settings.results_priority_quality()
		self.results_apply_to_external_sources = settings.results_apply_to_external_sources()
		self.filter_av1 = settings.filter_status('av1')
		self.filter_hevc = settings.filter_status('hevc')
		self.filter_hdr = settings.filter_status('hdr')
		self.filter_dv = settings.filter_status('dv')
		self.hybrid_allowed = self.filter_hdr in (0, 2)
		self.include_prerelease_results, self.include_3D_results = settings.include_prerelease_3d_results()
		self.quality_filter = self._quality_filter()
		self.results_only_fr_vostfr = settings.results_only_fr_vostfr()
		self.results_exclude_vfq_only = settings.results_exclude_vfq_only()
		self.results_exclude_vostfr_only = settings.results_exclude_vostfr_only()
		self.results_private_trackers_first = settings.results_private_trackers_first()
		self.results_comet_fallback = settings.results_comet_fallback()
		self.comet_fallback_threshold = 5
		self.results_max_per_resolution = settings.results_max_per_resolution()
		self.results_max_total = settings.results_max_total()
		# Le chargement plein écran a été retiré : le chargement se fait toujours en arrière-plan.
		# Cela neutralise aussi les anciens réglages Kodi qui auraient conservé load_action=1.
		self.full_screen = False
		self.size_filter = int(get_setting('results.size_filter', '0'))
		self.include_unknown_size = get_setting('results.include.unknown.size') == 'true'
		self.sleep_time = settings.display_sleep_time()
		self.timeout = int(get_setting('scrapers.timeout.1', '10')) * 2 if self.disabled_ignored else int(get_setting('scrapers.timeout.1', '10'))
		# Mode des sources alkoFlix intégrées (Comet, Nyaa, etc.).
		# 0 = comportement actuel : les sources personnelles et intégrées cherchent ensemble.
		# 1 = solution de secours : les sources intégrées ne se déclenchent que si
		#     AIOStreams / Custom 1 / Custom 2 ne retournent aucun lien.
		#
		# Important : l'option du menu contextuel « Scraper avec TOUS les scrapers externes »
		# passe disabled_ignored=True. Dans ce cas, on doit impérativement ignorer le mode
		# solution de secours afin de conserver le comportement historique : tous les scrapers
		# externes sont appelés ensemble, même si une source personnelle retourne des liens.
		self.alkoflix_sources_fallback = get_setting('sources.alkoflix_mode', '0') == '1' and not self.disabled_ignored
		self.alkoflix_fallback_external_providers = []
		self.comet_fallback_external_providers = []
		if not hasattr(self, 'meta'): self.meta = get_source_meta(self.params)
		self.pack_reuse_source = pack_reuse.build_reuse_source(self.meta)
		self.get_sources()

	def get_sources(self):
		results = []
		start_time = time.monotonic()
		self.prepare_internal_scrapers()
		if any(x in self.active_internal_scrapers for x in default_internal_scrapers) and self.prescrape:
			results = self.collect_prescrape_results()
			if results: results = self.process_results(results)
		if not results:
			self.prescrape = False
			if self.active_external: self.activate_external_providers()
			self.orig_results = self.collect_results()
			results = self.process_results(self.orig_results)
		self.meta.update({'scrape_sources': len(results), 'scrape_time': time.monotonic() - start_time})
		if not results: return self._process_post_results()
		self.play_source(results)

	def collect_results(self):
		self.sources.extend(self.prescrape_sources)
		if getattr(self, 'pack_reuse_source', None):
			self.sources.insert(0, self.pack_reuse_source)
		if self.active_folders: self.append_folder_scrapers(self.providers)
		self.providers.extend(internal_sources(self.active_internal_scrapers, self.media_type))
		if self.providers:
			threads = (Thread(target=self.activate_providers, args=(i[0], i[1], False), name=i[2]) for i in self.providers)
			self.threads.extend(threads)
			for i in self.threads: i.start()
		if self.active_external or self.background:
			progress_bg_started = None
			try:
				if self.active_external:
					self.meta.update({'full_screen': self.full_screen, 'scrape_timeout': self.timeout})
					self.external_args = self._make_external_args(self.external_providers)
					self.activate_providers('external', Manager, False)

					# Sources alkoFlix en solution de secours : si AIOStreams / Custom 1 / Custom 2
					# ne retournent aucun lien, on lance alors les sources intégrées activées plus bas
					# dans les réglages. Sans ce mode, le comportement historique reste inchangé.
					if self.alkoflix_fallback_external_providers and not self._has_personal_results(self.sources):
						self.external_args = self._make_external_args(self.alkoflix_fallback_external_providers)
						self.activate_providers('external', Manager, False)

					# Comet en secours souple : Comet complète seulement les recherches trop pauvres.
					# 0 à 5 liens non-Comet = on interroge Comet. Plus de 5 = on évite les doublons.
					if self.comet_fallback_external_providers and self._non_comet_result_count(self.sources) <= self.comet_fallback_threshold:
						self.external_args = self._make_external_args(self.comet_fallback_external_providers)
						self.activate_providers('external', Manager, False)
				if self.providers: [i.join() for i in self.threads]
			finally:
				if progress_bg_started: _safe_close_background_progress(progress_bg_started)
		else: self.scrapers_dialog('internal')
		self._kill_progress_dialog()
		return self.sources

	def _make_external_args(self, providers):
		return (
			self.meta,
			providers,
			self.debrid_torrent_enabled,
			self.debrid_hoster_enabled,
			self.internal_scraper_names,
			self.prescrape_sources,
			self.display_uncached_torrents,
			self.progress_dialog,
			self.disabled_ignored
		)

	def _has_personal_results(self, results):
		try: return any(self._is_personal_result(i) for i in results)
		except: return False

	def _has_non_comet_results(self, results):
		try: return any(not is_comet_result(i) for i in results)
		except: return False

	def _non_comet_result_count(self, results):
		try: return len([i for i in results or [] if not is_comet_result(i)])
		except: return 0

	def collect_prescrape_results(self):
		if getattr(self, 'pack_reuse_source', None) and self.pack_reuse_source not in self.prescrape_sources:
			self.prescrape_sources.insert(0, self.pack_reuse_source)
		if self.active_folders:
			if self.autoplay or check_prescrape_sources('folders', self.media_type):
				self.append_folder_scrapers(self.prescrape_scrapers)
				self.remove_scrapers.append('folders')
		self.prescrape_scrapers.extend(internal_sources(self.active_internal_scrapers, self.media_type, True))
		if not self.prescrape_scrapers: return self.prescrape_sources
		threads = (Thread(target=self.activate_providers, args=(i[0], i[1], True), name=i[2]) for i in self.prescrape_scrapers)
		self.prescrape_threads.extend(threads)
		for i in self.prescrape_threads: i.start()
		self.remove_scrapers.extend(i[2] for i in self.prescrape_scrapers)
		if self.background: [i.join() for i in self.prescrape_threads]
		else: self.scrapers_dialog('pre_scrape')
		self._kill_progress_dialog()
		return self.prescrape_sources

	def process_results(self, results):
		results = normalize_source_results(results)
		results = [i for i in results if not _is_blocked_file_source(i)]
		if self.prescrape: self.all_scrapers = self.active_internal_scrapers
		else: self.all_scrapers = list(set(self.active_internal_scrapers + self.remove_scrapers))

		if self.results_apply_to_external_sources:
			# Option avancée : AIOStreams / Custom 1 / Custom 2 entrent dans le
			# même pipeline que les sources alkoFlix (qualité, langue, limites,
			# filtres AV1/HEVC/HDR/Dolby Vision, taille, ordre principal).
			# Désactivé par défaut afin de préserver l'ordre/configuration du manifest.
			personal_results, alkoflix_results = [], list(results or [])
		else:
			personal_results, alkoflix_results = self._split_personal_results(results)
			personal_results = self._sort_personal_results(personal_results)
		if self.ignore_scrape_filters:
			self.filters_ignored = True
			alkoflix_results = self.sort_results(alkoflix_results)
			alkoflix_results = self._sort_first(alkoflix_results)
			# La priorité finale est centralisée : Langue par défaut, ou Qualité si choisi.
			# Le tri précédent reste le tri secondaire stable dans chaque groupe.
			alkoflix_results = self._sort_french_streaming_priority(alkoflix_results)
		else:
			alkoflix_results = self.filter_results(alkoflix_results)
			alkoflix_results = self.sort_results(alkoflix_results)
			alkoflix_results = self._special_filter(alkoflix_results, hevc_filter_key, self.filter_hevc)
			alkoflix_results = self._special_filter(alkoflix_results, hdr_filter_key, self.filter_hdr)
			alkoflix_results = self._special_filter(alkoflix_results, dolby_vision_filter_key, self.filter_dv)
			alkoflix_results = self._special_filter(alkoflix_results, av1_filter_key, self.filter_av1)
			alkoflix_results = self._sort_first(alkoflix_results)
			# Priorité finale : Langue par défaut, ou Qualité si choisi.
			# Les filtres CAM/3D/HEVC/HDR/etc. restent appliqués avant cette étape.
			alkoflix_results = self._sort_french_streaming_priority(alkoflix_results)

		# Les limites/filtres d'affichage s'appliquent aux résultats traités dans le
		# pipeline alkoFlix. Par défaut, les sources personnelles externes n'y entrent
		# pas; avec l'option avancée, elles y passent aussi.
		alkoflix_results = self._apply_display_result_limits(alkoflix_results)

		# Les sources personnelles externes restent dans leur ordre interne quand l'option
		# avancée est désactivée. Si l'option « cloud en haut » est active, on applique
		# ensuite une simple partition stable globale : CLOUD d'abord, puis le reste,
		# sans retrier les résultats à l'intérieur de chaque groupe.
		results = self._sort_pack_reuse_to_top(personal_results + alkoflix_results)
		return self._apply_global_cloud_priority(results)

	def _sort_pack_reuse_to_top(self, results):
		try:
			if self.results_apply_to_external_sources:
				return sorted(results or [], key=lambda item: 0 if item.get('pack_reuse') else 1)
			personal_results, alkoflix_results = self._split_personal_results(results or [])
			alkoflix_results = sorted(alkoflix_results, key=lambda item: 0 if item.get('pack_reuse') else 1)
			return personal_results + alkoflix_results
		except:
			return results

	def _split_personal_results(self, results):
		personal_results, alkoflix_results = [], []
		for item in results:
			if self._is_personal_result(item): personal_results.append(item)
			else: alkoflix_results.append(item)
		return personal_results, alkoflix_results

	def _active_cloud_priority_scrapers(self):
		try: return [i for i in cloud_scrapers if sort_to_top(i)]
		except: return []

	def _cloud_priority_scraper(self, item):
		# Détecte les résultats issus d'un cloud débrideur, peu importe leur origine
		# visible dans le sélecteur : source native alkoFlix, AIOStreams ou manifest custom.
		# On évite volontairement de considérer un simple cache_provider=alldebrid
		# comme du CLOUD : un torrent caché AD/RD/TB n'est pas forcément un élément
		# déjà présent dans le cloud de l'utilisateur.
		try:
			if not isinstance(item, dict): return ''
			for key in ('scrape_provider', 'source', 'provider', 'provider_name', 'display_name', 'source_site'):
				value = str(item.get(key) or '').strip().lower().replace('-', '_').replace(' ', '_')
				if value in cloud_scrapers: return value
				if 'ad_cloud' in value: return 'ad_cloud'
				if 'rd_cloud' in value: return 'rd_cloud'
				if 'tb_cloud' in value: return 'tb_cloud'
				if 'cloud' in value and ('all_debrid' in value or 'alldebrid' in value): return 'ad_cloud'
				if 'cloud' in value and ('real_debrid' in value or 'realdebrid' in value): return 'rd_cloud'
				if 'cloud' in value and 'torbox' in value: return 'tb_cloud'
			if item.get('ad_cloud_section'): return 'ad_cloud'
		except:
			pass
		return ''

	def _apply_global_cloud_priority(self, results):
		# Option « placer les résultats du cloud en haut » :
		# applique une partition stable sur la liste finale complète.
		# Les éléments CLOUD remontent, mais leur ordre relatif reste inchangé.
		try:
			cloud_first_scrapers = self._active_cloud_priority_scrapers()
			if not cloud_first_scrapers: return results
			cloud_first, cloud_last = [], []
			for item in results or []:
				if self._cloud_priority_scraper(item) in cloud_first_scrapers: cloud_first.append(item)
				else: cloud_last.append(item)
			if not cloud_first: return results
			return cloud_first + cloud_last
		except:
			return results

	def _apply_cloud_priority_exception(self, personal_results, alkoflix_results):
		# Compatibilité interne : l'ancienne logique séparait les sources personnelles
		# et alkoFlix. La priorité CLOUD est maintenant appliquée globalement plus tard.
		try: return personal_results + alkoflix_results
		except: return (personal_results or []) + (alkoflix_results or [])

	def _cloud_display_filter_exempt(self, item):
		# Les résultats issus du cloud personnel sont déjà validés par le scraper
		# cloud sur le vrai critère métier : titre + année ou titre + saison/épisode.
		# Pour Links/History, AllDebrid retourne souvent des noms très simples
		# (ex. Dexter S04E10.mkv) sans tag FR/MULTI/1080p. Les filtres langue et
		# limites par résolution doivent donc être évités uniquement pour ces résultats.
		try:
			if not isinstance(item, dict): return False
			if item.get('cloud_skip_display_filters'): return True
			if item.get('ad_cloud_section'): return True
		except:
			pass
		return False

	def _apply_display_result_limits(self, results):
		# Limites d'affichage appliquées après filtres, tri et priorité de langue.
		# Elles ne déclenchent aucun appel supplémentaire aux sources ni au débrideur.
		try:
			cloud_exempt = [i for i in results or [] if self._cloud_display_filter_exempt(i)]
			if cloud_exempt:
				regular_results = [i for i in results or [] if not self._cloud_display_filter_exempt(i)]
				filtered_regular = self._apply_display_result_limits_to_regular(regular_results)
				kept_regular = set(id(i) for i in filtered_regular)
				# Fusion stable : on conserve la position relative des résultats cloud dans
				# la liste déjà triée, tout en appliquant les limites aux sources régulières.
				return [
					i for i in results or []
					if self._cloud_display_filter_exempt(i) or id(i) in kept_regular
				]
			return self._apply_display_result_limits_to_regular(results)
		except:
			pass
		return results

	def _apply_display_result_limits_to_regular(self, results):
		try:
			if self.results_exclude_vfq_only:
				results = filter_vfq_only_results(results)
			if self.results_exclude_vostfr_only:
				results = filter_vostfr_only_results(results)
			if self.results_only_fr_vostfr:
				results = filter_french_vostfr_results(results)
			results = limit_results_with_comet_fallback(
				results,
				self.results_max_per_resolution,
				self.results_max_total,
				self.results_comet_fallback,
				self.comet_fallback_threshold
			)
		except:
			pass
		return results

	def _sort_personal_results(self, results):
		# Ordre fixe des sources personnelles : AIOStreams > Custom 1 > Custom 2.
		# La logique est centralisée pour éviter les variantes entre recherche, tri et dédoublonnage.
		return sort_source_personal_results(results)

	def _personal_result_order(self, item):
		return source_personal_result_order(item)

	def _is_personal_result(self, item):
		return source_is_personal_result(item)

	def prepare_internal_scrapers(self):
		if self.active_external and len(self.active_internal_scrapers) == 1: return
		active_internal_scrapers = [i for i in self.active_internal_scrapers if not i in self.remove_scrapers]
		self.active_folders = 'folders' in active_internal_scrapers
		if self.active_folders:
			self.folder_info = self.get_folderscraper_info()
			self.internal_scraper_names = [i for i in active_internal_scrapers if not i == 'folders']
			self.internal_scraper_names += [i[0] for i in self.folder_info]
			self.active_internal_scrapers = active_internal_scrapers
		else:
			self.folder_info = []
			self.internal_scraper_names = active_internal_scrapers[:]
			self.active_internal_scrapers = active_internal_scrapers

	def activate_providers(self, module_type, function, prescrape):
		if module_type == 'external': module = function(*self.external_args)
		elif module_type == 'folders': module = function[0](*function[1])
		else: module = function()
		sources = module.results(self.meta['search_info'])
		if not sources: return
		if prescrape: self.prescrape_sources.extend(sources)
		else: self.sources.extend(sources)

	def activate_external_providers(self):
		self.debrid_enabled = debrid_enabled()
		self.debrid_torrent_enabled = debrid_type_enabled('torrent', self.debrid_enabled)
		self.debrid_hoster_enabled = debrid_valid_hosts(debrid_type_enabled('hoster', self.debrid_enabled))
		if not self.debrid_torrent_enabled and not self.debrid_hoster_enabled:
			self._kill_progress_dialog()
			if ''.join(self.active_internal_scrapers) == 'external': notification(32854)
			self.active_external = False
		else:
#			if not self.debrid_torrent_enabled: self.exclude_list.extend(scraper_names('torrents'))
#			elif not self.debrid_hoster_enabled: self.exclude_list.extend(scraper_names('hosters'))
			external_providers = external_sources(ret_all=self.disabled_ignored)
			if getattr(self, 'pack_reuse_source', None):
				# Important : on ne retire plus C411/Torr9/TR4KER/YGG de la liste.
				# On les garde afin de réafficher les résultats déjà connus depuis le cache local
				# (packs saison/intégrale, hashes déjà trouvés), mais on interdit tout nouvel appel API
				# pour cet épisode si le cache n'a pas encore l'entrée demandée.
				self.meta['private_tracker_cache_only'] = True
				try: kodi_utils.logger('Réutilisation pack', 'Trackers en mode cache local uniquement pour cet épisode')
				except: pass
			else:
				try: self.meta.pop('private_tracker_cache_only', None)
				except: pass

			# Le mode « tous les scrapers externes » du menu contextuel doit vraiment tout lancer.
			# On ne sépare donc pas AIOStreams/Custom des sources intégrées dans ce cas,
			# même si le réglage global « Solution de secours » est activé.
			if self.disabled_ignored:
				self.external_providers = self._prepare_external_provider_list(external_providers)
				self.alkoflix_fallback_external_providers = []
				return

			if self.results_comet_fallback:
				comet_providers = [i for i in external_providers if i[0] == 'comet']
				external_providers = [i for i in external_providers if not i[0] == 'comet']
				self.comet_fallback_external_providers = self._prepare_external_provider_list(comet_providers)
			else:
				self.comet_fallback_external_providers = []

			personal_providers = [i for i in external_providers if i[0] in PERSONAL_EXTERNAL_PROVIDERS]
			alkoflix_providers = [i for i in external_providers if not i[0] in PERSONAL_EXTERNAL_PROVIDERS]

			# Mode « solution de secours » : quand une source personnelle est active,
			# AIOStreams / Custom 1 / Custom 2 cherchent d'abord seules.
			# Les sources alkoFlix intégrées (Comet, Nyaa, etc.)
			# sont conservées pour une seconde passe uniquement si aucun lien personnel
			# n'a été trouvé. Le mode par défaut reste inchangé.
			if self.alkoflix_sources_fallback and personal_providers:
				self.external_providers = self._prepare_external_provider_list(personal_providers)
				self.alkoflix_fallback_external_providers = self._prepare_external_provider_list(alkoflix_providers)
			else:
				self.external_providers = self._prepare_external_provider_list(external_providers)
				self.alkoflix_fallback_external_providers = []

	def _prepare_external_provider_list(self, providers):
		provider_list = [
			i for i in providers if not i[0] in self.exclude_list
			and (i[1].hasEpisodes if self.media_type == 'episode' else i[1].hasMovies)
		]
		if not self.media_type == 'episode': return provider_list
		provider_list = [(i[0], i[1], '') for i in provider_list]
		season_packs, show_packs = pack_enable_check(self.meta, self.season, self.episode)
		if not season_packs: return provider_list
		pack_capable = [i for i in provider_list if i[1].pack_capable]
		if pack_capable:
			provider_list.extend([(i[0], i[1], season_str) for i in pack_capable])
		if pack_capable and show_packs:
			provider_list.extend([(i[0], i[1], show_str) for i in pack_capable])
		return provider_list

	def append_folder_scrapers(self, current_list):
		current_list.extend(internal_folders_import(self.folder_info))

	def get_folderscraper_info(self):
		folder_info = [(get_setting('%s.display_name' % i), i) for i in folder_scrapers]
		return [i for i in folder_info if not i[0] in (None, 'None', '')]

	def scrapers_dialog(self, scrape_type):
		if scrape_type == 'internal':
			scraper_list, _threads, line1_inst, line2_inst = self.providers, self.threads, ls(32096), 'Int:'
		else:
			scraper_list, _threads = self.prescrape_scrapers, self.prescrape_threads,
			line1_inst, line2_inst = '%s %s' % (ls(32829), ls(32830)), 'Pre:'
		self.internal_scrapers = self._get_active_scraper_names(scraper_list)
		if not self.internal_scrapers: return
		int_dialog_hl = 'dodgerblue'
		line1 = total_format % (int_dialog_hl, line1_inst)
		_total_format = total_format % (int_dialog_hl, '%s')
		timeout = self.timeout
		start_time = time.monotonic()
		end_time = start_time + timeout
		progress_bg_started = None if self.progress_dialog else _start_background_progress(ls(32577), 1)
		try:
			while alive_threads := [x.name for x in _threads if x.is_alive() is True]:
				if monitor.abortRequested() or time.monotonic() > end_time: break
				try:
					self._process_internal_results()
					int_totals = [_total_format % v for v in self.internal_resolutions.values()]
					current_progress = time.monotonic() - start_time
					line2 = dialog_format % (int_dialog_hl, line2_inst, *int_totals)
					line3 = remaining_format % ', '.join(alive_threads).upper()
					percent = int((current_progress/float(timeout))*100)
					_update_background_progress(progress_bg_started, percent, line3)
				except: pass
				sleep(self.sleep_time)
		finally:
			_safe_close_background_progress(progress_bg_started)

	def _get_active_scraper_names(self, scraper_list):
		return [i[2] for i in scraper_list]

	def _process_post_results(self):
		if self.ignore_results_filter and self.orig_results: return self._process_ignore_filters()
		return self._no_results()

	def _process_ignore_filters(self):
		if self.autoplay: notification(32686)
		self.autoplay = False
		self.filters_ignored = True
		personal_results, alkoflix_results = self._split_personal_results(self.orig_results)
		personal_results = self._sort_personal_results(personal_results)
		alkoflix_results = self.sort_results(alkoflix_results)
		alkoflix_results = self._sort_first(alkoflix_results)
		results = self._sort_pack_reuse_to_top(personal_results + alkoflix_results)
		return self.play_source(self._apply_global_cloud_priority(results))

	def _no_results(self):
		hide_busy_dialog()
		if self.background: return notification('%s %s' % (nextep_str, nores_str), 5000)
		notification(32760)

	def _process_internal_results(self):
		for i in self.internal_scrapers:
			win_property = get_property('%s.internal_results' % i)
			if win_property in ('checked', '', None): continue
			try: sources = json.loads(win_property)
			except: continue
			set_property('%s.internal_results' % i, 'checked')
			for k in self.internal_resolutions: self.internal_resolutions[k] += sources.get(k, 0)

	def _quality_filter(self):
		setting = 'results_quality_%s' % self.media_type if not self.autoplay else 'autoplay_quality_%s' % self.media_type
		filter_list = quality_filter(setting)
		if self.include_prerelease_results and 'SD' in filter_list: filter_list += ['SCR', 'CAM', 'TELE']
		return filter_list

	def _clear_properties(self):
		for item in default_internal_scrapers: clear_property('%s.internal_results' % item)
		for item in self.get_folderscraper_info(): clear_property('%s.internal_results' % item[0])

	def _make_progress_dialog(self):
		self.progress_dialog = create_window(('windows.sources', 'ProgressMedia'), 'progress_media.xml', meta=self.meta)
		Thread(target=self.progress_dialog.run).start()

	def _kill_progress_dialog(self):
		try: self.progress_dialog.close()
		except: close_all_dialog()
		try: del self.progress_dialog
		except: pass
		self.progress_dialog = None

	def _source_results_window_config(self, window_style=None):
		# Point unique de résolution du sélecteur de sources.
		# Évite de dupliquer le couple style/XML/control ID dans plusieurs endroits.
		style_key = _results_window_style_key(window_style)
		config = dict(SOURCE_RESULTS_WINDOW_CONFIG[style_key])
		config['style_key'] = style_key
		if window_style and _results_window_style_key(window_style) == style_key:
			config['style'] = window_style
		return config

	def _source_results_window_kwargs(self, results, config):
		return dict(
			window_style=config['style'],
			window_id=config['window_id'],
			results=results,
			meta=self.meta,
			scraper_settings=self.scraper_settings,
			prescrape=self.prescrape,
			filters_ignored=self.filters_ignored
		)

	def _open_source_results_window(self, results, window_style):
		config = self._source_results_window_config(window_style)
		chosen_item = open_window(
			('windows.sources', 'SourceResults'),
			config['xml'],
			**self._source_results_window_kwargs(results, config)
		)
		if chosen_item is not None or config['style_key'] == SOURCE_RESULTS_DEFAULT_STYLE:
			return chosen_item
		try: kodi_utils.logger('Sélecteur sources', 'Fallback vers Liste large après échec XML: %s' % config['xml'])
		except: pass
		fallback_config = self._source_results_window_config(SOURCE_RESULTS_WINDOW_CONFIG[SOURCE_RESULTS_DEFAULT_STYLE]['style'])
		return open_window(
			('windows.sources', 'SourceResults'),
			fallback_config['xml'],
			**self._source_results_window_kwargs(results, fallback_config)
		)

	def display_results(self, results):
		chosen_item = self._open_source_results_window(results, results_xml_style())
		if not chosen_item: return self._kill_progress_dialog()
		action, chosen_item = chosen_item
		if action == 'play':
			self._kill_progress_dialog()
			return self.play_file(results, chosen_item)
		if action == 'perform_full_search' and self.prescrape:
			self.prescrape, self.clear_properties = False, False
			return self.playback_prep()

	def play_source(self, results):
		if self.background: self.alkoflix_background_url = self.play_file(results, autoplay=True, background=True)
		elif self.autoplay: return self.play_file(results, autoplay=True)
		else: return self.display_results(results)

	def play_file(self, results, source=None, autoplay=False, background=False):
		def _log_play_attempt(item, count, total, status='Tentative'):
			try:
				kodi_utils.logger('Lecture source', '%s #%s/%s | provider=%s | debrid=%s | cache=%s | qualité=%s | hash=%s | fichier=%s' % (
					status, count, total,
					source_debug_value(item, 'provider'),
					source_debug_value(item, 'debrid'),
					source_debug_value(item, 'cache_provider'),
					source_debug_value(item, 'quality'),
					source_debug_value(item, 'hash'),
					source_debug_file(item)
				))
			except:
				pass

		def _process():
			for count, item in enumerate(items, 1):
				if not background:
					try:
						if monitor.abortRequested(): break
						elif self.progress_dialog and self.progress_dialog.iscanceled(): break
						percent = int(((total_items := len(items))-count)/total_items*100)
						name = clean_file_name_preserve_extension(item.get('name') or item.get('URLName') or '').upper()
						line1 = source_progress_line1(item)
						line2 = ' | '.join(i for i in (item.get('size_label', ''), item.get('extraInfo', '')) if i)
						if self.progress_dialog: self.progress_dialog.update(format_line % (line1, line2, name), percent)
						else: _update_background_progress(progress_bg_started, percent, name)
					except: pass
				_log_play_attempt(item, count, len(items), 'Tentative')
				if 'unrestricted_link' in item:
					link = item['unrestricted_link']
					sleep(500)
				else: link = Source(item, self.meta).resolve_sources()
				if not link is None:
					_log_play_attempt(item, count, len(items), 'Lien résolu')
					self._set_binge_group_property(item)
					yield item, link
				else:
					_log_play_attempt(item, count, len(items), 'Lien ignoré ou non résolu')
		try:
			self._kill_progress_dialog()
			if autoplay:
				items = [i for i in results if not 'Uncached' in i.get('cache_provider', '')]
				items = self._sort_binge_group(items)
				if self.filters_ignored: notification(32686)
			else:
				# Lecture manuelle : le lien choisi reste toujours essayé en premier.
				# Si ce lien est HS/non supporté ou si Kodi ne démarre aucun flux vidéo,
				# on ne doit pas rester bloqué sur « Lecture impossible ».
				# Les autres résultats sont donc essayés ensuite en boucle, à partir de
				# l'élément suivant, puis en revenant au début de la liste si nécessaire.
				source_index = results.index(source) if source in results else 0
				fallback_items = []
				if results:
					fallback_items = results[source_index + 1:] + results[:source_index]
				items = [source]
				seen_ids = set([id(source)])
				for i in fallback_items:
					if id(i) in seen_ids: continue
					if 'Uncached' in i.get('cache_provider', ''): continue
					items.append(i)
					seen_ids.add(id(i))
					if len(items) >= 41: break
			if background: return True if items else None
			progress_bg_started = None
			if self.full_screen:
				self._make_progress_dialog()
				progress_media = self._kill_progress_dialog
			else:
				progress_bg_started = _start_background_progress(ls(32577), 1)
				progress_media = None
			played_attempts = 0
			for item, url in _process():
				played_attempts += 1
				if not self.full_screen: _safe_close_background_progress(progress_bg_started)
				playback_result = AlkoFlixPlayer().run(url, self.meta, progress_media)
				# False signifie que Kodi n'a jamais démarré un flux vidéo exploitable
				# (lien expiré, erreur lecteur immédiate, lien invalide). Dans ce cas,
				# on tente le lien suivant déjà présent dans la liste au lieu de bloquer
				# l'utilisateur sur « Lecture impossible ».
				if playback_result is False:
					try:
						kodi_utils.logger('Lecture source', 'Lecture impossible -> essai du lien suivant | provider=%s | fichier=%s' % (source_debug_value(item, 'provider'), source_debug_file(item)))
					except: pass
					if monitor.abortRequested(): break
					continue
				return playback_result
			if not self.full_screen: _safe_close_background_progress(progress_bg_started)
			self._kill_progress_dialog()
			try: hide_busy_dialog()
			except: pass
			try:
				notification('Lecture impossible : aucun lien lisible' if not played_attempts else 'Lecture impossible : aucun autre lien lisible', 5000)
			except: pass
			return None
		except Exception as e:
			try: hide_busy_dialog()
			except: pass
			try: notification('Erreur pendant la lecture du lien', 5000)
			except: pass
			try: kodi_utils.logger('Lecture source', 'Erreur play_file | %s: %s' % (type(e).__name__, e))
			except: pass

	def _source_binge_group(self, item):
		# Signature volontairement lisible et stable : elle permet à l'épisode suivant
		# de rester sur un lien du même groupe sans casser l'ordre normal des résultats.
		try:
			item_get = item.get
			explicit = item_get('bingeGroup') or item_get('binge_group') or item_get('binge_group_id')
			return {
				'bingeGroup': explicit or '',
				'provider': item_get('provider') or '',
				'provider_name': item_get('provider_name') or '',
				'scrape_provider': item_get('scrape_provider') or '',
				'debrid': item_get('debrid') or '',
				'cache_provider': item_get('cache_provider') or '',
				'source': item_get('source') or '',
				'quality': item_get('quality') or '',
				'language': item_get('language') or '',
				'personal_source': bool(item_get('personal_source')),
				'direct': bool(item_get('direct')),
				'cloud': item_get('scrape_provider') in cloud_scrapers or item_get('scrape_provider') == 'folders'
			}
		except:
			return {}

	def _set_binge_group_property(self, item):
		try:
			if self.media_type != 'episode': return
			group = self._source_binge_group(item)
			if group: set_property('alkoflix_binge_group', json.dumps(group))
		except:
			pass

	def _load_binge_group(self):
		try:
			if isinstance(self.binge_group, dict): return self.binge_group
			if not self.binge_group: return {}
			return json.loads(self.binge_group)
		except:
			return {}

	def _sort_binge_group(self, results):
		# Utilisé uniquement pour l'autoplay Up Next. Si aucun groupe n'est transmis,
		# l'ordre historique reste strictement inchangé.
		try:
			wanted = self._load_binge_group()
			if not wanted: return results

			def clean(value):
				return str(value or '').strip().lower()

			def score(item):
				if item.get('pack_reuse'): return -1
				current = self._source_binge_group(item)
				wanted_explicit = clean(wanted.get('bingeGroup'))
				current_explicit = clean(current.get('bingeGroup'))
				if wanted_explicit and current_explicit and wanted_explicit == current_explicit: return 0
				if all(clean(current.get(k)) == clean(wanted.get(k)) for k in ('provider', 'scrape_provider', 'debrid', 'source', 'quality', 'language')): return 1
				if all(clean(current.get(k)) == clean(wanted.get(k)) for k in ('provider', 'scrape_provider', 'debrid', 'source', 'quality')): return 2
				if all(clean(current.get(k)) == clean(wanted.get(k)) for k in ('provider', 'scrape_provider', 'quality')): return 3
				if clean(current.get('quality')) == clean(wanted.get('quality')) and clean(current.get('language')) == clean(wanted.get('language')): return 4
				return 9

			return sorted(results, key=score)
		except:
			return results

	def filter_results(self, results):
		if self.size_filter == 1:
			duration = self.meta['duration'] or (3600 if self.media_type == 'episode' else 5400)
			max_size = ((0.125 * (0.90 * string_to_float(get_setting('results.size.speed', '20'), '20'))) * duration)/1000
		elif self.size_filter == 2:
			max_size = string_to_float(get_setting('results.size.file', '10000'), '10000') / 1000
		else:
			max_size = None
		return [
			item for item in results
			if source_passes_result_filters(
				item,
				self.quality_filter,
				include_3d=self.include_3D_results,
				size_filter=self.size_filter,
				include_unknown_size=self.include_unknown_size,
				max_size=max_size
			)
		]

	def sort_results(self, results):
		# Ici on applique le tri secondaire fixe et le traitement cache.
		# La priorité principale (Langue ou Qualité) est appliquée ensuite,
		# dans process_results(), pour rester cohérente et prévisible.
		set_source_sort_ranks(results, self.provider_sort_ranks)

		if self.sort_function:
			results.sort(key=self.sort_function)

		results = self._sort_uncached_torrents(results)
		clear_property('fs_filterless_search')
		return results

	def _get_provider_rank(self, account_type):
		return self.provider_sort_ranks.get(account_type, 11)

	def _get_quality_rank(self, quality):
		return source_quality_rank(quality)

	def _sort_french_streaming_priority(self, results):
		# Priorité principale configurable pour les sources alkoFlix intégrées :
		# - Langue : comportement francophone historique.
		# - Qualité : résolution choisie avant la langue, puis FR/MULTI/VOSTFR dans chaque qualité.
		if self.results_sort_priority == 'quality':
			return sort_sources_quality_then_language_priority(results, self.results_priority_quality, self.results_private_trackers_first)
		return sort_sources_french_priority(results, self.results_private_trackers_first)

	def _is_french_multi_result(self, item):
		from modules.source_result_utils import is_french_multi_result
		return is_french_multi_result(item)

	def _is_vostfr_result(self, item):
		from modules.source_result_utils import is_vostfr_result
		return is_vostfr_result(item)

	def _release_text(self, item):
		from modules.source_result_utils import release_text
		return release_text(item)

	def _sort_uncached_torrents(self, results):
		return sort_sources_uncached(
			results,
			display_uncached=self.display_uncached_torrents,
			filterless_search=get_property('fs_filterless_search') == 'true'
		)

	def _special_filter(self, results, key, enable_setting):
		return apply_source_special_filter(
			results,
			key,
			enable_setting,
			autoplay=self.autoplay,
			hybrid_allowed=self.hybrid_allowed,
			hdr_key=hdr_filter_key
		)

	def _sort_first(self, results):
		try:
			sort_first_scrapers = []
			if 'folders' in self.all_scrapers and sort_to_top('folders'): sort_first_scrapers.append('folders')
			sort_first_scrapers.extend([i for i in self.all_scrapers if i in cloud_scrapers and sort_to_top(i)])
			if not sort_first_scrapers: return results
			sort_first = [i for i in results if i.get('scrape_provider') in sort_first_scrapers]
			sort_last = [i for i in results if not i in sort_first]
			results = sort_first + sort_last
		except: pass
		return results

	def _sort_folder_to_top(self, provider):
		if provider == 'folders': return 0
		else: return 1

	nextep_params = []

	@classmethod
	def factory(cls, params):
		if params.get('media_type') == 'episode':
			cls.nextep_callback(params)
			while cls.nextep_params:
				try: cls().playback_prep(cls.nextep_params.pop())
				except: pass
		else: cls().playback_prep(params)

	@classmethod
	def nextep_callback(cls, params):
		if not isinstance(params, dict): return
		cls.nextep_params.insert(0, params)

	@classmethod
	def background_prep(cls, params):
		self = cls()
		self.playback_prep({**params, 'background': 'true'})
		return self.alkoflix_background_url

class Manager:
	def dialog_hook(function):
		def wrapper(instance, *args, **kwargs):
			progress_bg_started = None
			if not instance.background:
				hide_busy_dialog()
				if not instance.progress_dialog and not instance.full_screen:
					progress_bg_started = _start_background_progress(ls(32577), 1)
				else: instance._make_progress_dialog()
			instance.progress_bg_started = progress_bg_started
			try:
				return function(instance, *args, **kwargs)
			finally:
				if not instance.background:
					if not instance.progress_dialog and not instance.full_screen:
						_safe_close_background_progress(progress_bg_started)
					else: instance._kill_progress_dialog()
				try: instance.progress_bg_started = None
				except: pass
		return wrapper

	def __init__(
		self, meta, source_dict, debrid_torrents, debrid_hosters, internal_scrapers,
		prescrape_sources, display_uncached_torrents, progress_dialog, disabled_ignored=False
	):
		self.meta = meta
		self.background = self.meta.get('background', False)
		# Sécurité : les anciennes métadonnées full_screen sont ignorées.
		self.full_screen = False
		self.debrid_torrents, self.debrid_hosters = debrid_torrents, debrid_hosters
		self.source_dict, self.hostDict = source_dict, self.make_host_dict()
		self.internal_scrapers, self.prescrape_sources = internal_scrapers, prescrape_sources
		self.display_uncached_torrents = display_uncached_torrents
		self.disabled_ignored, self.progress_dialog = disabled_ignored, progress_dialog
		self.internal_activated = len(self.internal_scrapers) > 0
		self.internal_prescraped = len(self.prescrape_sources) > 0
		self.processed_prescrape = False
		self.processed_internal_scrapers, self.sources, self.final_sources = [], [], []
		self.processed_internal_scrapers_append = self.processed_internal_scrapers.append
		self.sleep_time = settings.display_sleep_time()
		self.timeout = int(self.meta.get('scrape_timeout', '10')) - 1
		self.int_dialog_highlight = 'dodgerblue'
		self.ext_dialog_highlight = 'magenta'
		self.finish_early = get_setting('search.finish.early')
		self.int_total = total_format % (self.int_dialog_highlight, '%s')
		self.ext_total = total_format % (self.ext_dialog_highlight, '%s')
		self.internal_resolutions = dict.fromkeys(resolutions.split(), 0)
		self.resolutions = dict.fromkeys(resolutions.split(), 0)

	@dialog_hook
	def results(self, info):
		ExternalSource.resolutions, ExternalSource.timeout = self.resolutions, self.timeout
		tpe = TPE(max(1, len(self.source_dict), len(self.debrid_torrents)))
		self.threads = []
		try:
			personal_source_dict = sorted([i for i in self.source_dict if i[0] in PERSONAL_EXTERNAL_PROVIDERS], key=lambda k: PERSONAL_PROVIDER_ORDER.get(str(k[0]).lower(), 99))
			alkoflix_source_dict = [i for i in self.source_dict if not i[0] in PERSONAL_EXTERNAL_PROVIDERS]
			random.shuffle(alkoflix_source_dict)
			# Les sources personnelles sont soumises en premier et gardent la priorité au dédoublonnage.
			# Les sources intégrées peuvent encore être mélangées pour conserver le comportement existant.
			for provider, module, *pack in personal_source_dict + alkoflix_source_dict:
				args = (provider, module, *pack) if pack else (provider, module)
				fut = tpe.submit(ExternalSource(self.meta, args=args).results, info)
				provider_label = _external_progress_label(provider)
				fut.name = pack_display % (provider_label, *pack) if pack and pack[0] else provider_label
				self.threads.append(fut)
			self.wait()
			providers = normalize_source_results([i for fut in self.threads for i in (fut.result() if fut.done() else [])])
			providers = self._mark_personal_manifest_order(providers)
			personal_providers = [i for i in providers if self._is_personal_result(i)]
			personal_providers = self._sort_personal_results(personal_providers)
			alkoflix_providers = [i for i in providers if not self._is_personal_result(i)]
			# Les résultats personnels sont conservés tels quels.
			# Le dédoublonnage sert seulement à éviter que les sources intégrées répètent les mêmes liens.
			self.sources.extend(personal_providers)
			self.sources.extend(self.process_duplicates(alkoflix_providers, personal_providers))
			direct_sources = [i for i in self.sources if is_direct_source_result(i)]
			self.sources = [i for i in self.sources if not is_direct_source_result(i)]
			torrent_sources = [i for i in self.sources if 'hash' in i]
			result_hashes = list({i['hash'] for i in torrent_sources})
			DebridCheck.set_cached_hashes(result_hashes)
			self.threads = []
			for item in self.debrid_torrents:
				fut = tpe.submit(DebridCheck(self.meta, item).cache_check)
				fut.name = item
				self.threads.append(fut)
			self.wait(debrid_check=True)
			for name, hashes in ((fut.name, fut.result() if fut.done() else []) for fut in self.threads):
				status = ('Unchecked %s' if name in ('real-debrid', 'alldebrid') else 'Uncached %s') % name
				def _pack_reuse_cached(item):
					try: return item.get('pack_reuse') and str(item.get('debrid') or '').lower() == str(name or '').lower()
					except: return False
				self.final_sources.extend({**i, 'cache_provider': name, 'debrid': name} for i in torrent_sources if i['hash'] in hashes or _pack_reuse_cached(i))
				self.final_sources.extend({**i, 'cache_provider': status, 'debrid': name} for i in torrent_sources if not i['hash'] in hashes and not _pack_reuse_cached(i))
			self.final_sources = [
				i for i in self.final_sources
				if i.get('personal_source') or not (i['source'] == 'usenet' and 'Unchecked' in i['cache_provider'])
			]
			hoster_sources = [i for i in self.sources if not 'hash' in i]
			result_hosters = list({i['source'].lower() for i in hoster_sources})
			for item in self.debrid_hosters:
				for k, v in item.items():
					valid_hosters = [i for i in result_hosters if i in v]
					self.final_sources.extend([{**i, 'debrid': k} for i in hoster_sources if i['source'] in valid_hosters])
			if direct_sources:
				self.final_sources.extend(direct_sources)
			self.final_sources = self._sort_personal_results(self.final_sources)
		except: notification(32574)
		finally: tpe.shutdown(False)
		return self.final_sources

	def _is_personal_result(self, item):
		return source_is_personal_result(item)

	def _mark_personal_manifest_order(self, results):
		# Marque la position exacte reçue de chaque manifest personnel avant les étapes
		# qui enrichissent les résultats (cache débrideur, séparation direct/torrent, etc.).
		try:
			counters = {}
			for item in results or []:
				if not self._is_personal_result(item): continue
				key = self._personal_result_order(item)
				if item.get('manifest_order') in ('', None) and item.get('_manifest_order') in ('', None):
					item['_manifest_order'] = counters.get(key, 0)
				counters[key] = counters.get(key, 0) + 1
		except:
			pass
		return results

	def _sort_personal_results(self, results):
		# Même logique que SourceSelect : les sources personnelles restent devant
		# sans modifier l'ordre interne retourné par chaque provider.
		return sort_source_personal_results(results)

	def _personal_result_order(self, item):
		return source_personal_result_order(item)

	def wait(self, debrid_check=False):
		if not self.background:
			if self.internal_activated or self.internal_prescraped:
				string3 = int_format % (self.int_dialog_highlight, '%s')
				string4 = ext_format % (self.ext_dialog_highlight, '%s')
			else: string4 = ext_scr_format % (self.ext_dialog_highlight, ls(32118))
			string1, string2 = ls(32579) if debrid_check else ls(32676), ls(32677)
			line1 = line2 = line3 = ''
		len_threads = len(self.threads)
		end_time = time.monotonic() + self.timeout
		while alive_threads := [x.name for x in self.threads if not x.done()]:
			if monitor.abortRequested() or time.monotonic() > end_time: break
			if not self.background:
				try:
					if self.progress_dialog and self.progress_dialog.iscanceled(): break
					ext_totals = [self.ext_total % v for v in self.resolutions.values()]
					len_alive_threads = len(alive_threads)
					progress = int((len_threads-len_alive_threads)/len_threads*100)
					if self.internal_activated or self.internal_prescraped:
						alive_threads.extend(self.process_internal_results())
						int_totals = [self.int_total % v for v in self.internal_resolutions.values()]
						line1 = string3 % diag_format % tuple(int_totals)
						line2 = string4 % diag_format % tuple(ext_totals)
					else:
						line1 = string4
						line2 = diag_format % tuple(ext_totals)
					if len_alive_threads > 5: line3 = string1 % str(len_threads-len_alive_threads)
					else: line3 = string1 % ', '.join(alive_threads).upper()
					if self.progress_dialog: self.progress_dialog.update(format_line % (line1, line2, line3), progress)
					else: _update_background_progress(getattr(self, 'progress_bg_started', None), progress, line3)
					finish_early = debrid_check is False and self.finish_early and len(self.sources) > len_threads // 0.1
					if finish_early: break
				except: pass
			sleep(self.sleep_time)

	def _duplicate_provider_priority(self, item):
		# Le dédoublonnage se fait avant le tri final. Sans priorité ici,
		# un résultat C411 peut disparaître si Comet/YGG/Torr9 a déjà retourné
		# le même hash. On conserve donc d'abord les sources voulues.
		try:
			provider = str((item or {}).get('scrape_provider') or (item or {}).get('provider') or '').lower()
			tracker = str((item or {}).get('tracker') or (item or {}).get('tracker_name') or '').lower()
			source = str((item or {}).get('source') or '').lower()
			priority = {'c411': 0, 'torr9': 1, 'tr4ker': 2, 'ygg': 3, 'alkopaste': 4, 'movix': 5, 'comet': 6, 'free_telecharger': 7, 'nyaa': 8}
			return min(priority.get(provider, 50), priority.get(tracker, 50), priority.get(source, 50))
		except:
			return 50

	def _dedupe_sort_sources(self, sources):
		try:
			return sorted(sources or [], key=self._duplicate_provider_priority)
		except:
			return sources or []

	def process_duplicates(self, sources, existing_sources=None):
		uniqueURLs, uniqueHashes = set(), set()
		for provider in existing_sources or []:
			try:
				uniqueURLs.add(provider['url'].lower())
				if 'hash' in provider: uniqueHashes.add(provider['hash'].lower())
			except: pass
		for provider in self._dedupe_sort_sources(sources):
			try:
				url = provider['url'].lower()
				if url in uniqueURLs: continue
				uniqueURLs.add(url)
				if 'hash' in provider:
					_hash = provider['hash'].lower()
					if _hash in uniqueHashes: continue
					uniqueHashes.add(_hash)
					yield provider
				else: yield provider
			except: yield provider

	def process_internal_results(self):
		def _process_quality_count(sources):
			for k in self.internal_resolutions: self.internal_resolutions[k] += sources.get(k, 0)
		if self.internal_prescraped and not self.processed_prescrape:
			_process_quality_count(sources_quality_count(self.prescrape_sources))
			self.processed_prescrape = True
		for i in self.internal_scrapers:
			win_property = get_property('%s.internal_results' % i)
			if win_property in ('checked', '', None): continue
			try: internal_sources = json.loads(win_property)
			except: continue
			set_property('%s.internal_results' % i, 'checked')
			self.processed_internal_scrapers_append(i)
			_process_quality_count(internal_sources)
		return [i for i in self.internal_scrapers if not i in self.processed_internal_scrapers]

	def make_host_dict(self):
		pr_list = []
		pr_list_extend = pr_list.extend
		for item in self.debrid_hosters:
			for k, v in item.items(): pr_list_extend(v)
		return list(set(pr_list))

	def _make_progress_dialog(self):
		if self.progress_dialog: return
		self.progress_dialog = create_window(('windows.sources', 'ProgressMedia'), 'progress_media.xml', meta=self.meta)
		Thread(target=self.progress_dialog.run).start()

	def _kill_progress_dialog(self):
		try: self.progress_dialog.close()
		except: pass
		try: del self.progress_dialog
		except: pass
		self.progress_dialog = None
