"""Dona YouTube: public video search (Hermes web or optional Data API), validated IDs. No personal Google sign-in, YouTube credentials or video downloads. Each search uses an independent Hermes session and never touches the persistent chat/ComfyUI context. """ from __future__ import annotations import json import os import re import subprocess from urllib.parse import parse_qs, urlsplit, urlencode from urllib.request import Request, urlopen from flask import Blueprint, request, jsonify VIDEO_ID = re.compile(r'^[A-Za-z0-9_-]{11}$') SEARCH_RE = re.compile(r'\b(?:youtube|you\s*tube|vid[eé]os?|tuto(?:riel)?s?)\b', re.I) COMMAND_RE = re.compile(r'\b(?:pause|mets? en pause|arr[eê]te|reprends?|continue|relance|lecture|joue|avance|recule|retourne en arri[eè]re|plein [eé]cran|volume|son|ferme|quitte)\b', re.I) EXPLICIT_RE = re.compile(r'\b(?:youtube|vid[eé]o|lecteur)\b', re.I) STOPWORDS = re.compile(r'^(?:(?:dona|alfred)[,\s]+)?(?:montre(?:[- ]moi)?|cherche(?:[- ]moi)?|trouve(?:[- ]moi)?|affiche(?:[- ]moi)?|lance|ouvre|regarde|cherche)\s+',re.I) MAX_QUERY = 200 def is_search(text: str) -> bool: q=str(text or '').strip() if len(q)>500 or not SEARCH_RE.search(q):return False # A video control must never launch a new search. if is_control(q, player_active=True):return False return bool(re.search(r'\b(?:montre|cherche|trouve|affiche|lance|ouvre|regarde|voir|recherche|vid[eé]o|youtube|tutoriel)\b',q,re.I)) def is_control(text: str, player_active=False) -> bool: q=str(text or '').strip() if not q or len(q)>120 or not COMMAND_RE.search(q):return False if not EXPLICIT_RE.search(q) and not player_active:return False # Don't hijack Spotify/music/home audio controls. if re.search(r'\b(?:spotify|musique|morceau|playlist)\b',q,re.I):return False return action_for(q) is not None def action_for(text: str): q=str(text or '').casefold() if re.search(r'\b(?:recule|retourne en arri[eè]re)\b',q):return {'action':'seek','seconds':-30} if re.search(r'\b(?:avance|saute)\b',q): num=re.search(r'\b(\d{1,3})\s*(?:s|secondes?)\b',q) return {'action':'seek','seconds':min(int(num.group(1)),300) if num else 30} if re.search(r'\b(?:pause|arr[eê]te)\b',q):return {'action':'pause'} if re.search(r'\b(?:reprends?|continue|relance|lecture|joue)\b',q):return {'action':'play'} if re.search(r'\bplein [eé]cran\b',q):return {'action':'fullscreen'} if re.search(r'\b(?:ferme|quitte)\b',q):return {'action':'close'} return None def extract_query(text: str) -> str: q=str(text or '').strip() q=re.sub(r'^(?:(?:dona|alfred)[,\s]+)', '',q,flags=re.I) q=STOPWORDS.sub('',q) q=re.sub(r'^\s*(?:une?|des|la|les)\s+vid[eé]os?\s*(?:youtube)?\s*(?:sur|pour|de|qui explique|comment)?\s*', '',q,flags=re.I) q=re.sub(r'^\s*(?:youtube|un tutoriel|tutoriel|tuto)\s*(?:sur|pour|de|comment)?\s*','',q,flags=re.I) return q.strip(' .?!,:;')[:MAX_QUERY] or str(text or '')[:MAX_QUERY] def _video_id(value): if not isinstance(value,str):return None value=value.strip() if VIDEO_ID.fullmatch(value):return value try: u=urlsplit(value) if u.scheme not in ('https','http') or u.username or u.password:return None host=(u.hostname or '').lower() if host in ('youtu.be','www.youtu.be'): v=u.path.strip('/').split('/')[0] elif host in ('youtube.com','www.youtube.com','m.youtube.com','music.youtube.com','www.youtube-nocookie.com','youtube-nocookie.com'): parts=u.path.strip('/').split('/') if u.path=='/watch':v=(parse_qs(u.query).get('v') or [''])[0] elif len(parts)>=2 and parts[0] in ('shorts','embed','live'):v=parts[1] else:return None else:return None return v if VIDEO_ID.fullmatch(v) else None except ValueError:return None def normalize_videos(raw): if isinstance(raw,dict):raw=raw.get('videos',raw.get('results',[])) if not isinstance(raw,list):return [] out=[];seen=set() for obj in raw[:12]: if not isinstance(obj,dict):continue vid=_video_id(obj.get('url') or obj.get('video_url') or obj.get('id')) if not vid or vid in seen:continue seen.add(vid) title=obj.get('title') if isinstance(obj.get('title'),str) else 'Vidéo YouTube' channel=obj.get('channel') if isinstance(obj.get('channel'),str) else '' out.append({'id':vid,'title':title.replace('\x00','')[:160], 'channel':channel.replace('\x00','')[:100], 'url':f'https://www.youtube.com/watch?v={vid}'}) if len(out)>=6:break return out def _extract_json(text): parser=json.JSONDecoder() for pos,ch in enumerate(text): if ch not in '[{':continue try:obj,_=parser.raw_decode(text,pos) except ValueError:continue arr=normalize_videos(obj) if arr:return arr return [] def search_hermes(query): """Uses Hermes web toolset in a *new* isolated session, never resume.""" from services import hermes_client prompt=( 'Cherche SUR LE WEB des vidéos YouTube publiques en français sur le sujet suivant : ' + query + '\n' 'Utilise impérativement les outils de recherche web disponibles. ' 'Ne devine jamais les identifiants ou URL des vidéos ; ne cite que des URL de vidéos effectivement trouvées dans les résultats web. ' 'Retourne exclusivement un JSON valide sous la forme ' '{"videos":[{"title":"titre réel","url":"https://www.youtube.com/watch?v=XXXXXXXXXXX","channel":"chaîne si connue"}]}. ' 'Maximum cinq vidéos, aucune chaîne ni playlist ni lien de page de résultats. ' 'Si aucune URL de vidéo n’a été trouvée, réponds {"videos":[]}.' ) cmd=[hermes_client._hermes_binary(),'chat','--oneshot','--query-file','-', '--format','stream-json','--source','cli'] toolsets=os.getenv('DONA_YOUTUBE_TOOLSETS','web').strip() if toolsets:cmd.extend(['--toolsets',toolsets]) with hermes_client._REQUEST_LOCK: process=subprocess.run(cmd,input=prompt,text=True,capture_output=True,timeout=min(180,hermes_client._hermes_timeout()),check=False) if process.returncode != 0 and toolsets and 'toolset' in (process.stderr or '').lower(): cmd=cmd[:-2] process=subprocess.run(cmd,input=prompt,text=True,capture_output=True,timeout=min(180,hermes_client._hermes_timeout()),check=False) if process.returncode != 0:raise RuntimeError('Recherche Hermes indisponible : '+(process.stderr or '')[-300:]) response,_sid=hermes_client._parse_jsonl(process.stdout) return _extract_json(response) def search_api(query): key=os.getenv('DONA_YOUTUBE_API_KEY','').strip() if not key:raise RuntimeError('Clé YouTube Data API absente : utiliser le moteur Hermes.') params={'part':'snippet','type':'video','maxResults':5,'q':query,'safeSearch':'moderate', 'relevanceLanguage':'fr','regionCode':'FR','videoEmbeddable':'true','key':key} url='https://www.googleapis.com/youtube/v3/search?'+urlencode(params) req=Request(url,headers={'User-Agent':'Dona-YouTube/1.0','Accept':'application/json'}) with urlopen(req,timeout=12) as resp: data=json.load(resp) raw=[] for item in data.get('items',[]): if not isinstance(item,dict):continue sn=item.get('snippet') or {} raw.append({'id':(item.get('id') or {}).get('videoId'), 'title':sn.get('title'), 'channel':sn.get('channelTitle')}) return normalize_videos(raw) def search(query): if os.getenv('DONA_YOUTUBE_SEARCH_PROVIDER','hermes').strip().lower()=='api':return search_api(query) return search_hermes(query) # Single-display device model: controls only when the YouTube overlay is active. # No video history or identity is persisted server-side. _active = False def player_active():return _active def register_youtube(app, socketio): bp=Blueprint('dona_youtube_v82',__name__) def allowed(): if not request.is_json or request.headers.get('X-Alfred-Request')!='1':return False origin=request.headers.get('Origin') if origin: try: u=urlsplit(origin) return u.scheme in ('http','https') and u.netloc==request.host except ValueError:return False return request.headers.get('Sec-Fetch-Site','same-origin') in ('same-origin','none') @bp.post('/api/dona/youtube/player-state') def state(): global _active if not allowed():return jsonify(ok=False,error='Requête refusée'),403 obj=request.get_json(silent=True) or {} _active=obj.get('active') is True return jsonify(ok=True) @bp.post('/api/dona/youtube/search') def search_endpoint(): if not allowed():return jsonify(ok=False,error='Requête refusée'),403 data=request.get_json(silent=True) or {} query=data.get('query') if not isinstance(query,str) or not 2<=len(query.strip())<=MAX_QUERY: return jsonify(ok=False,error='Recherche de 2 à 200 caractères requise'),400 try:found=search(query.strip()) except Exception: app.logger.exception('Dona YouTube: recherche') return jsonify(ok=False,error='Recherche momentanément indisponible'),503 return jsonify(ok=True,query=query.strip(),videos=found) app.register_blueprint(bp) def search_and_emit(prompt,socketio,state,state_lock): query=extract_query(prompt) try: videos=search(query) payload={'type':'dona_youtube','query':query,'videos':videos} speech=(f"J'ai trouvé {len(videos)} vidéos sur {query}." if videos else "Je n'ai pas trouvé de vidéo YouTube exploitable. Tu peux essayer une autre recherche.") except Exception as exc: print('[DONA YOUTUBE] recherche échouée:',type(exc).__name__,str(exc)[:350],flush=True) payload={'type':'dona_youtube','query':query,'videos':[], 'error':'Recherche YouTube momentanément indisponible.'} speech='Je ne peux pas rechercher de vidéos YouTube actuellement.' with state_lock: state['content']=payload socketio.emit('alfred_render',payload) return speech def control_and_emit(text,socketio): if not player_active():return None action=action_for(text) if not action:return None socketio.emit('dona_youtube_control',action) return 'Commande envoyée au lecteur YouTube.'