import os import uuid import glob import json import subprocess import threading from flask import Flask, request, jsonify, send_file, render_template app = Flask(__name__) DOWNLOAD_DIR = os.path.join(os.path.dirname(__file__), "downloads") os.makedirs(DOWNLOAD_DIR, exist_ok=True) jobs = {} @app.after_request def add_security_headers(response): # Required for ffmpeg.wasm on /trim — needs SharedArrayBuffer, # which is only available in cross-origin isolated contexts. # `credentialless` is the most permissive — cross-origin resources # (Google Fonts, unpkg) load without credentials and don't need CORP headers. response.headers.setdefault('Cross-Origin-Opener-Policy', 'same-origin') response.headers.setdefault('Cross-Origin-Embedder-Policy', 'credentialless') return response def run_download(job_id, url, format_choice, format_id, audio_codec="mp3", video_codec="mp4"): job = jobs[job_id] out_template = os.path.join(DOWNLOAD_DIR, f"{job_id}.%(ext)s") cmd = ["yt-dlp", "--no-playlist", "-o", out_template] if format_choice == "audio": if format_id: cmd += ["-f", format_id] if audio_codec == "best": cmd += ["-x"] else: cmd += ["-x", "--audio-format", audio_codec] elif format_id: cmd += ["-f", f"{format_id}+bestaudio/best", "--merge-output-format", video_codec] else: cmd += ["-f", "bestvideo+bestaudio/best", "--merge-output-format", video_codec] cmd.append(url) try: result = subprocess.run(cmd, capture_output=True, text=True, timeout=300) if result.returncode != 0: job["status"] = "error" job["error"] = result.stderr.strip().split("\n")[-1] return files = glob.glob(os.path.join(DOWNLOAD_DIR, f"{job_id}.*")) if not files: job["status"] = "error" job["error"] = "Download completed but no file was found" return if format_choice == "audio": if audio_codec != "best": target = [f for f in files if f.endswith(f".{audio_codec}")] else: target = [f for f in files if not f.endswith(".mp4") and not f.endswith(".webm")] chosen = target[0] if target else files[0] else: target = [f for f in files if f.endswith(f".{video_codec}")] chosen = target[0] if target else files[0] for f in files: if f != chosen: try: os.remove(f) except OSError: pass job["status"] = "done" job["file"] = chosen ext = os.path.splitext(chosen)[1] is_audio = job.get("format_choice") == "audio" artist = job.get("artist", "") track = job.get("track", "") uploader = job.get("uploader", "") title = job.get("title", "") if is_audio and artist and track: raw_name = f"{artist} - {track}" elif uploader and title: raw_name = f"{uploader} - {title}" else: raw_name = title.strip() if raw_name: safe_name = "".join(c for c in raw_name if c not in r'\/:*?"<>|').strip()[:80].strip() job["filename"] = f"{safe_name}{ext}" if safe_name else os.path.basename(chosen) else: job["filename"] = os.path.basename(chosen) except subprocess.TimeoutExpired: job["status"] = "error" job["error"] = "Download timed out (5 min limit)" except Exception as e: job["status"] = "error" job["error"] = str(e) @app.route("/") def index(): return render_template("index.html") @app.route("/robots.txt") def robots(): return send_file(os.path.join(app.static_folder, "robots.txt"), mimetype="text/plain") @app.route("/llms.txt") def llms(): return send_file(os.path.join(app.static_folder, "llms.txt"), mimetype="text/plain") @app.route("/api/info", methods=["POST"]) def get_info(): data = request.json url = data.get("url", "").strip() if not url: return jsonify({"error": "No URL provided"}), 400 cmd = ["yt-dlp", "--no-playlist", "-j", url] try: result = subprocess.run(cmd, capture_output=True, text=True, timeout=60) if result.returncode != 0: return jsonify({"error": result.stderr.strip().split("\n")[-1]}), 400 info = json.loads(result.stdout) # Build quality options — keep best format per resolution best_by_height = {} audio_by_abr = {} for f in info.get("formats", []): if f.get("vcodec") == "none": abr = f.get("abr") if abr: abr = int(abr) if abr not in audio_by_abr or (f.get("asr", 0) > audio_by_abr[abr].get("asr", 0)): audio_by_abr[abr] = f continue height = f.get("height") if height and f.get("vcodec", "none") != "none": tbr = f.get("tbr") or 0 if height not in best_by_height or tbr > (best_by_height[height].get("tbr") or 0): best_by_height[height] = f formats = [] for height, f in best_by_height.items(): formats.append({ "id": f["format_id"], "label": f"{height}p", "height": height, }) formats.sort(key=lambda x: x["height"], reverse=True) audio_formats = [] for abr, f in audio_by_abr.items(): audio_formats.append({ "id": f["format_id"], "label": f"{abr}kbps", "abr": abr, }) audio_formats.sort(key=lambda x: x["abr"], reverse=True) art = info.get("artist") artist_str = ", ".join(art) if isinstance(art, list) else str(art or "").strip() return jsonify({ "title": info.get("title", ""), "thumbnail": info.get("thumbnail", ""), "duration": info.get("duration"), "uploader": info.get("uploader", ""), "artist": artist_str, "track": str(info.get("track") or "").strip(), "formats": formats, "audioFormats": audio_formats, }) except subprocess.TimeoutExpired: return jsonify({"error": "Timed out fetching video info"}), 400 except Exception as e: return jsonify({"error": str(e)}), 400 @app.route("/api/download", methods=["POST"]) def start_download(): data = request.json url = data.get("url", "").strip() format_choice = data.get("format", "video") format_id = data.get("format_id") audio_codec = data.get("audio_codec", "mp3") video_codec = data.get("video_codec", "mp4") title = data.get("title", "") artist = data.get("artist", "") track = data.get("track", "") uploader = data.get("uploader", "") if not url: return jsonify({"error": "No URL provided"}), 400 job_id = uuid.uuid4().hex[:10] jobs[job_id] = { "status": "downloading", "url": url, "title": title, "artist": artist, "track": track, "uploader": uploader, "format_choice": format_choice, "audio_codec": audio_codec, "video_codec": video_codec } thread = threading.Thread(target=run_download, args=(job_id, url, format_choice, format_id, audio_codec, video_codec)) thread.daemon = True thread.start() return jsonify({"job_id": job_id}) @app.route("/api/status/") def check_status(job_id): job = jobs.get(job_id) if not job: return jsonify({"error": "Job not found"}), 404 return jsonify({ "status": job["status"], "error": job.get("error"), "filename": job.get("filename"), }) @app.route("/api/file/") @app.route("/api/file//") def download_file(job_id, filename=None): job = jobs.get(job_id) if not job or job["status"] != "done": return jsonify({"error": "File not ready"}), 404 return send_file(job["file"], as_attachment=True, download_name=job["filename"]) TRIM_BACKEND_ENABLED = os.environ.get("RECLIP_TRIM_BACKEND", "enabled").lower() != "disabled" @app.route("/trim") def trim_page(): return render_template("trim.html") @app.route("/api/capabilities") def capabilities(): return jsonify({"trimBackend": TRIM_BACKEND_ENABLED}) @app.route("/api/recent-downloads") def recent_downloads(): """Audio downloads completed in this session, surfaced on the trim page.""" audio = [] for jid, job in jobs.items(): if job.get("status") == "done" and job.get("format_choice") == "audio": audio.append({ "id": jid, "filename": job.get("filename", ""), "title": job.get("title", ""), }) return jsonify(audio) def _parse_time(value, fallback=None): if value is None or value == "": return fallback s = str(value).strip() if ":" in s: parts = s.split(":") total = 0.0 for p in parts: total = total * 60 + float(p) return total return float(s) @app.route("/api/trim", methods=["POST"]) def trim_audio(): if not TRIM_BACKEND_ENABLED: return jsonify({"error": "Trim backend disabled on this instance"}), 503 file_obj = request.files.get("file") job_id = request.form.get("job_id") try: start = _parse_time(request.form.get("start"), 0.0) end = _parse_time(request.form.get("end")) except ValueError: return jsonify({"error": "Invalid start/end time"}), 400 mode = (request.form.get("mode") or "copy").lower() if mode not in ("copy", "encode"): mode = "copy" trim_id = uuid.uuid4().hex[:10] cleanup_src = False if file_obj and file_obj.filename: ext = os.path.splitext(file_obj.filename)[1].lower() or ".aac" src_path = os.path.join(DOWNLOAD_DIR, f"trim_src_{trim_id}{ext}") file_obj.save(src_path) cleanup_src = True original_name = os.path.splitext(file_obj.filename)[0] elif job_id: job = jobs.get(job_id) if not job or job.get("status") != "done": return jsonify({"error": "Source download not found or not complete"}), 404 src_path = job["file"] ext = os.path.splitext(src_path)[1].lower() original_name = os.path.splitext(job.get("filename", "audio"))[0] else: return jsonify({"error": "No source file or job_id provided"}), 400 out_ext = ext if ext else ".aac" out_path = os.path.join(DOWNLOAD_DIR, f"trim_out_{trim_id}{out_ext}") # Raw .aac has no timestamps → `-c copy` with -ss/-to silently produces a # 0-byte file. Force re-encode for .aac regardless of mode. must_reencode = ext == ".aac" use_copy = mode == "copy" and not must_reencode if use_copy: cmd = ["ffmpeg", "-y", "-ss", str(start)] if end is not None: cmd += ["-to", str(end)] cmd += ["-i", src_path, "-c", "copy", out_path] else: cmd = ["ffmpeg", "-y", "-i", src_path, "-ss", str(start)] if end is not None: cmd += ["-to", str(end)] cmd += ["-c:a", "aac", "-b:a", "192k", out_path] try: result = subprocess.run(cmd, capture_output=True, text=True, timeout=180) if result.returncode != 0: return jsonify({"error": result.stderr.strip()[-400:] or "ffmpeg failed"}), 500 except subprocess.TimeoutExpired: return jsonify({"error": "Trim timed out (3 min limit)"}), 500 finally: if cleanup_src and os.path.exists(src_path): try: os.remove(src_path) except OSError: pass download_name = f"{original_name}_trimmed{out_ext}" return send_file(out_path, as_attachment=True, download_name=download_name) if __name__ == "__main__": port = int(os.environ.get("PORT", 7860)) host = os.environ.get("HOST", "127.0.0.1") app.run(host=host, port=port)