commit 7a55b9af09b95b79e86397b37bce997e3fed673a
parent dc913441637fe302bdb2932c506b3b93cd339acd
Author: Amit Dutta <mail@amit.is-a.dev>
Date: Thu, 17 Sep 2026 13:55:36 +0530
Merge pull request #23 from notamitgamer/restore-cobalt-fixed
Restore YouTube/Cobalt mp3 download (fixed startup check)
Diffstat:
6 files changed, 924 insertions(+), 6 deletions(-)
diff --git a/Dockerfile b/Dockerfile
@@ -1,3 +1,5 @@
+FROM ghcr.io/imputnet/cobalt:11 AS cobalt
+
FROM node:24-bookworm-slim
WORKDIR /app
@@ -9,6 +11,9 @@ RUN apt-get update \
python3-pip \
&& rm -rf /var/lib/apt/lists/*
+# Copy the already-built Cobalt API from the official Cobalt image
+COPY --from=cobalt /app /opt/cobalt-api
+
# Install FastAPI dependencies
COPY requirements.txt .
diff --git a/app/main.py b/app/main.py
@@ -368,6 +368,332 @@ async def gh_sync(
}
+@app.get("/ytmusic")
+async def url_ytmusic_page(request: Request):
+ ctx = await render_context({"page": "ytmusic"})
+ return templates.TemplateResponse(request, "ytmusic.html", ctx)
+
+
+# ---------------------------------------------------------------------------------
+# YTMusic routes: Cobalt API Proxy
+# ---------------------------------------------------------------------------------
+
+class YtRequest(BaseModel):
+ url: str
+
+
+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):
+ 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=60.0,
+ )
+
+ try:
+ req = client.build_request(
+ request.method,
+ cobalt_url,
+ headers=headers,
+ )
+
+ res = await client.send(req, stream=True)
+
+ response_headers = {}
+
+ for header in [
+ "Content-Type",
+ "Content-Length",
+ "Content-Range",
+ "Accept-Ranges",
+ "Content-Disposition",
+ "Cache-Control",
+ "ETag",
+ "Last-Modified",
+ ]:
+ value = res.headers.get(header)
+ if value:
+ response_headers[header] = value
+
+ return StreamingResponse(
+ _proxy_stream(client, res),
+ status_code=res.status_code,
+ headers=response_headers,
+ )
+
+ except httpx.RequestError as exc:
+ await client.aclose()
+ raise HTTPException(
+ status_code=502,
+ detail=f"Error connecting to Cobalt tunnel: {exc}",
+ )
+
+
+@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.",
+ )
+
+ 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"
+ ),
+ }
+
+ 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}"
+ else:
+ headers["Authorization"] = cobalt_api_token
+
+ payload = {
+ "url": url,
+ "downloadMode": "audio",
+ "audioFormat": "mp3",
+ "audioBitrate": "128",
+ "filenameStyle": "basic",
+ "youtubeHLS": True,
+ }
+
+ 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", {})
+
+ print("========== COBALT ERROR ==========")
+ print(f"Full response: {data}")
+
+ if isinstance(error, dict):
+ print(f"Error code: {error.get('code')}")
+ print(f"Error context: {error.get('context')}")
+ else:
+ print(f"Error: {error}")
+
+ print("==================================")
+
+ if isinstance(error, dict):
+ raise HTTPException(
+ status_code=400,
+ detail={
+ "message": "Cobalt API failed",
+ "code": error.get("code"),
+ "context": error.get("context"),
+ "raw": data,
+ },
+ )
+ else:
+ raise HTTPException(
+ status_code=400,
+ detail={
+ "message": "Cobalt API failed",
+ "error": str(error),
+ "raw": data,
+ },
+ )
+
+ 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})"
+ ),
+ )
+
+ 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}",
+ )
+
+
+# ---------------------------------------------------------------------------------
+# TEMPORARY COBALT DIAGNOSTIC ENDPOINT
+# ---------------------------------------------------------------------------------
+
+@app.get("/api/yt/debug")
+async def yt_debug(url: str):
+ url = url.strip()
+
+ if not url:
+ raise HTTPException(
+ status_code=400,
+ detail="Missing YouTube URL.",
+ )
+
+ 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"
+ ),
+ }
+
+ 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}"
+ else:
+ headers["Authorization"] = cobalt_api_token
+
+ payload = {
+ "url": url,
+ "downloadMode": "audio",
+ "audioFormat": "mp3",
+ "audioBitrate": "128",
+ "filenameStyle": "basic",
+ }
+
+ async with httpx.AsyncClient(
+ timeout=60.0,
+ follow_redirects=False,
+ ) as client:
+ try:
+ res = await client.post(
+ cobalt_api,
+ json=payload,
+ headers=headers,
+ )
+
+ raw_text = res.text
+
+ try:
+ response_body = res.json()
+ except Exception:
+ response_body = raw_text
+
+ print("========== COBALT DEBUG ==========")
+ print(f"HTTP status: {res.status_code}")
+ print(f"Response headers: {dict(res.headers)}")
+ print(f"Response body: {response_body}")
+ print("===================================")
+
+ return {
+ "cobalt_url": cobalt_api,
+ "http_status": res.status_code,
+ "response_headers": dict(res.headers),
+ "response": response_body,
+ }
+
+ except httpx.RequestError as exc:
+ print("========== COBALT DEBUG ERROR ==========")
+ print(f"Request error: {repr(exc)}")
+ print("========================================")
+
+ raise HTTPException(
+ status_code=502,
+ detail={
+ "message": "Error connecting to Cobalt",
+ "error": repr(exc),
+ },
+ )
+
+
+
@app.get("/shorten")
async def url_shortener_page(request: Request):
ctx = await render_context({"page": "shorten"})
diff --git a/app/templates/_navbar.html b/app/templates/_navbar.html
@@ -179,6 +179,9 @@
<a href="/shorten" class="{{ 'active' if page == 'shorten' }}" title="URL Shortener" aria-label="URL Shortener"{{ ' aria-current="page"'|safe if page == 'shorten' }}>
<svg xmlns="http://www.w3.org/2000/svg" width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
</a>
+ <a href="/ytmusic" class="{{ 'active' if page == 'ytmusic' }}" title="Youtube Music Download" aria-label="Youtube Music Download"{{ ' aria-current="page"'|safe if page == 'ytmusic' }}>
+ <svg xmlns="http://www.w3.org/2000/svg" width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 10v3"/><path d="M6 6v11"/><path d="M10 3v18"/><path d="M14 8v7"/><path d="M18 5v13"/><path d="M22 10v3"/></svg>
+ </a>
</nav>
</header>
{% if not (page == 'listing' and not path) %}
@@ -206,6 +209,8 @@
{% set ns.items = ns.items + [{'name': 'gh-sync', 'href': '/gh-sync'}] %}
{% elif page == 'shorten' %}
{% set ns.items = ns.items + [{'name': 'shorten', 'href': '/shorten'}] %}
+ {% elif page == 'ytmusic' %}
+ {% set ns.items = ns.items + [{'name': 'ytmusic', 'href': '/ytmusic'}] %}
{% elif page == 'file' %}
{% set ns.items = ns.items + [{'name': filename, 'href': none}] %}
{% endif %}
diff --git a/app/templates/ytmusic.html b/app/templates/ytmusic.html
@@ -0,0 +1,461 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>YouTube Music Downloader — amit/cdn</title>
+ <meta name="description" content="Get a direct download link for YouTube music.">
+ <link rel="icon" href="/favicon.ico" sizes="any">
+ <link rel="icon" type="image/png" sizes="32x32" href="/static/icons/favicon-32x32.png">
+ <link rel="icon" type="image/png" sizes="16x16" href="/static/icons/favicon-16x16.png">
+ <link rel="icon" type="image/svg+xml" href="/static/icons/logo.svg">
+ <link rel="apple-touch-icon" sizes="180x180" href="/static/icons/apple-touch-icon.png">
+ <link rel="manifest" href="/static/manifest.json">
+ <meta name="theme-color" content="#09090b">
+ <meta name="color-scheme" content="dark">
+ <script type="module" src="https://cdn.jsdelivr.net/npm/@hotwired/turbo@8.0.4/dist/turbo.es2017-esm.js"></script>
+ <link rel="preconnect" href="https://fonts.googleapis.com">
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+ <link href="https://fonts.googleapis.com/css2?family=Zilla+Slab:wght@500;600;700&family=Work+Sans:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
+ <style>
+ :root {
+ --bg: #09090b;
+ --surface: #121214;
+ --surface-2: #1e1e22;
+ --surface-3: #27272a;
+ --text-main: #f4f4f5;
+ --text-dim: #b8b8c0;
+ --accent-primary: #818cf8;
+ --accent-primary-hover: #a5b4fc;
+ --success: #10b981;
+ --danger: #ef4444;
+ --border: rgba(255, 255, 255, 0.08);
+ --border-hover: rgba(255, 255, 255, 0.15);
+ --font-heading: 'Zilla Slab', Georgia, 'Times New Roman', serif;
+ --font-body: 'Work Sans', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif;
+ --font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+ --radius-md: 8px;
+ --radius-lg: 12px;
+ --radius-xl: 16px;
+ }
+ * { box-sizing: border-box; }
+ body {
+ font-family: var(--font-body);
+ background: var(--bg);
+ color: var(--text-main);
+ margin: 0;
+ padding: 0 0 4rem 0;
+ line-height: 1.6;
+ font-size: 1rem;
+ -webkit-font-smoothing: antialiased;
+ }
+ @media (max-width: 480px) {
+ body { font-size: 0.95rem; }
+ }
+ .container {
+ width: 90%;
+ max-width: 1000px;
+ margin: 0 auto;
+ }
+ h1 {
+ font-family: var(--font-heading);
+ font-size: clamp(2.2rem, 5vw, 3rem);
+ letter-spacing: -0.02em;
+ color: #ffffff;
+ font-weight: 600;
+ margin: 0 0 1.25rem 0;
+ line-height: 1.1;
+ max-width: 20ch;
+ }
+ blockquote {
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-left: 3px solid var(--accent-primary);
+ border-radius: var(--radius-md);
+ margin: 0 0 2.25rem 0;
+ padding: 1rem 1.25rem;
+ color: var(--text-dim);
+ max-width: 68ch;
+ font-size: 0.95rem;
+ }
+ a { color: var(--accent-primary); text-decoration: none; transition: color 0.15s; }
+ a:hover { color: var(--accent-primary-hover); text-decoration: underline; }
+ .input-group {
+ position: relative;
+ display: flex;
+ align-items: center;
+ width: 100%;
+ }
+ .url-input {
+ width: 100%;
+ padding: 0.85rem 2.5rem 0.85rem 1.25rem;
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ color: var(--text-main);
+ font-family: var(--font-mono);
+ font-size: 0.9rem;
+ transition: all 0.2s ease;
+ box-shadow: 0 2px 10px rgba(0,0,0,0.1) inset;
+ }
+ .url-input:focus {
+ outline: none;
+ background: var(--surface-2);
+ border-color: var(--accent-primary);
+ box-shadow: 0 0 0 3px rgba(129, 140, 248, 0.15);
+ }
+ .url-input::placeholder { color: var(--text-dim); }
+ .input-clear-btn {
+ position: absolute;
+ right: 0.75rem;
+ top: 50%;
+ transform: translateY(-50%);
+ background: transparent;
+ border: none;
+ color: var(--text-dim);
+ cursor: pointer;
+ padding: 0.25rem;
+ display: none;
+ align-items: center;
+ justify-content: center;
+ border-radius: 50%;
+ transition: color 0.2s, background 0.2s;
+ }
+ .input-group[data-has-text="true"] .input-clear-btn { display: flex; }
+ .input-clear-btn:hover { color: var(--text-main); background: var(--surface-3); }
+ .btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.5rem;
+ background: var(--surface-2);
+ color: var(--text-main);
+ padding: 0.65rem 1.25rem;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ cursor: pointer;
+ font-family: var(--font-body);
+ font-size: 0.9rem;
+ font-weight: 500;
+ transition: all 0.2s ease, transform 0.1s ease;
+ text-decoration: none;
+ }
+ .btn:hover:not(:disabled) { background: var(--surface-3); border-color: var(--border-hover); text-decoration: none; color: var(--text-main); }
+ .btn:active:not(:disabled) { transform: scale(0.97); }
+ .btn:disabled { opacity: 0.6; cursor: not-allowed; }
+ .btn-primary { background: var(--accent-primary) !important; color: #ffffff !important; border-color: var(--accent-primary) !important; font-weight: 600; transition: none !important; }
+ .btn-primary:hover:not(:disabled), .btn-primary:focus:not(:disabled), .btn-primary:active:not(:disabled) { background: var(--accent-primary) !important; border-color: var(--accent-primary) !important; color: #ffffff !important; }
+ .btn-primary svg { stroke: #ffffff !important; }
+ .result-box {
+ margin-top: 2rem;
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ padding: 1.5rem;
+ }
+ .result-box h3 {
+ font-family: var(--font-heading);
+ font-size: 1.2rem;
+ margin: 0 0 1rem 0;
+ color: #fff;
+ }
+ .result-url {
+ font-family: var(--font-mono);
+ font-size: 0.95rem;
+ word-break: break-all;
+ color: var(--accent-primary);
+ background: var(--surface-2);
+ padding: 0.75rem 1rem;
+ border-radius: var(--radius-md);
+ border: 1px solid var(--border);
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1rem;
+ flex-wrap: wrap;
+ }
+ .result-url a { color: var(--accent-primary); text-decoration: none; }
+ .result-url a:hover { text-decoration: underline; }
+ .error-box {
+ display: none;
+ color: var(--danger);
+ margin-top: 1rem;
+ padding: 1rem 1.25rem;
+ border: 1px solid rgba(239, 68, 68, 0.5);
+ background: rgba(239, 68, 68, 0.1);
+ border-radius: var(--radius-md);
+ }
+ .info-text {
+ color: var(--text-dim);
+ font-size: 0.9rem;
+ margin-top: 1rem;
+ }
+ .spin-anim { animation: spin 0.7s linear infinite; display: inline-flex; }
+ @keyframes spin { 100% { transform: rotate(360deg); } }
+ .btn-primary .btn-icon { display: inline-flex; }
+ .btn-primary.is-loading .btn-icon.btn-icon-default { display: none; }
+ .btn-primary .btn-icon-spinner { display: none; }
+ .btn-primary.is-loading .btn-icon-spinner { display: inline-flex; }
+ footer {
+ width: 90%;
+ max-width: 1000px;
+ margin: 4rem auto 0;
+ padding-top: 1.5rem;
+ border-top: 1px solid var(--border);
+ font-size: .85rem;
+ color: var(--muted);
+ }
+ @media (max-width: 480px) {
+ .container { width: 100%; padding: 0 1.25rem; }
+ .result-url { flex-direction: column; align-items: flex-start; }
+ }
+ </style>
+</head>
+<body>
+ {% include "_navbar.html" %}
+ <main class="container">
+ <h1>YouTube Music Downloader</h1>
+ <blockquote>
+ Paste a YouTube or YouTube Music URL and get a 128 KBPS audio download link.
+ </blockquote>
+
+ <div class="input-group" id="youtube-group">
+ <input
+ type="url"
+ id="youtube-input"
+ class="url-input"
+ placeholder="Paste a YouTube URL (e.g. https://music.youtube.com/...)"
+ autocomplete="off"
+ spellcheck="false"
+ required
+ >
+ <button type="button" class="input-clear-btn" id="youtube-clear" aria-label="Clear URL">
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
+ <line x1="18" y1="6" x2="6" y2="18"/>
+ <line x1="6" y1="6" x2="18" y2="18"/>
+ </svg>
+ </button>
+ </div>
+
+ <div style="display:flex; gap:0.75rem; margin-top:1rem; align-items:center;">
+ <button type="button" class="btn btn-primary" id="download-btn">
+ <span class="btn-icon btn-icon-default">
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+ <path d="M12 3v12"/>
+ <path d="m7 10 5 5 5-5"/>
+ <path d="M5 21h14"/>
+ </svg>
+ </span>
+ <span class="btn-icon btn-icon-spinner spin-anim">
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+ <path d="M21 12a9 9 0 1 1-6.219-8.56"/>
+ </svg>
+ </span>
+ <span id="download-btn-label">Get Download Link</span>
+ </button>
+ </div>
+
+ <div id="status-box" class="info-text" style="display:none;" aria-live="polite"></div>
+ <div id="error-box" class="error-box" role="alert" aria-live="polite"></div>
+
+ <div id="result" style="display:none;" class="result-box">
+ <h3>Your download link</h3>
+ <div class="result-url">
+ <a href="#" id="masked-link" target="_blank" rel="noreferrer" referrerpolicy="no-referrer"></a>
+ <button type="button" class="btn" id="copy-btn">Copy</button>
+ </div>
+ <p class="info-text">This link redirects to the official VidsSave download stream.</p>
+ </div>
+ </main>
+
+ <footer>
+ <p>© 2025 - 2026 notamitgamer. All rights reserved.</p>
+ </footer>
+
+ <script>
+ (function() {
+ const AUTH = '20250901majwlqo';
+ const DOMAIN = 'api-ak.vidssave.com';
+
+ function isYouTubeUrl(rawUrl) {
+ try {
+ const parsed = new URL(rawUrl);
+ if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') return false;
+ const host = parsed.hostname.toLowerCase().replace(/^www\./, '');
+ return (
+ host === 'youtube.com' ||
+ host === 'm.youtube.com' ||
+ host === 'music.youtube.com' ||
+ host === 'youtu.be'
+ );
+ } catch {
+ return false;
+ }
+ }
+
+ function setupYtMusic() {
+ const youtubeInput = document.getElementById('youtube-input');
+ const youtubeClear = document.getElementById('youtube-clear');
+ const downloadBtn = document.getElementById('download-btn');
+ const downloadBtnLabel = document.getElementById('download-btn-label');
+ const statusBox = document.getElementById('status-box');
+ const errorBox = document.getElementById('error-box');
+ const resultDiv = document.getElementById('result');
+ const maskedLink = document.getElementById('masked-link');
+ const copyBtn = document.getElementById('copy-btn');
+
+ if (!youtubeInput || !downloadBtn || downloadBtn.dataset.initialized) return;
+ downloadBtn.dataset.initialized = 'true';
+
+ function setStatus(message) {
+ statusBox.textContent = message;
+ statusBox.style.display = message ? 'block' : 'none';
+ }
+
+ function showError(message) {
+ errorBox.textContent = message;
+ errorBox.style.display = 'block';
+ }
+
+ function clearMessages() {
+ errorBox.style.display = 'none';
+ resultDiv.style.display = 'none';
+ setStatus('');
+ }
+
+ function setLoading(loading) {
+ downloadBtn.disabled = loading;
+ downloadBtn.classList.toggle('is-loading', loading);
+ downloadBtnLabel.textContent = loading ? 'Working...' : 'Get Download Link';
+ }
+
+ async function getDownloadLink(url) {
+ const response = await fetch('/api/yt/download', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ url })
+ });
+
+ const data = await response.json();
+ if (!response.ok || !data.url) {
+ throw new Error(data.detail || 'Failed to extract download link.');
+ }
+ return data.url;
+ }
+
+ async function shortenUrl(url) {
+ const response = await fetch('/api/shorten', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ url })
+ });
+
+ const data = await response.json().catch(() => ({}));
+ if (!response.ok || !data.id || !data.masked_url) {
+ throw new Error(data.detail || 'The download link could not be shortened.');
+ }
+ return data.masked_url;
+ }
+
+ downloadBtn.addEventListener('click', async () => {
+ const url = youtubeInput.value.trim();
+ clearMessages();
+
+ if (!url) {
+ showError('Please enter a YouTube URL.');
+ return;
+ }
+ if (!isYouTubeUrl(url)) {
+ showError('Please enter a valid YouTube or YouTube Music URL.');
+ return;
+ }
+
+ setLoading(true);
+
+ try {
+ setStatus('Extracting direct audio link natively...');
+ const downloadUrl = await getDownloadLink(url);
+
+ setStatus('Shortening link...');
+ const maskedUrl = await shortenUrl(downloadUrl);
+
+ maskedLink.href = maskedUrl;
+ maskedLink.textContent = maskedUrl;
+ copyBtn.setAttribute('data-url', maskedUrl);
+
+ resultDiv.style.display = 'block';
+ setStatus('Ready.');
+ } catch (error) {
+ console.error(error);
+ showError(error.message || 'Something went wrong.');
+ } finally {
+ setLoading(false);
+ }
+ });
+
+ youtubeClear.addEventListener('click', () => {
+ youtubeInput.value = '';
+ youtubeInput.focus();
+ youtubeClear.parentElement.removeAttribute('data-has-text');
+ clearMessages();
+ });
+
+ youtubeInput.addEventListener('input', () => {
+ if (youtubeInput.value.length > 0) {
+ youtubeClear.parentElement.setAttribute('data-has-text', 'true');
+ } else {
+ youtubeClear.parentElement.removeAttribute('data-has-text');
+ }
+ });
+
+ youtubeInput.addEventListener('keydown', event => {
+ if (event.key === 'Enter') {
+ event.preventDefault();
+ downloadBtn.click();
+ }
+ });
+
+ copyBtn.addEventListener('click', async () => {
+ const url = copyBtn.getAttribute('data-url');
+ if (!url) return;
+
+ try {
+ await navigator.clipboard.writeText(url);
+ const originalText = copyBtn.textContent;
+ const originalStyle = copyBtn.style.cssText;
+
+ copyBtn.textContent = 'Copied!';
+ copyBtn.style.borderColor = 'var(--success)';
+ copyBtn.style.color = 'var(--success)';
+
+ setTimeout(() => {
+ copyBtn.style.cssText = originalStyle;
+ copyBtn.textContent = originalText;
+ }, 1000);
+ } catch (error) {
+ console.error('Copy failed:', error);
+ }
+ });
+ }
+
+ document.addEventListener('turbo:load', setupYtMusic);
+ document.addEventListener('DOMContentLoaded', setupYtMusic);
+
+ if (!window.__ytmusicKbdBound) {
+ window.__ytmusicKbdBound = true;
+ document.addEventListener('keydown', event => {
+ if (event.key !== '/') return;
+ const tag = document.activeElement?.tagName || '';
+ if (tag === 'INPUT' || tag === 'TEXTAREA') return;
+
+ const currentYtInput = document.getElementById('youtube-input');
+ if (currentYtInput) {
+ event.preventDefault();
+ currentYtInput.focus();
+ }
+ });
+ }
+ })();
+ </script>
+</body>
+</html>
+\ No newline at end of file
diff --git a/render.yaml b/render.yaml
@@ -14,4 +14,13 @@ services:
value: raw.cdn.amit.is-a.dev
- key: RAW_BASE_URL
- value: https://raw.cdn.amit.is-a.dev
-\ No newline at end of file
+ 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"
+
+ - key: YOUTUBE_COOKIE_HEADER
+ sync: false
+\ No newline at end of file
diff --git a/start.sh b/start.sh
@@ -1,11 +1,122 @@
-#!/bin/sh
+#!/bin/bash
set -e
echo "========================================"
-echo "Starting CDN"
+echo "Starting CDN + Cobalt"
echo "========================================"
-exec uvicorn app.main:app \
+echo ""
+echo "========== COBALT ENV =========="
+echo "API_URL=${API_URL:-https://cdn.amit.is-a.dev/cobalt/}"
+echo "API_PORT=9000"
+echo "================================"
+echo ""
+
+COBALT_COOKIE_PATH=""
+
+if [ -n "$YOUTUBE_COOKIE_HEADER" ]; then
+ echo "YOUTUBE_COOKIE_HEADER is set - writing cookies.json..."
+
+ COBALT_COOKIE_PATH="/tmp/cobalt-cookies.json"
+
+ YOUTUBE_COOKIE_HEADER="$YOUTUBE_COOKIE_HEADER" python3 -c "
+import json
+import os
+
+cookie_header = os.environ['YOUTUBE_COOKIE_HEADER'].strip()
+
+with open('$COBALT_COOKIE_PATH', 'w') as f:
+ json.dump({'youtube': [cookie_header]}, f)
+"
+
+ echo "Wrote $COBALT_COOKIE_PATH"
+else
+ echo "YOUTUBE_COOKIE_HEADER not set - continuing without YouTube cookies"
+ echo "(YouTube downloads will likely fail with error.api.youtube.login)"
+fi
+
+echo ""
+echo "Starting Cobalt..."
+
+cd /opt/cobalt-api
+
+# NOTE: we intentionally do NOT gate startup on the presence of a specific
+# file (e.g. "src/cobalt"). Cobalt's own image runs it as
+# `node src/cobalt`, which Node resolves via its module resolution
+# algorithm (src/cobalt.js or src/cobalt/index.js) - there is no literal
+# file named "cobalt" with no extension. A hard existence check on that
+# exact path previously killed the whole container on every boot even
+# when the Cobalt build itself was fine. Instead we start the process and
+# let the readiness probe below be the real success/failure signal.
+if [ -n "$COBALT_COOKIE_PATH" ]; then
+ API_URL="https://cdn.amit.is-a.dev/cobalt/" \
+ API_PORT=9000 \
+ COOKIE_PATH="$COBALT_COOKIE_PATH" \
+ node src/cobalt &
+else
+ API_URL="https://cdn.amit.is-a.dev/cobalt/" \
+ API_PORT=9000 \
+ node src/cobalt &
+fi
+
+COBALT_PID=$!
+
+echo "Cobalt PID: $COBALT_PID"
+
+echo ""
+echo "Waiting for Cobalt to start..."
+
+COBALT_READY=0
+
+for i in $(seq 1 30); do
+ if python3 -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:9000/', timeout=1)" >/dev/null 2>&1; then
+ COBALT_READY=1
+ echo "Cobalt is ready."
+ break
+ fi
+
+ if ! kill -0 "$COBALT_PID" 2>/dev/null; then
+ echo "ERROR: Cobalt process exited unexpectedly during startup."
+ wait "$COBALT_PID"
+ exit 1
+ fi
+
+ sleep 1
+done
+
+if [ "$COBALT_READY" -ne 1 ]; then
+ echo "ERROR: Cobalt did not become ready within 30 seconds."
+ kill "$COBALT_PID" 2>/dev/null || true
+ exit 1
+fi
+
+echo ""
+echo "Starting FastAPI..."
+
+cd /app
+
+uvicorn app.main:app \
--host 0.0.0.0 \
- --port "${PORT:-8000}"
+ --port "${PORT:-8000}" &
+
+FASTAPI_PID=$!
+
+echo "FastAPI PID: $FASTAPI_PID"
+
+echo ""
+echo "========================================"
+echo "Both services started"
+echo "========================================"
+echo "Cobalt: http://127.0.0.1:9000"
+echo "FastAPI: http://0.0.0.0:${PORT:-8000}"
+echo "========================================"
+
+wait -n "$COBALT_PID" "$FASTAPI_PID"
+
+echo ""
+echo "ERROR: One of the services stopped."
+echo "Cobalt PID: $COBALT_PID"
+echo "FastAPI PID: $FASTAPI_PID"
+
+exit 1