Spaces:
Running
Running
| from __future__ import annotations | |
| import os | |
| from dataclasses import asdict, dataclass | |
| import shutil | |
| import subprocess | |
| from fastmcp import FastMCP | |
| from aileen3_mcp.media_tools import register_media_tools, _silence_stdio, _YDLLogger | |
| from aileen3_mcp.logging_utils import configure_logging | |
| import logging | |
| log = logging.getLogger(__name__) | |
| class HealthResult: | |
| ok: bool | |
| detail: str | |
| def make_app() -> FastMCP: | |
| """Create the MCP application with all tools registered on a FastMCP instance. | |
| This function is the single entry point for tool registration: | |
| - it wires up lightweight health checks used by the demo Space, and | |
| - it delegates to :func:`register_media_tools` for all long-running media flows. | |
| """ | |
| app = FastMCP("aileen3-mcp") | |
| def health() -> dict: | |
| """Return a basic health payload including ffmpeg and Gemini env availability. | |
| This mirrors the Gradio health cell and is intentionally cheap: | |
| it only checks for the *presence* of ffmpeg and a Gemini API key, | |
| leaving deeper checks to the demo-side health probe. | |
| """ | |
| def _ffmpeg_ok() -> tuple[bool, str]: | |
| # Keep this probe very small: just check that the binary is | |
| # callable and returns a version string, without running any | |
| # actual media processing pipeline. | |
| binary = shutil.which("ffmpeg") | |
| if not binary: | |
| return False, "ffmpeg not found on PATH" | |
| try: | |
| completed = subprocess.run( | |
| [binary, "-version"], | |
| capture_output=True, | |
| text=True, | |
| timeout=5, | |
| check=False, | |
| ) | |
| except Exception as exc: # pragma: no cover - defensive | |
| return False, f"ffmpeg exec failed: {exc}" | |
| if completed.returncode != 0: | |
| return False, completed.stderr.strip() or "ffmpeg returned error" | |
| first = (completed.stdout or "").splitlines()[0] if completed.stdout else "ffmpeg present" | |
| return True, first | |
| def _gemini_key_ok() -> tuple[bool, str]: | |
| key = bool(os.environ.get("GEMINI_API_KEY")) | |
| return (key, "GEMINI_API_KEY is set" if key else "GEMINI_API_KEY missing") | |
| ff_ok, ff_detail = _ffmpeg_ok() | |
| gem_ok, gem_detail = _gemini_key_ok() | |
| overall_ok = ff_ok and gem_ok | |
| detail = "; ".join(d for d in (ff_detail, gem_detail) if d) | |
| result = {"ok": overall_ok, "detail": detail, "ffmpeg": ff_ok, "gemini_api_key": gem_ok} | |
| log.debug("Health probe returning %s", result) | |
| return result | |
| def search_youtube(query: str, max_results: int = 10) -> dict: | |
| """Search YouTube for videos. | |
| Args: | |
| query: Free-form search terms, e.g. "lofi focus mix" or | |
| "python packaging tutorial 2024". | |
| max_results: Maximum number of videos to return (1-50). Defaults to 10. | |
| Returns: | |
| A dictionary with a single key ``videos`` that maps to a list of | |
| video objects. Each object contains: | |
| - id: YouTube video ID | |
| - title: Video title | |
| - webpage_url: Canonical video URL | |
| - duration_seconds: Video length in seconds (may be null) | |
| - channel: Channel name | |
| - channel_id: Channel ID | |
| Typical `Aileen Agent` usage: | |
| - Use this tool when the user describes what they want to analyze but does not give a concrete URL. | |
| Notes for LLM tool users: | |
| - Use this tool to retrieve candidate videos before choosing one to | |
| watch or share; it does not download media. | |
| - Keep ``max_results`` modest (<=10) for fastest responses. | |
| """ | |
| from yt_dlp import YoutubeDL # local import so health probe stays light | |
| capped_results = max(1, min(max_results, 50)) | |
| opts = { | |
| "quiet": True, | |
| "no_warnings": True, | |
| "noprogress": True, | |
| "skip_download": True, | |
| "extract_flat": "in_playlist", | |
| "logger": _YDLLogger(), | |
| "extractor_args": {"youtube": {"player_client": ["default"]}}, | |
| } | |
| search_spec = f"ytsearch{capped_results}:{query}" | |
| log.info("search_youtube query=%r max_results=%d", query, capped_results) | |
| with _silence_stdio(): | |
| with YoutubeDL(opts) as ydl: | |
| info = ydl.extract_info(search_spec, download=False) | |
| entries = info.get("entries", []) if info else [] | |
| videos = [] | |
| for entry in entries: | |
| video_id = entry.get("id") | |
| webpage_url = entry.get("webpage_url") or ( | |
| f"https://www.youtube.com/watch?v={video_id}" if video_id else None | |
| ) | |
| videos.append( | |
| { | |
| "id": video_id, | |
| "title": entry.get("title"), | |
| "webpage_url": webpage_url, | |
| "duration_seconds": entry.get("duration"), | |
| "channel": entry.get("channel"), | |
| "channel_id": entry.get("channel_id"), | |
| } | |
| ) | |
| return {"videos": videos} | |
| # Register media analysis tools: | |
| # - start_media_retrieval / get_media_retrieval_status | |
| # - start_slide_extraction / get_extracted_slides | |
| # - start_media_analysis / get_media_analysis_result | |
| # - start_media_transcription / get_media_transcription_result | |
| # | |
| # Each of these is exposed as an MCP tool that can be called from | |
| # Claude Desktop, the Aileen 3 Agent, or the Gradio demo. | |
| register_media_tools(app) | |
| return app | |
| def main() -> None: | |
| """Configure logging and run the MCP server over stdio.""" | |
| configure_logging() | |
| app = make_app() | |
| app.run() # stdio transport by default | |
| if __name__ == "__main__": | |
| main() | |