cdn

Log | Files | Refs | Activity

commit 3041005f3add1cbd95331bb7416650045b66556b
parent b40e43cc9747aa11c40d5098b0730a32d60e022b
Author: Amit Dutta <mail@amit.is-a.dev>
Date:   Thu,  3 Sep 2026 15:52:50 +0530

Merge pull request #4 from notamitgamer/feature/dark-ui-multiupload-ytdlp-fix

Dark UI, raw hyperlinks, multi-file drag-drop upload, yt-dlp android/ios client fix
Diffstat:
MDockerfile | 6++++++
Mapp/main.py | 38++++++++++++++++++++++----------------
Mapp/templates/index.html | 287++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------
Mapp/ytmusic.py | 9++++++++-
4 files changed, 242 insertions(+), 98 deletions(-)

diff --git a/Dockerfile b/Dockerfile @@ -10,6 +10,12 @@ WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt +# yt-dlp needs to keep up with YouTube's frequent changes — always pull +# the latest release on build instead of relying on the requirements.txt +# layer cache, which would otherwise pin whatever version was current +# the last time requirements.txt itself changed. +RUN pip install --no-cache-dir -U yt-dlp + # Copy the app structure COPY app/ ./app/ diff --git a/app/main.py b/app/main.py @@ -1,5 +1,6 @@ import os import shutil +import uuid import httpx from fastapi import FastAPI, Request, Form, File, UploadFile, Depends, HTTPException, BackgroundTasks from fastapi.responses import StreamingResponse, HTMLResponse @@ -83,23 +84,28 @@ async def download_file(path: str): return StreamingResponse(stream_generator(), headers=headers) -# Phase 4: Token-protected Upload endpoint +# Phase 4/8: Token-protected multi-file upload endpoint @app.post("/api/upload") -async def handle_upload(file: UploadFile = File(...), _ = Depends(verify_token)): - temp_path = f"/tmp/{file.filename}" - with open(temp_path, "wb") as f: - shutil.copyfileobj(file.file, f) - - try: - hf_path = upload_temp_file(temp_path, file.filename) - finally: - if os.path.exists(temp_path): - os.remove(temp_path) - - return { - "cdn_url": f"{CDN_BASE_URL}/{hf_path}", - "raw_url": f"{CDN_BASE_URL}/{RAW_PREFIX}{hf_path}" - } +async def handle_upload(files: list[UploadFile] = File(...), _ = Depends(verify_token)): + results = [] + for file in files: + temp_path = f"/tmp/{uuid.uuid4()}-{file.filename}" + with open(temp_path, "wb") as f: + shutil.copyfileobj(file.file, f) + + try: + hf_path = upload_temp_file(temp_path, file.filename) + finally: + if os.path.exists(temp_path): + os.remove(temp_path) + + results.append({ + "filename": file.filename, + "cdn_url": f"{CDN_BASE_URL}/{hf_path}", + "raw_url": f"{CDN_BASE_URL}/{RAW_PREFIX}{hf_path}" + }) + + return {"files": results} # Phase 5: Public, rate-limited YouTube Music downloader @app.post("/api/ytmusic") diff --git a/app/templates/index.html b/app/templates/index.html @@ -4,35 +4,106 @@ <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>CDN | {{ page | capitalize }}</title> - <!-- Phase 3: Minimal, bare-but-fresh frontend (CSS included inline to enforce single-file HTML mandate) --> <style> - :root { --accent: #2563eb; --bg: #f8fafc; --text: #0f172a; --border: #e2e8f0; } - body { font-family: system-ui, -apple-system, sans-serif; background: var(--bg); color: var(--text); margin: 0; padding: 2rem; } - .container { max-width: 800px; margin: 0 auto; background: white; padding: 2rem; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); } + :root { + --accent: #60a5fa; + --accent-dark: #3b82f6; + --bg: #0b0f14; + --surface: #12171f; + --surface-2: #1a212b; + --text: #e6edf3; + --text-dim: #8b96a5; + --border: #232b36; + --danger-bg: #3a1518; + --danger-text: #ffb4ab; + } + * { box-sizing: border-box; } + body { + font-family: system-ui, -apple-system, "Segoe UI", sans-serif; + background: var(--bg); + color: var(--text); + margin: 0; + padding: 2.5rem 1.5rem; + } + .container { + max-width: 800px; + margin: 0 auto; + background: var(--surface); + padding: 2rem; + border-radius: 12px; + border: 1px solid var(--border); + } a { color: var(--accent); text-decoration: none; } a:hover { text-decoration: underline; } - .nav { margin-bottom: 2rem; font-size: 1.1rem; } - .btn { display: inline-block; background: var(--accent); color: white; padding: 0.5rem 1rem; border-radius: 4px; border: none; cursor: pointer; text-decoration: none; font-size: 0.9rem; } - .btn:hover { background: #1d4ed8; text-decoration: none; } - .btn-secondary { background: #f1f5f9; color: var(--text); border: 1px solid var(--border); } - .btn-secondary:hover { background: #e2e8f0; } - + .nav { margin-bottom: 2rem; font-size: 0.95rem; color: var(--text-dim); } + .nav a { color: var(--text-dim); } + .nav a:hover { color: var(--accent); } + + .btn { + display: inline-block; + background: var(--accent-dark); + color: #fff; + padding: 0.55rem 1.1rem; + border-radius: 6px; + border: none; + cursor: pointer; + text-decoration: none; + font-size: 0.9rem; + font-weight: 500; + } + .btn:hover { background: var(--accent); text-decoration: none; } + .btn:disabled { opacity: 0.5; cursor: not-allowed; } + .btn-secondary { background: var(--surface-2); color: var(--text); border: 1px solid var(--border); } + .btn-secondary:hover { background: #232b36; } + table { width: 100%; border-collapse: collapse; margin-top: 1rem; } th, td { text-align: left; padding: 0.75rem; border-bottom: 1px solid var(--border); } - th { font-weight: 500; color: #64748b; } - - .form-group { margin-bottom: 1rem; } - .form-group label { display: block; margin-bottom: 0.5rem; font-weight: 500; } - .form-group input { width: 100%; padding: 0.5rem; border: 1px solid var(--border); border-radius: 4px; } - - #result { margin-top: 1rem; padding: 1rem; background: #f1f5f9; border-radius: 4px; display: none; word-break: break-all; } + th { font-weight: 500; color: var(--text-dim); font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.03em; } + tr:last-child td { border-bottom: none; } + + .form-group { margin-bottom: 1.2rem; } + .form-group label { display: block; margin-bottom: 0.5rem; font-weight: 500; font-size: 0.9rem; color: var(--text-dim); } + .form-group input { + width: 100%; padding: 0.6rem 0.75rem; border: 1px solid var(--border); + border-radius: 6px; background: var(--surface-2); color: var(--text); font-size: 0.95rem; + } + .form-group input:focus { outline: none; border-color: var(--accent); } + + #dropzone { + border: 2px dashed var(--border); + border-radius: 10px; + padding: 2.5rem 1.5rem; + text-align: center; + cursor: pointer; + background: var(--surface-2); + transition: border-color 0.15s, background 0.15s; + margin-bottom: 1.5rem; + } + #dropzone.dragover { border-color: var(--accent); background: #16202e; } + #dropzone .hint { color: var(--text-dim); font-size: 0.85rem; margin-top: 0.4rem; } + + .file-row { + display: flex; align-items: center; justify-content: space-between; + padding: 0.7rem 0.9rem; background: var(--surface-2); border-radius: 8px; + margin-bottom: 0.6rem; gap: 1rem; + } + .file-row .name { font-size: 0.9rem; word-break: break-all; } + .file-row .status { font-size: 0.8rem; color: var(--text-dim); white-space: nowrap; } + .file-row.done .status { color: #4ade80; } + .file-row.error .status { color: var(--danger-text); } + .file-row .links { font-size: 0.8rem; margin-top: 0.2rem; } + .file-row .links a { margin-right: 0.8rem; } + + #error-box { + display: none; background: var(--danger-bg); color: var(--danger-text); + padding: 0.9rem 1rem; border-radius: 8px; margin-bottom: 1.2rem; font-size: 0.9rem; + } </style> </head> <body> <div class="container"> - <!-- Breadcrumb Navigation --> <nav class="nav"> - <a href="/">~ Root</a> + <a href="/">~ Root</a> {% if page in ['listing', 'file'] and path %} {% set parts = path.split('/') %} {% set current = namespace(val='') %} @@ -51,16 +122,12 @@ {% if page == 'listing' %} <table> <thead> - <tr> - <th>Name</th> - <th>Size</th> - <th>Action</th> - </tr> + <tr><th>Name</th><th>Size</th><th>Action</th></tr> </thead> <tbody> {% if path %} <tr> - <td><a href="../">📁 ..</a></td> + <td><a href="../">.. (parent)</a></td> <td>-</td> <td></td> </tr> @@ -69,9 +136,9 @@ <tr> <td> {% if item.is_dir %} - 📁 <a href="/{{ item.path }}">{{ item.name }}</a> + <a href="/{{ item.path }}">{{ item.name }}/</a> {% else %} - 📄 <a href="/{{ item.path }}">{{ item.name }}</a> + <a href="/raw/{{ item.path }}">{{ item.name }}</a> {% endif %} </td> <td>{{ item.size_str }}</td> @@ -85,7 +152,7 @@ </tr> {% endfor %} {% if not items %} - <tr><td colspan="3" style="text-align: center; padding: 2rem;">Empty directory</td></tr> + <tr><td colspan="3" style="text-align: center; padding: 2rem; color: var(--text-dim);">Empty directory</td></tr> {% endif %} </tbody> </table> @@ -93,76 +160,135 @@ <!-- Single File View --> {% elif page == 'file' %} <div style="text-align: center; padding: 3rem 0;"> - <h2>📄 {{ filename }}</h2> - <p style="color: #64748b; margin-bottom: 2rem;">{{ path }}</p> - <div style="display: flex; gap: 1rem; justify-content: center;"> + <h2>{{ filename }}</h2> + <p style="color: var(--text-dim); margin-bottom: 2rem;">{{ path }}</p> + <div style="display: flex; gap: 1rem; justify-content: center; flex-wrap: wrap;"> <a href="/api/download/{{ path }}" class="btn">Download File</a> - <button onclick="copyToClipboard(window.location.origin + '/{{ path }}')" class="btn btn-secondary">Copy Link</button> - <button onclick="copyToClipboard(window.location.origin + '/raw/{{ path }}')" class="btn btn-secondary">Copy Raw Link</button> + <a href="/raw/{{ path }}" class="btn btn-secondary">Open Raw</a> + <button onclick="copyToClipboard(window.location.origin + '/' + '{{ path }}')" class="btn btn-secondary">Copy Link</button> + <button onclick="copyToClipboard(window.location.origin + '/raw/' + '{{ path }}')" class="btn btn-secondary">Copy Raw Link</button> </div> </div> - <!-- Token-Protected Upload View --> + <!-- Token-Protected Multi-File Upload View --> {% elif page == 'upload' %} - <h2>Upload File</h2> - <form id="uploadForm"> - <div class="form-group"> - <label>Admin Token</label> - <input type="password" id="token" required> - </div> - <div class="form-group"> - <label>File</label> - <input type="file" id="file" required> - </div> - <button type="submit" class="btn">Upload to CDN</button> - </form> - <div id="result"></div> + <h2>Upload Files</h2> + <div class="form-group"> + <label>Admin Token</label> + <input type="password" id="token" placeholder="Bearer token" required> + </div> + + <div id="dropzone"> + <div>Click, or drag &amp; drop files here</div> + <div class="hint">Multiple files supported &middot; any type</div> + <input type="file" id="file-input" multiple style="display:none;"> + </div> + + <div id="error-box"></div> + <div id="results"></div> + <script> - document.getElementById('uploadForm').addEventListener('submit', async (e) => { + const dropzone = document.getElementById('dropzone'); + const fileInput = document.getElementById('file-input'); + const tokenInput = document.getElementById('token'); + const results = document.getElementById('results'); + const errorBox = document.getElementById('error-box'); + + dropzone.addEventListener('click', () => fileInput.click()); + dropzone.addEventListener('dragover', (e) => { e.preventDefault(); dropzone.classList.add('dragover'); }); + dropzone.addEventListener('dragleave', () => dropzone.classList.remove('dragover')); + dropzone.addEventListener('drop', (e) => { e.preventDefault(); - const btn = e.target.querySelector('button'); - btn.textContent = 'Uploading...'; - btn.disabled = true; - + dropzone.classList.remove('dragover'); + if (e.dataTransfer.files.length) handleFiles(e.dataTransfer.files); + }); + fileInput.addEventListener('change', () => { + if (fileInput.files.length) handleFiles(fileInput.files); + }); + + function showError(msg) { + errorBox.textContent = msg; + errorBox.style.display = 'block'; + } + + function handleFiles(fileList) { + errorBox.style.display = 'none'; + const token = tokenInput.value.trim(); + if (!token) { showError('Enter your admin token first.'); return; } + + const files = Array.from(fileList); + const rows = {}; + files.forEach(f => { + const row = document.createElement('div'); + row.className = 'file-row'; + row.innerHTML = `<div><div class="name">${f.name}</div><div class="links"></div></div><div class="status">Uploading...</div>`; + results.prepend(row); + rows[f.name] = row; + }); + const formData = new FormData(); - formData.append('file', document.getElementById('file').files[0]); - - try { - const res = await fetch('/api/upload', { - method: 'POST', - headers: { 'Authorization': 'Bearer ' + document.getElementById('token').value }, - body: formData + files.forEach(f => formData.append('files', f)); + + const xhr = new XMLHttpRequest(); + xhr.open('POST', '/api/upload', true); + xhr.setRequestHeader('Authorization', 'Bearer ' + token); + + xhr.upload.onprogress = (e) => { + if (!e.lengthComputable) return; + const pct = Math.round((e.loaded / e.total) * 100); + files.forEach(f => { rows[f.name].querySelector('.status').textContent = pct + '%'; }); + }; + + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) { + try { + const data = JSON.parse(xhr.responseText); + data.files.forEach(item => { + const row = rows[item.filename]; + if (!row) return; + row.classList.add('done'); + row.querySelector('.status').textContent = 'Done'; + row.querySelector('.links').innerHTML = + `<a href="${item.raw_url}" target="_blank">Raw link</a>` + + `<a href="${item.cdn_url}" target="_blank">View</a>`; + }); + } catch (err) { + showError('Invalid response from server.'); + } + } else { + let msg = 'Upload failed.'; + try { msg = JSON.parse(xhr.responseText).detail || msg; } catch (e) {} + showError(msg); + files.forEach(f => { + rows[f.name].classList.add('error'); + rows[f.name].querySelector('.status').textContent = 'Failed'; + }); + } + fileInput.value = ''; + }; + + xhr.onerror = () => { + showError('Could not connect to server.'); + files.forEach(f => { + rows[f.name].classList.add('error'); + rows[f.name].querySelector('.status').textContent = 'Failed'; }); - - if (!res.ok) throw new Error('Upload failed: ' + res.statusText); - const data = await res.json(); - - const resultDiv = document.getElementById('result'); - resultDiv.style.display = 'block'; - resultDiv.innerHTML = ` - <strong>Success!</strong><br><br> - CDN Link: <a href="${data.cdn_url}" target="_blank">${data.cdn_url}</a><br> - Raw Link: <a href="${data.raw_url}" target="_blank">${data.raw_url}</a> - `; - } catch (err) { - alert(err.message); - } finally { - btn.textContent = 'Upload to CDN'; - btn.disabled = false; - } - }); + }; + + xhr.send(formData); + } </script> <!-- YT Music Converter View --> {% elif page == 'ytmusic' %} <h2>YouTube Music Downloader</h2> - <p>Paste a YouTube Music link to download it directly as an MP3.</p> + <p style="color: var(--text-dim);">Paste a YouTube Music link. It downloads straight to your device as an MP3 — nothing is stored on the CDN.</p> <form method="POST" action="/api/ytmusic"> <div class="form-group"> <label>YouTube URL</label> <input type="url" name="url" placeholder="https://music.youtube.com/watch?v=..." required> </div> - <button type="submit" class="btn" onclick="this.textContent='Processing... Please wait';">Download MP3</button> + <button type="submit" class="btn">Download MP3</button> </form> {% endif %} </div> @@ -177,4 +303,4 @@ } </script> </body> -</html>- \ No newline at end of file +</html> diff --git a/app/ytmusic.py b/app/ytmusic.py @@ -20,13 +20,20 @@ def process_and_stream_ytmusic(url: str, background_tasks: BackgroundTasks): try: ydl_opts = { - 'format': 'bestaudio', + 'format': 'bestaudio/best', 'extract_audio': True, 'audio_format': 'mp3', 'audio_quality': '0', 'outtmpl': os.path.join(temp_dir, '%(artist)s - %(title)s.%(ext)s'), 'noplaylist': True, 'match_filter': filter_duration_and_live, + # The default web player client requires solving YouTube's + # signature/n-challenge, which needs an external JS runtime + # yt-dlp doesn't ship. The android/ios clients receive + # pre-resolved stream URLs and skip that challenge entirely. + 'extractor_args': { + 'youtube': {'player_client': ['android', 'ios']} + }, } # Optional: YT_COOKIES env var holding the full contents of a
© notamitgamer • Site Built: 2026-09-05 01:53:16 UTC • git-mirror commit: c170d72 [view raw info]
Originally created with stagit • modified by notamitgamer
Forked from github.com/notamitgamer/git-mirror