Files
Alfredv2/alfred/app/services/dona_youtube.py
T
2026-10-30 22:10:03 +01:00

228 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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.'