Spaces:
Sleeping
Sleeping
Upload 4 files
Browse files- Dockerfile +21 -0
- app.py +91 -0
- fastmcp.json +10 -0
- requirements.txt +3 -0
Dockerfile
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
# System dependencies for audio processing
|
| 4 |
+
RUN apt-get update \
|
| 5 |
+
&& apt-get install -y --no-install-recommends ffmpeg ca-certificates \
|
| 6 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 7 |
+
|
| 8 |
+
# Set the working directory in the container
|
| 9 |
+
WORKDIR /app
|
| 10 |
+
|
| 11 |
+
COPY requirements.txt .
|
| 12 |
+
|
| 13 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 14 |
+
|
| 15 |
+
COPY . .
|
| 16 |
+
|
| 17 |
+
EXPOSE 7862
|
| 18 |
+
|
| 19 |
+
ENV PORT=7862
|
| 20 |
+
|
| 21 |
+
CMD ["python", "app.py"]
|
app.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
import glob
|
| 5 |
+
from typing import Optional, Literal
|
| 6 |
+
|
| 7 |
+
from pydantic import BaseModel, Field, HttpUrl
|
| 8 |
+
|
| 9 |
+
from fastmcp import FastMCP
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
mcp = FastMCP(
|
| 13 |
+
name="youtube-audio",
|
| 14 |
+
host="0.0.0.0",
|
| 15 |
+
port=7862,
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class DownloadResult(BaseModel):
|
| 20 |
+
url: HttpUrl = Field(..., description="Original YouTube URL")
|
| 21 |
+
title: Optional[str] = Field(None, description="Video title if available")
|
| 22 |
+
filepath: str = Field(..., description="Absolute path to the downloaded audio file in the container")
|
| 23 |
+
ext: str = Field(..., description="Audio file extension, e.g., mp3")
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
@mcp.tool(description="Download audio from a YouTube video URL and return the local file path inside the container.")
|
| 27 |
+
def download_youtube_audio(url: HttpUrl, audio_format: Literal["mp3", "m4a", "wav", "aac", "opus"] = "mp3") -> DownloadResult:
|
| 28 |
+
"""
|
| 29 |
+
- url: A direct YouTube video URL
|
| 30 |
+
- audio_format: Desired audio format (requires ffmpeg in the container)
|
| 31 |
+
|
| 32 |
+
The file will be saved under /app/downloads. Ensure the container has write access.
|
| 33 |
+
"""
|
| 34 |
+
# Ensure output directory exists
|
| 35 |
+
output_dir = "/app/downloads"
|
| 36 |
+
os.makedirs(output_dir, exist_ok=True)
|
| 37 |
+
|
| 38 |
+
try:
|
| 39 |
+
import yt_dlp as ytdlp
|
| 40 |
+
except Exception:
|
| 41 |
+
raise RuntimeError("yt-dlp is required. Ensure it is listed in requirements.txt and installed.")
|
| 42 |
+
|
| 43 |
+
ydl_opts = {
|
| 44 |
+
"format": "bestaudio/best",
|
| 45 |
+
"outtmpl": os.path.join(output_dir, "%(title).200s [%(id)s].%(ext)s"),
|
| 46 |
+
"postprocessors": [
|
| 47 |
+
{
|
| 48 |
+
"key": "FFmpegExtractAudio",
|
| 49 |
+
"preferredcodec": audio_format,
|
| 50 |
+
"preferredquality": "0",
|
| 51 |
+
}
|
| 52 |
+
],
|
| 53 |
+
"noplaylist": True,
|
| 54 |
+
"quiet": True,
|
| 55 |
+
"nocheckcertificate": True,
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
info_title: Optional[str] = None
|
| 59 |
+
downloaded_id: Optional[str] = None
|
| 60 |
+
|
| 61 |
+
with ytdlp.YoutubeDL(ydl_opts) as ydl:
|
| 62 |
+
info = ydl.extract_info(str(url), download=True)
|
| 63 |
+
info_title = info.get("title") if isinstance(info, dict) else None
|
| 64 |
+
downloaded_id = info.get("id") if isinstance(info, dict) else None
|
| 65 |
+
|
| 66 |
+
# Resolve the final filename after post-processing
|
| 67 |
+
final_path: Optional[str] = None
|
| 68 |
+
if downloaded_id:
|
| 69 |
+
pattern = os.path.join(output_dir, f"*[{downloaded_id}].{audio_format}")
|
| 70 |
+
matches = glob.glob(pattern)
|
| 71 |
+
if matches:
|
| 72 |
+
final_path = os.path.abspath(matches[0])
|
| 73 |
+
|
| 74 |
+
if not final_path:
|
| 75 |
+
# Fallback: best-effort to find any file with the selected extension modified recently
|
| 76 |
+
candidates = sorted(
|
| 77 |
+
glob.glob(os.path.join(output_dir, f"*.{audio_format}")),
|
| 78 |
+
key=lambda p: os.path.getmtime(p),
|
| 79 |
+
reverse=True,
|
| 80 |
+
)
|
| 81 |
+
if candidates:
|
| 82 |
+
final_path = os.path.abspath(candidates[0])
|
| 83 |
+
|
| 84 |
+
if not final_path:
|
| 85 |
+
raise RuntimeError("Audio file not found after download. Check logs and ffmpeg availability.")
|
| 86 |
+
|
| 87 |
+
return DownloadResult(url=url, title=info_title, filepath=final_path, ext=audio_format)
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
if __name__ == "__main__":
|
| 91 |
+
mcp.run(transport="http")
|
fastmcp.json
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "youtube-audio",
|
| 3 |
+
"description": "MCP server that downloads audio from a YouTube video URL and returns the file path",
|
| 4 |
+
"entrypoint": "app.py",
|
| 5 |
+
"transport": "streamable-http",
|
| 6 |
+
"http": {
|
| 7 |
+
"host": "0.0.0.0",
|
| 8 |
+
"port": 7862
|
| 9 |
+
}
|
| 10 |
+
}
|
requirements.txt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastmcp>=2.12.2
|
| 2 |
+
pydantic>=2.7.0
|
| 3 |
+
yt-dlp>=2024.4.9
|