commit 74e77775110fb97eee9399aa72dcc515f4523209
parent 3bb18910c8762ae5263fc23bd2f9678228e6a76b
Author: Amit Dutta <mail@amit.is-a.dev>
Date: Wed, 16 Sep 2026 22:35:06 +0530
fix#11
Diffstat:
| M | Dockerfile | | | 50 | ++++++++++++++++++++++++++++++++++++++++++++++---- |
| M | app/main.py | | | 257 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------- |
| M | render.yaml | | | 10 | ++++++++++ |
| A | start.sh | | | 26 | ++++++++++++++++++++++++++ |
4 files changed, 278 insertions(+), 65 deletions(-)
diff --git a/Dockerfile b/Dockerfile
@@ -1,13 +1,54 @@
-FROM python:3.11-slim
+FROM node:24-bookworm-slim
WORKDIR /app
+# Install Python and build dependencies required by Cobalt
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends \
+ python3 \
+ python3-pip \
+ python3-dev \
+ git \
+ build-essential \
+ && rm -rf /var/lib/apt/lists/*
+
+# Enable pnpm
+ENV PNPM_HOME="/pnpm"
+ENV PATH="$PNPM_HOME:$PATH"
+
+RUN corepack enable
+
+# Clone the current Cobalt source
+RUN git clone --depth 1 https://github.com/imputnet/cobalt.git /opt/cobalt
+
+WORKDIR /opt/cobalt
+
+# Install Cobalt production dependencies
+RUN pnpm install --prod --frozen-lockfile
+
+# Deploy only the Cobalt API package
+RUN pnpm deploy --filter=@imput/cobalt-api --prod /opt/cobalt-api
+
+# Install Python dependencies
+WORKDIR /app
+
COPY requirements.txt .
-RUN pip install --no-cache-dir -r requirements.txt
-# Copy the app structure
+RUN pip3 install \
+ --no-cache-dir \
+ --break-system-packages \
+ -r requirements.txt
+
+# Copy the FastAPI application
COPY app/ ./app/
+# Copy startup script
+COPY start.sh /start.sh
+
+RUN chmod +x /start.sh
+
+# Render exposes the FastAPI process.
+# Cobalt itself remains internal on port 9000.
EXPOSE 8000
-CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+CMD ["/start.sh"]
+\ No newline at end of file
diff --git a/app/main.py b/app/main.py
@@ -314,31 +314,120 @@ async def url_ytmusic_page(request: Request):
class YtRequest(BaseModel):
url: str
-COBALT_FALLBACK_URLS = [
- "https://api.cobalt.tools/",
- "https://co.wukko.me/"
-]
+
+COBALT_INTERNAL_URL = os.getenv(
+ "COBALT_INTERNAL_URL",
+ "http://127.0.0.1:9000",
+).rstrip("/")
+
+COBALT_PUBLIC_PREFIX = "/cobalt"
+
+
+@app.api_route(
+ "/cobalt/{path:path}",
+ methods=["GET", "HEAD"],
+)
+async def cobalt_tunnel_proxy(path: str, request: Request):
+ """
+ Proxy Cobalt tunnel requests through the same Render service.
+
+ Cobalt itself listens only on 127.0.0.1:9000, while this FastAPI
+ application is the only publicly exposed process.
+ """
+ cobalt_url = f"{COBALT_INTERNAL_URL}/{path}"
+
+ query_string = request.url.query
+ if query_string:
+ cobalt_url += f"?{query_string}"
+
+ headers = {}
+
+ for header in (
+ "Range",
+ "If-Range",
+ "If-None-Match",
+ "If-Modified-Since",
+ ):
+ value = request.headers.get(header)
+ if value:
+ headers[header] = value
+
+ client = httpx.AsyncClient(
+ follow_redirects=False,
+ timeout=None,
+ )
+
+ try:
+ upstream = await client.send(
+ client.build_request(
+ request.method,
+ cobalt_url,
+ headers=headers,
+ ),
+ stream=True,
+ )
+ except httpx.RequestError as exc:
+ await client.aclose()
+ raise HTTPException(
+ status_code=502,
+ detail=f"Error connecting to Cobalt tunnel: {exc}",
+ )
+
+ response_headers = {}
+
+ for header in (
+ "content-type",
+ "content-length",
+ "content-range",
+ "accept-ranges",
+ "content-disposition",
+ "cache-control",
+ "etag",
+ "last-modified",
+ "estimated-content-length",
+ ):
+ value = upstream.headers.get(header)
+ if value:
+ response_headers[header] = value
+
+ async def stream_cobalt():
+ try:
+ async for chunk in upstream.aiter_raw():
+ yield chunk
+ finally:
+ await client.aclose()
+
+ return StreamingResponse(
+ stream_cobalt(),
+ status_code=upstream.status_code,
+ headers=response_headers,
+ )
+
@app.post("/api/yt/download")
async def yt_download_cobalt(request: Request, body: YtRequest):
url = body.url.strip()
+
if not url:
- raise HTTPException(status_code=400, detail="Missing YouTube URL.")
+ raise HTTPException(
+ status_code=400,
+ detail="Missing YouTube URL.",
+ )
- # Determine which instance URL to use
- user_defined_api = os.getenv("COBALT_API_URL")
-
- # If the user defines an API URL, strictly use it. Otherwise, attempt fallbacks
- # in case community instances go offline (DNS/NXDOMAIN errors).
- instances_to_try = [user_defined_api] if user_defined_api else COBALT_FALLBACK_URLS
+ cobalt_api = f"{COBALT_INTERNAL_URL}/"
headers = {
"Accept": "application/json",
"Content-Type": "application/json",
- "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"
+ "User-Agent": (
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
+ "Chrome/122.0.0.0 Safari/537.36"
+ ),
}
cobalt_api_token = os.getenv("COBALT_API_TOKEN")
+
if cobalt_api_token:
if " " not in cobalt_api_token:
headers["Authorization"] = f"Api-Key {cobalt_api_token}"
@@ -349,57 +438,103 @@ async def yt_download_cobalt(request: Request, body: YtRequest):
"url": url,
"downloadMode": "audio",
"audioFormat": "mp3",
- "filenameStyle": "basic"
+ "audioBitrate": "128",
+ "filenameStyle": "basic",
}
- last_error = "Could not reach any Cobalt API instances."
-
- for instance in instances_to_try:
- if not instance.endswith("/"):
- instance += "/"
-
- async with httpx.AsyncClient(timeout=30.0) as client:
- try:
- res = await client.post(instance, json=payload, headers=headers)
-
- # Non-200 could be bot protection, ratelimits, or API validation errors.
- if res.status_code != 200:
- err_text = res.text
- try:
- data = res.json()
- if "error" in data and "code" in data["error"]:
- err_text = data["error"]["code"]
- else:
- err_text = data.get("text", err_text)
- except Exception:
- if len(err_text) > 200:
- err_text = err_text[:200] + "..."
-
- last_error = f"API error ({res.status_code}): {err_text}"
- # Do not fallback if the instance actually responded with an auth/format error
- if res.status_code in (400, 401, 403):
- raise HTTPException(status_code=400, detail=f"Cobalt returned an error: {err_text}")
- continue
-
- data = res.json()
- status = data.get("status")
-
- if status == "error":
- err_code = data.get("error", {}).get("code", "Unknown Cobalt error")
- raise HTTPException(status_code=400, detail=f"Cobalt error: {err_code}")
-
- dl_url = data.get("url")
- if not dl_url:
- raise HTTPException(status_code=500, detail=f"Cobalt succeeded but returned no download link. (Status: {status})")
-
- return {"url": dl_url, "title": "Audio"}
-
- except (httpx.RequestError, socket.gaierror) as exc:
- last_error = f"Failed to connect to {instance}: {exc}"
- continue # Try the next instance in the list
-
- # If the loop finishes without returning, all instances failed
- raise HTTPException(status_code=502, detail=last_error)
+ async with httpx.AsyncClient(
+ timeout=60.0,
+ follow_redirects=False,
+ ) as client:
+ try:
+ res = await client.post(
+ cobalt_api,
+ json=payload,
+ headers=headers,
+ )
+
+ if res.status_code != 200:
+ err_text = res.text
+
+ try:
+ data = res.json()
+
+ if "error" in data and isinstance(data["error"], dict):
+ err_text = data["error"].get(
+ "code",
+ err_text,
+ )
+ elif data.get("text"):
+ err_text = data["text"]
+
+ except Exception:
+ pass
+
+ if len(err_text) > 300:
+ err_text = err_text[:300] + "..."
+
+ raise HTTPException(
+ status_code=400,
+ detail=f"Cobalt API failed: {err_text}",
+ )
+
+ data = res.json()
+ status = data.get("status")
+
+ if status == "error":
+ error = data.get("error", {})
+
+ if isinstance(error, dict):
+ error_code = error.get(
+ "code",
+ "Unknown Cobalt error",
+ )
+ else:
+ error_code = str(error)
+
+ raise HTTPException(
+ status_code=400,
+ detail=f"Cobalt error: {error_code}",
+ )
+
+ dl_url = data.get("url")
+
+ if not dl_url:
+ raise HTTPException(
+ status_code=500,
+ detail=(
+ "Cobalt succeeded but returned no "
+ f"download link. (Status: {status})"
+ ),
+ )
+
+ # Cobalt may return its own tunnel URL.
+ # Convert it to our public FastAPI proxy URL.
+ if status == "tunnel":
+ parsed = urlparse(dl_url)
+
+ if parsed.path.startswith("/tunnel"):
+ public_url = (
+ f"{CDN_BASE_URL}{COBALT_PUBLIC_PREFIX}"
+ f"{parsed.path}"
+ )
+
+ if parsed.query:
+ public_url += f"?{parsed.query}"
+
+ dl_url = public_url
+
+ return {
+ "url": dl_url,
+ "title": data.get("filename", "Audio"),
+ }
+
+ except httpx.RequestError as exc:
+ raise HTTPException(
+ status_code=502,
+ detail=f"Error connecting to Cobalt: {exc}",
+ )
+
# ---------------------------------------------------------------------------------
diff --git a/render.yaml b/render.yaml
@@ -6,9 +6,18 @@ services:
envVars:
- key: HF_REPO_ID
value: notamitgamer/cdn
+
- key: HF_TOKEN
sync: false
+
- key: RAW_DOMAIN
value: raw.cdn.amit.is-a.dev
+
- key: RAW_BASE_URL
value: https://raw.cdn.amit.is-a.dev
+
+ - key: COBALT_API_URL
+ value: https://cdn.amit.is-a.dev/cobalt/
+
+ - key: COBALT_PORT
+ value: "9000"
+\ No newline at end of file
diff --git a/start.sh b/start.sh
@@ -0,0 +1,25 @@
+#!/bin/sh
+
+set -e
+
+echo "Starting Cobalt..."
+
+cd /opt/cobalt/api
+
+API_URL="${COBALT_API_URL:-https://cdn.amit.is-a.dev/cobalt/}"
+API_PORT="${COBALT_PORT:-9000}"
+
+export API_URL
+export API_PORT
+export API_LISTEN_ADDRESS="127.0.0.1"
+
+pnpm start &
+COBALT_PID=$!
+
+echo "Cobalt started with PID ${COBALT_PID}"
+
+cd /app
+
+echo "Starting FastAPI..."
+
+exec uvicorn app.main:app --host 0.0.0.0 --port "${PORT:-8000}"
+\ No newline at end of file