from threading import Thread
import re
from debrids.alldebrid_api import AllDebridAPI as Debrid
from modules import source_utils
from alko import source_utils as alko_source_utils, cleantitle as alko_cleantitle
from modules.source_objects import clean_file_name_preserve_extension
from modules.utils import clean_file_name, normalize
from modules.settings import enabled_debrids_check, filter_by_name
from modules.source_objects import _is_blocked_file_source
from urllib.parse import unquote_plus, urlparse
# from modules.kodi_utils import logger

internal_results, check_title, clean_title = source_utils.internal_results, source_utils.check_title, source_utils.clean_title
get_file_info, release_info_format, seas_ep_filter = source_utils.get_file_info, source_utils.release_info_format, source_utils.seas_ep_filter
extensions, extras_filter = source_utils.supported_video_extensions(), source_utils.extras_filter()

class source(Debrid):
	scrape_provider = 'ad_cloud'
	def results(self, info):
		try:
			self.sources = []
			sources_append = self.sources.append
			if not enabled_debrids_check('ad'): return internal_results(self.scrape_provider, self.sources)
			self.scrape_results = []
			title_filter = filter_by_name(self.scrape_provider)
			self.media_type = info.get('media_type') or info.get('mediatype')
			raw_title = info.get('title') or ''
			# Pour les épisodes, info['title'] représente souvent le titre de
			# l'épisode (ex. « Les innocents »), pas le titre de la série. Les
			# scrapers de sources utilisent déjà tvshowtitle; le scraper cloud doit
			# faire pareil, sinon les liens simples dans Links/History comme
			# « Dexter S04E10 ... » ou « Dead zone - 4x12 ... » sont comparés au
			# mauvais titre et passent seulement par hasard via les magnets/packs.
			self.episode_title = raw_title if self.media_type == 'episode' else ''
			title = info.get('tvshowtitle') if self.media_type == 'episode' and info.get('tvshowtitle') else raw_title
			self.title = title
			try: self.year = int(info.get('year') or 0)
			except: self.year = 0
			self.season, self.episode = info.get('season'), info.get('episode')
			if self.media_type == 'episode':
				self.season, self.episode = int(self.season), int(self.episode)
				self.seas_ep_query_list = source_utils.seas_ep_query_list(self.season, self.episode)
			self.folder_query, self.year_query_list = clean_title(normalize(title)), tuple(map(str, range(self.year - 1, self.year + 2)))
			self._scrape_cloud()
			if not self.scrape_results: return internal_results(self.scrape_provider, self.sources)
			self.aliases = source_utils.get_aliases_titles(info.get('aliases', []))
			self.cloud_title_queries = self._cloud_title_queries(title, self.aliases)
			# Filet de sécurité uniquement cloud : si une entrée Links/History ne
			# contient pas le titre de série mais contient le titre d'épisode exact,
			# elle peut quand même être reconnue en combinaison avec SxxEyy/4x12.
			self.cloud_episode_title_queries = self._cloud_title_queries(self.episode_title, []) if getattr(self, 'episode_title', '') else tuple()
			extras_filtering_list = tuple(i for i in extras_filter if not i in title.lower())
			for item in self.scrape_results:
				try:
					if _is_blocked_file_source(item): continue
					filename_value = item.get('filename') or ''
					filename_has_video_ext = filename_value.lower().endswith(tuple(extensions))
					is_cloud_link_entry = item.get('ad_cloud_section') in ('links', 'history')
					# AllDebrid Cloud n'est pas une recherche web classique : ce sont déjà
					# des fichiers/liens présents chez l'utilisateur. On garde le blocage
					# des fichiers dangereux, mais on ne rejette plus aveuglément un résultat
					# uniquement parce qu'il manque une extension lisible. Plusieurs entrées
					# AD (links/history, packs, certains fichiers internes de magnets) ont un
					# nom exploitable sans finir proprement par .mkv/.mp4/.avi. Le vrai tri
					# se fait ensuite par titre + saison/épisode.
					if not filename_has_video_ext and not is_cloud_link_entry and not self._cloud_allow_extensionless_item(item, filename_value): continue
					formalized = normalize(item.get('folder_name') or '')
					foldername = clean_title(formalized)
					normalized = normalize(filename_value)
					candidate_text = self._cloud_candidate_text(item, normalized, formalized)
					filename = clean_title(normalized)

					# AllDebrid Cloud doit être traité comme un inventaire personnel :
					# on reconnaît d'abord le média par titre + année ou titre + saison/épisode,
					# puis seulement ensuite on prépare la ligne du sélecteur. Les anciens tests
					# trop génériques rataient des cas évidents comme Dexter.S04, Dead zone - S04
					# ou 3x.Rien.S04E20.
					if not self._cloud_item_matches(item, candidate_text, normalized, formalized, filename, foldername, extras_filtering_list, is_cloud_link_entry): continue

					# On ne réapplique pas check_title ici : il est trop strict pour
					# certains titres cloud courts ou typographiés différemment
					# (ex. « 3 x rien » vs « 3x.Rien.S04E20 »). Le couple
					# _cloud_item_matches + saison/épisode/année garde le filtre précis
					# sans rejeter ces cas valides.
					direct_debrid_link, URLName = item.get('downloads', False), clean_file_name_preserve_extension(normalized)
					file_dl, size = item.get('link_dl') if direct_debrid_link else item.get('link'), round(float(int(item.get('size') or 0))/1073741824, 2)
					if not file_dl: continue
					# Les fichiers issus d'un magnet peuvent porter seulement le titre/épisode,
					# tandis que la langue et la qualité sont souvent dans le nom du pack parent
					# (ex. Dexter.S04.Multi.BluRay... + Dexter S04E01.mkv).
					# Les filtres d'affichage FR/MULTI/VOSTFR consultent name_info/name/URLName :
					# si on ne transmet que le nom du fichier interne, les résultats cloud valides
					# disparaissent avec les filtres normaux et ne reviennent que lorsque l'utilisateur
					# ignore les filtres. On construit donc l'info de release avec fichier + dossier.
					release_source = self._cloud_release_source(item, normalized, formalized, candidate_text)
					release_info = release_info_format(release_source)
					video_quality, details = get_file_info(name_info=release_info)
					language = self._cloud_release_language(release_info)
					sources_append({
						'source': self.scrape_provider, 'direct': True, 'direct_debrid_link': direct_debrid_link,
						'scrape_provider': self.scrape_provider, 'id': file_dl, 'url_dl': file_dl, 'name': normalized, 'title': normalized,
						'URLName': URLName, 'name_info': release_info, 'folder_name': formalized,
						'extraInfo': details, 'quality': video_quality, 'language': language, 'size': size, 'size_label': '%.2f GB' % size,
						'ad_cloud_section': item.get('ad_cloud_section'), 'ad_requires_unlock': item.get('ad_requires_unlock', False),
						'ad_direct_link': item.get('ad_direct_link', False),
						# Les liens cloud sont personnels et déjà préfiltrés par titre/saison/épisode.
						# On évite donc qu'ils disparaissent ensuite à cause des filtres techniques
						# généraux (qualité/taille) et des filtres d'affichage langue/résolution.
						# Links/History contiennent souvent seulement un nom de fichier simple, sans
						# tag FR/MULTI/1080p; seul le dossier Magnets conserve parfois ces infos.
						'bypass_filter': True, 'cloud_skip_display_filters': True
					})
				except: pass
		except Exception as e:
			from modules.kodi_utils import logger
			logger(f"alkoFlix {self.scrape_provider} Exception", e)
		internal_results(self.scrape_provider, self.sources)
		return self.sources

	def _cloud_release_source(self, item, filename='', foldername='', candidate_text=''):
		try:
			parts = []
			for value in (filename, foldername):
				value = normalize(str(value or '')).strip()
				if value and value not in parts: parts.append(value)
			# Certaines réponses AllDebrid contiennent des métadonnées utiles sous
			# plusieurs clés. On les ajoute uniquement comme texte d'analyse, sans
			# modifier le nom affiché dans le sélecteur.
			for key in ('name', 'title', 'file', 'filename', 'folder_name'):
				value = normalize(str(item.get(key) or '')).strip()
				if value and value not in parts: parts.append(value)
			# Évite d'injecter une URL entière dans name_info, mais garde le nom de
			# chemin s'il est exploitable.
			for key in ('link', 'link_dl', 'url', 'download'):
				value = str(item.get(key) or '').strip()
				if not value: continue
				try:
					path_name = unquote_plus(urlparse(value).path.rsplit('/', 1)[-1])
					path_name = normalize(path_name).strip()
					if path_name and path_name not in parts: parts.append(path_name)
				except: pass
			return ' '.join(parts) or normalize(str(candidate_text or filename or foldername or ''))
		except:
			return normalize(str(filename or foldername or candidate_text or ''))

	def _cloud_release_language(self, release_info):
		try:
			text = ' %s ' % normalize(str(release_info or '')).lower()
			# Reconnaissance locale au scraper cloud seulement : ne change pas les
			# règles globales des sources normales. VOQ/VOF/VF/VFF/VFQ/VF2/FR et
			# les packs MULTI du cloud personnel doivent passer les filtres FR.
			if re.search(r'(^|[^a-z0-9])(voq|vof|vf|vff|vfq|vf2|vfi|fr|french|truefrench|multi)([^a-z0-9]|$)', text, re.I):
				return 'fr'
		except:
			pass
		try: return alko_source_utils.release_language(release_info)
		except: return 'en'


	def _cloud_item_matches(self, item, candidate_text, normalized, formalized, filename, foldername, extras_filtering_list, is_cloud_link_entry=False):
		try:
			if self.media_type == 'movie':
				if any(x in filename for x in extras_filtering_list): return False
				if not self._cloud_title_match(candidate_text, filename, foldername): return False
				# Pour les films, on garde l'année quand elle est visible, mais on accepte les
				# liens personnels AD Links/History sans année si le titre est très clair.
				if any(x in filename for x in self.year_query_list): return True
				if is_cloud_link_entry or item.get('ad_direct_link'): return True
				return bool(any(x in clean_title(normalize(formalized or '')) for x in self.year_query_list))

			# Séries : les sections Links/History d'AllDebrid contiennent souvent des
			# fichiers individuels déjà résolus avec un nom très simple, par exemple
			# "Dead zone - 4x12 - Le cadeau.avi". Ces entrées ne doivent pas passer
			# par une logique pensée pour les releases torrent complètes. On applique
			# d'abord un match direct titre utile + épisode exact, strictement limité
			# aux liens cloud déjà présents chez l'utilisateur.
			if is_cloud_link_entry and self._cloud_link_history_episode_match(candidate_text, normalized, formalized): return True

			# Séries : matcher le titre dans le nom du fichier OU le nom du pack, puis
			# matcher l'épisode dans le fichier, ou dans fichier+pack pour les packs saison.
			if not self._cloud_title_match(candidate_text, filename, foldername): return False
			return self._cloud_episode_match(candidate_text, normalized, formalized)
		except:
			return False

	def _cloud_text(self, value):
		try: return normalize(unquote_plus(str(value or ''))).lower()
		except: return str(value or '').lower()

	def _cloud_compact(self, value):
		try: return re.sub(r'[^a-z0-9]+', '', self._cloud_text(value))
		except: return ''

	def _cloud_series_title_part(self, value):
		try:
			text = self._cloud_dot_text(value)
			if not text: return ''
			# Coupe avant les marqueurs série/film habituels pour isoler le titre.
			patterns = (
				r'(.+?)(?:^|[.])s\d{1,2}[.]?e[p]?[.]?\d{1,4}(?:[.]|$)',
				r'(.+?)(?:^|[.])s\d{1,2}[.]?episode[.]?\d{1,4}(?:[.]|$)',
				r'(.+?)(?:^|[.])\d{1,2}x\d{1,4}(?:[.]|$)',
				r'(.+?)(?:^|[.])s\d{1,2}[.]\d{1,4}(?:[.]|$)',
				r'(.+?)(?:^|[.])season[.]?\d{1,2}[.]?episode[.]?\d{1,4}(?:[.]|$)',
				r'(.+?)(?:^|[.])saison[.]?\d{1,2}[.]?episode[.]?\d{1,4}(?:[.]|$)',
				r'(.+?)(?:^|[.])s\d{1,2}(?:[.]|$)',
				r'(.+?)(?:^|[.])season[.]?\d{1,2}(?:[.]|$)',
				r'(.+?)(?:^|[.])saison[.]?\d{1,2}(?:[.]|$)'
			)
			for pattern in patterns:
				match = re.search(pattern, text, re.I)
				if match: return match.group(1).strip('.')
			return text
		except: return ''

	def _cloud_episode_pairs(self, value):
		pairs = []
		try:
			text = self._cloud_dot_text(value)
			if not text: return pairs
			# Motifs volontairement larges, mais toujours utilisés avec titre + saison/épisode
			# exacts. Inspiré de l'ancien outillage de maintenance PasteS : S04E10,
			# S04.E10, S04 EP10, 4x10, 4.10, S04-10, Saison 4 épisode 10,
			# Season 4 Episode 10, [S4.EP10]. On garde ici la reconnaissance
			# strictement dans le scraper cloud AllDebrid.
			patterns = (
				r'(?:^|[.])s0*(\d{1,2})[.]?e[p]?[.]?0*(\d{1,4})(?:[.]|$)',
				r'(?:^|[.])s0*(\d{1,2})[.]?episode[.]?0*(\d{1,4})(?:[.]|$)',
				r'(?:^|[.])0*(\d{1,2})[x]0*(\d{1,4})(?:[.]|$)',
				r'(?:^|[.])0*(\d{1,2})[.]0*(\d{1,4})(?:[.]|$)',
				r'(?:^|[.])s0*(\d{1,2})[.]0*(\d{1,4})(?:[.]|$)',
				r'(?:^|[.])season[.]?0*(\d{1,2})[.]?episode[.]?0*(\d{1,4})(?:[.]|$)',
				r'(?:^|[.])saison[.]?0*(\d{1,2})[.]?episode[.]?0*(\d{1,4})(?:[.]|$)'
			)
			for pattern in patterns:
				for match in re.finditer(pattern, text, re.I):
					try:
						season, episode = int(match.group(1)), int(match.group(2))
						# Évite les faux positifs absurdes de type résolution ou année.
						if 0 < season <= 99 and 0 < episode <= 9999:
							pairs.append((season, episode))
					except: pass
		except: pass
		return pairs

	def _cloud_exact_episode_in_value(self, value):
		try:
			season, episode = int(self.season), int(self.episode)
			return any(s == season and e == episode for s, e in self._cloud_episode_pairs(value))
		except: return False

	def _cloud_stop_words(self):
		return set(('the', 'a', 'an', 'le', 'la', 'les', 'l', 'un', 'une', 'des', 'du', 'de', 'd', 'el', 'los', 'las'))

	def _cloud_title_variants(self, value):
		try:
			base = normalize(str(value or '')).strip()
			if not base: return []
			variants = []

			def _add_variant(raw):
				try:
					raw = normalize(str(raw or '')).strip(' .:-–—_')
					if not raw: return
					if raw.lower() not in [i.lower() for i in variants]: variants.append(raw)
					# Les titres TMDb et les noms de release peuvent perdre l'article
					# (The Dead Zone -> Dead Zone, The Revenge -> Revenge).
					without_article = re.sub(r'^(the|a|an|le|la|les|l|un|une|des|du|de|d|el|los|las)[\s\'’.-]+', '', raw, flags=re.I).strip(' .:-–—_')
					if without_article and without_article.lower() not in [i.lower() for i in variants]: variants.append(without_article)
				except: pass

			_add_variant(base)
			# Certains historiques AllDebrid ne gardent que le sous-titre ou une
			# portion du titre de film. Exemple réel : TMDb = "Dhurandhar : The
			# Revenge", historique AD = "Revenge.2026...". Le scraper cloud doit
			# donc tester les deux côtés des séparateurs, mais seulement dans ce
			# fichier cloud afin de ne pas assouplir les sources normales.
			for separator in (':', ' - ', ' – ', ' — '):
				if separator in base:
					for part in base.split(separator): _add_variant(part)
			# Cas avec parenthèses/crochets, parfois utilisés pour le titre original
			# ou le sous-titre.
			for part in re.findall(r'[\(\[]([^\)\]]+)[\)\]]', base): _add_variant(part)
			return variants or [base]
		except: return [value]

	def _cloud_title_queries(self, title, aliases=None):
		def _add(value):
			try:
				for variant in self._cloud_title_variants(value):
					variant = normalize(str(variant or ''))
					for compact in (clean_title(variant), alko_cleantitle.get(variant), re.sub(r'[^a-z0-9]+', '', variant.lower())):
						if compact and compact not in queries: queries.append(compact)
			except: pass
		try: items = [title] + list(aliases or [])
		except: items = [title]
		queries = []
		for item in items: _add(item)
		return tuple(queries or (self.folder_query,))

	def _cloud_candidate_text(self, item, filename='', foldername=''):
		try:
			parts = [filename, foldername]
			for key in ('name', 'title', 'file', 'link', 'link_dl', 'url', 'download'):
				value = item.get(key)
				if value: parts.append(str(value))
			return ' '.join(i for i in parts if i)
		except:
			return '%s %s' % (filename or '', foldername or '')

	def _cloud_word_tokens(self, value):
		try:
			value = normalize(str(value or '')).lower()
			return [i for i in re.split(r'[^a-z0-9]+', value) if i and len(i) > 1]
		except:
			return []

	def _cloud_release_title(self, value):
		try: return alko_source_utils.release_title_format(normalize(str(value or '')))
		except:
			try: return release_info_format(normalize(str(value or '')))
			except: return str(value or '').lower()

	def _cloud_episode_handle(self, value):
		try: return seas_ep_filter(int(self.season), int(self.episode), value, return_match=True)
		except: pass
		try:
			season = int(self.season); episode = int(self.episode)
			text = self._cloud_release_title(value)
			patterns = (
				r's0*%d[.-]?e[p]?[.-]?0*%d[.-]' % (season, episode),
				r'[.-]0*%d[x.]0*%d[.-]' % (season, episode),
				r'season[.-]?0*%d[.-]?episode[.-]?0*%d[.-]' % (season, episode),
			)
			for pattern in patterns:
				match = re.search(pattern, text, re.I)
				if match: return match.group()
		except: pass
		return ''


	def _cloud_title_match(self, candidate_text, filename='', foldername=''):
		try:
			candidate = '%s %s %s' % (candidate_text or '', filename or '', foldername or '')
			compact_candidate = self._cloud_compact(candidate)
			if not compact_candidate: return False

			# Cas directs : titre compact ou alias compact présent dans le nom du fichier,
			# le nom du dossier/pack, l'URL ou les métadonnées AllDebrid.
			for query in getattr(self, 'cloud_title_queries', ()):
				if query and query in compact_candidate: return True

			# Cas des séries : on isole la partie titre avant SxxEyy/Sxx pour éviter que
			# les tags qualité/langue viennent perturber la comparaison.
			if self.media_type == 'episode':
				for value in (filename, foldername, candidate_text):
					part = self._cloud_series_title_part(value)
					compact_part = self._cloud_compact(part)
					if compact_part and any(query and (query in compact_part or compact_part in query) for query in getattr(self, 'cloud_title_queries', ())): return True

			# Dernier filet : comparaison par mots utiles, en retirant les articles.
			candidate_tokens = set(self._cloud_word_tokens(candidate))
			stop_words = self._cloud_stop_words()
			for raw_title in [self.title] + list(getattr(self, 'aliases', []) or []):
				for variant in self._cloud_title_variants(raw_title):
					tokens = [t for t in self._cloud_word_tokens(variant) if not t.isdigit() and t not in stop_words]
					if tokens and all(t in candidate_tokens or t in compact_candidate for t in tokens): return True
		except: pass
		return False

	def _cloud_allow_extensionless_item(self, item, filename=''):
		try:
			name = normalize(str(filename or item.get('filename') or '')).lower()
			if not name: return False
			# On garde les fichiers clairement non vidéo hors du sélecteur.
			if re.search(r'\.(srt|ass|ssa|sub|idx|nfo|txt|jpg|jpeg|png|gif|exe|msi|apk|zip|rar|7z)$', name, re.I): return False
			if not (item.get('link') or item.get('link_dl') or item.get('url') or item.get('download')): return False
			try: size = int(item.get('size') or item.get('s') or 0)
			except: size = 0
			# Un fichier sans extension mais assez volumineux dans le cloud AD est
			# plus probablement une vidéo qu'un sous-titre ou un fichier annexe.
			return size >= 50 * 1024 * 1024
		except: return False

	def _cloud_dot_text(self, value):
		try: return re.sub(r'[^a-z0-9]+', '.', normalize(unquote_plus(str(value or ''))).lower()).strip('.')
		except: return ''

	def _cloud_season_match(self, value):
		try:
			season = int(self.season)
			text = self._cloud_dot_text(value)
			if not text: return False
			patterns = (
				r'(^|[.])s0*%d([.]|$)' % season,
				r'(^|[.])season[.]?0*%d([.]|$)' % season,
				r'(^|[.])saison[.]?0*%d([.]|$)' % season,
			)
			return any(re.search(pattern, text, re.I) for pattern in patterns)
		except: return False

	def _cloud_episode_number_match(self, value):
		try:
			episode = int(self.episode)
			text = self._cloud_dot_text(value)
			if not text: return False
			patterns = (
				r'(^|[.])e[p]?[.]?0*%d([.]|$)' % episode,
				r'(^|[.])episode[.]?0*%d([.]|$)' % episode,
				r'(^|[.])0*%d([.]|$)' % episode,
			)
			return any(re.search(pattern, text, re.I) for pattern in patterns)
		except: return False


	def _cloud_episode_match(self, release_title, filename='', foldername=''):
		try:
			# Match direct dans le nom du fichier : Dexter S04E01, 4x01, etc.
			if self._cloud_exact_episode_in_value(filename): return True
			if self._cloud_exact_episode_in_value(release_title): return True

			# Packs saison : le dossier/pack porte la saison, le fichier porte l'épisode.
			# Exemples : Dexter.S04... + Dexter S04E01..., ou Dead zone - S04 - FR - XviD
			# + fichier interne Episode 01 / 01.avi.
			if self._cloud_season_match(foldername or release_title) and self._cloud_episode_number_match(filename or release_title): return True

			# Compatibilité avec l'ancien filtre alkoFlix.
			try:
				if seas_ep_filter(int(self.season), int(self.episode), release_title): return True
			except: pass
		except: pass
		return False


	def _cloud_has_target_episode_marker(self, value):
		try:
			season, episode = int(self.season), int(self.episode)
			if any(s == season and e == episode for s, e in self._cloud_episode_pairs(value)):
				return True
		except:
			return False
		return False

	def _cloud_useful_title_token_match(self, value):
		try:
			candidate_tokens = set(self._cloud_word_tokens(value))
			compact_candidate = self._cloud_compact(value)
			if not candidate_tokens and not compact_candidate: return False
			stop_words = self._cloud_stop_words()
			for raw_title in [self.title] + list(getattr(self, 'aliases', []) or []):
				for variant in self._cloud_title_variants(raw_title):
					tokens = [t for t in self._cloud_word_tokens(variant) if not t.isdigit() and t not in stop_words]
					if not tokens: continue
					compact_variant = re.sub(r'[^a-z0-9]+', '', normalize(str(variant or '')).lower())
					if compact_variant and compact_variant in compact_candidate: return True
					# Pour Links/History, un titre simple comme "Dead Zone" doit matcher
					# "Dead zone - 4x12 - Le cadeau.avi" sans dépendre des tags release.
					if len(tokens) == 1:
						if len(tokens[0]) >= 4 and tokens[0] in candidate_tokens: return True
					elif all(t in candidate_tokens or t in compact_candidate for t in tokens):
						return True
		except:
			pass
		return False

	def _cloud_link_history_episode_match(self, candidate_text='', filename='', foldername=''):
		try:
			text = ' '.join(str(i or '') for i in (candidate_text, filename, foldername))
			# Entrées Links/History : épisode exact obligatoire, sinon on risque de
			# proposer un mauvais épisode d'une même série.
			if not self._cloud_has_target_episode_marker(text): return False
			if self._cloud_useful_title_token_match(text): return True
			# Fallback strict : titre d'épisode exact + marqueur SxxEyy/4x12 déjà validé.
			# Utile pour certains liens AD sauvegardés qui ne gardent pas le nom de la
			# série, mais ne s'applique pas aux sources externes/intégrées.
			compact_text = self._cloud_compact(text)
			for query in getattr(self, 'cloud_episode_title_queries', ()):
				if query and query in compact_text: return True
		except:
			return False
		return False


	def _scrape_cloud(self):
		try:
			results_append = self.scrape_results.append
			threads = []
			append = threads.append
			cloud_files = self.user_cloud()
			for item in cloud_files.get('magnets', []):
				if _is_blocked_file_source({'filename': item.get('filename')}): continue
				# Ne pas bloquer un magnet uniquement parce que le nom du dossier
				# parent ne contient pas exactement le titre. Dans AllDebrid, les packs
				# peuvent avoir un nom de dossier générique, tandis que les vrais noms
				# d'épisodes sont dans les fichiers internes. Le filtrage précis se fait
				# après flatten_magnet_files(), sur chaque fichier.
				append(i := Thread(target=self._scrape_folders, args=(item,)))
				i.start()
			# La recherche cloud AD doit couvrir les trois sections visibles dans l'arborescence AllDebrid :
			# Magnets, Liens sauvegardés (/user/links) et Historique (/user/history).
			# Les deux appels liens/historique sont déjà mis en cache côté API wrapper,
			# ce qui évite de relancer inutilement AllDebrid à chaque recherche rapprochée.
			self._scrape_saved_links()
			self._scrape_downloads()
			[i.join() for i in threads]
		except: pass


	def _scrape_folders(self, folder_info):
		try:
			results_append = self.scrape_results.append
			folder = self.torrent_info(folder_info['id'])
			files = folder.get('files') or []
			folder_links = self.flatten_magnet_files(files)
			parent_name = folder_info.get('filename') or folder.get('filename') or folder.get('name') or ''
			for item in folder_links:
				file_name = item.get('n') or item.get('name') or item.get('filename') or ''
				if _is_blocked_file_source({'filename': file_name, 'folder_name': parent_name}): continue
				try: item.update({
					'filename': file_name, 'folder_name': parent_name,
					'size': item.get('s') or item.get('size') or 0,
					'link': self.normalize_download_link(item.get('l') or item.get('link') or item.get('download') or ''),
					'ad_cloud_section': 'magnets'
				})
				except: pass
				else: results_append(item)
		except: pass

	def _extract_link_items(self, result):
		try:
			if isinstance(result, list): return result
			if isinstance(result, dict):
				for key in ('links', 'history', 'downloads', 'items'):
					value = result.get(key)
					if isinstance(value, list): return value
				data = result.get('data')
				if isinstance(data, list): return data
				if isinstance(data, dict):
					for key in ('links', 'history', 'downloads', 'items'):
						value = data.get(key)
						if isinstance(value, list): return value
		except: pass
		return []

	def _append_link_results(self, items, saved=False):
		# /user/links retourne souvent des pages AllDebrid /f/... : elles doivent être
		# normalisées puis déverrouillées par /link/unlock avant lecture Kodi.
		# /user/history peut déjà contenir des liens lisibles, mais on garde le même
		# filet de sécurité dans modules.debrid pour éviter d'envoyer une page HTML au lecteur.
		try:
			results_append = self.scrape_results.append
			for item in items or []:
				try:
					filename = item.get('filename') or item.get('name') or item.get('file') or item.get('title') or ''
					link = item.get('link') or item.get('link_dl') or item.get('url') or item.get('download') or ''
					link = self.normalize_download_link(link)
					if not filename:
						try: filename = unquote_plus(urlparse(link).path.rsplit('/', 1)[-1])
						except: filename = link
					if not filename or not link: continue
					requires_unlock = saved or self.is_alldebrid_file_link(link)
					item.update({
						'filename': filename, 'folder_name': filename,
						'link': link, 'link_dl': link,
						'downloads': not requires_unlock, 'saved_links': saved,
						'ad_direct_link': not requires_unlock, 'ad_requires_unlock': requires_unlock,
						'ad_cloud_section': 'links' if saved else 'history'
					})
				except: pass
				else: results_append(item)
		except: pass

	def _scrape_saved_links(self):
		try:
			cloud_links = self.saved_links()
			self._append_link_results(self._extract_link_items(cloud_links), saved=True)
		except: pass

	def _scrape_downloads(self):
		try:
			cloud_downloads = self.downloads()
			self._append_link_results(self._extract_link_items(cloud_downloads), saved=False)
		except: pass

