cdnV2

git clone https://github.com/notamitgamer/cdnV2.git
· rebuilt hourlyOpen on GitHub
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:
MDockerfile | 5+++++
Mapp/main.py | 326+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mapp/templates/_navbar.html | 5+++++
Aapp/templates/ytmusic.html | 462+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mrender.yaml | 13+++++++++++--
Mstart.sh | 119++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
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 &mdash; 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>&copy; 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