228 lines
10 KiB
Python
228 lines
10 KiB
Python
"""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.'
|