This commit is contained in:
pporcheret
2026-10-30 22:10:03 +01:00
parent 7f26f9b015
commit 90ffa635a0
161 changed files with 19042 additions and 111 deletions
+227
View File
@@ -0,0 +1,227 @@
"""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.'