diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dfd98bf --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +*/__pycache__ +*/__pycache__/* \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..28a51d5 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "compile-hero.disable-compile-files-on-did-save-code": false +} \ No newline at end of file diff --git a/backend/Dockerfile b/Dockerfile similarity index 76% rename from backend/Dockerfile rename to Dockerfile index c7a4907..f33378f 100644 --- a/backend/Dockerfile +++ b/Dockerfile @@ -6,8 +6,7 @@ RUN apt-get update && apt-get install -y ffmpeg curl && \ chmod a+rx /usr/local/bin/yt-dlp WORKDIR /app -COPY requirements.txt . -RUN pip install -r requirements.txt COPY . . +RUN pip install -r backend/requirements.txt -CMD ["python", "main.py"] \ No newline at end of file +CMD ["python", "backend/main.py"] \ No newline at end of file diff --git a/backend/main.py b/backend/main.py index 67ab2a3..68d3c86 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,10 +1,26 @@ from flask import Flask, request, Response, send_from_directory, jsonify -import subprocess import requests +from flask_cors import CORS +import urllib.parse +from requests.adapters import HTTPAdapter +from urllib3.util import Retry +import yt_dlp +import io -app = Flask(__name__, static_folder='../frontend', static_url_path='') +# Serve frontend static files under `/static` to avoid colliding with API routes +app = Flask(__name__, static_folder='../frontend', static_url_path='/static') app.url_map.strict_slashes = False +# Use flask-cors for API routes +CORS(app, resources={r"/api/*": {"origins": "*"}}) + +# Configure a requests session with retries +session = requests.Session() +retries = Retry(total=2, backoff_factor=0.2, status_forcelist=(500, 502, 503, 504)) +adapter = HTTPAdapter(max_retries=retries) +session.mount('http://', adapter) +session.mount('https://', adapter) + @app.route('/api/status', methods=['POST', 'GET']) def proxy_status(): if request.method == 'POST': @@ -16,15 +32,74 @@ def proxy_status(): if not target_server: return jsonify({"error": "No server provided"}), 400 + if target_server.endswith('/'): + target_server = target_server[:-1] + target_server = f"{target_server.strip()}/api/status" + # Validate target URL + parsed = urllib.parse.urlparse(target_server) + if parsed.scheme not in ('http', 'https') or not parsed.netloc: + return jsonify({"error": "Invalid target URL"}), 400 try: - # Use the data gathered above - response = requests.post(target_server, json=client_data if request.method == 'POST' else {}, timeout=5) - return (response.content, response.status_code, response.headers.items()) + # Forward a small set of safe request headers + safe_request_headers = {} + for k in ('User-Agent', 'Accept', 'Accept-Encoding', 'Accept-Language', 'Range'): + if k in request.headers: + safe_request_headers[k] = request.headers[k] + + # Remove hop-by-hop request headers per RFC + for hop in ('Connection', 'Keep-Alive', 'Proxy-Authenticate', 'Proxy-Authorization', 'TE', 'Trailers', 'Transfer-Encoding', 'Upgrade'): + safe_request_headers.pop(hop, None) + + # Stream the GET via a session with small retry policy + resp = session.get(target_server, headers=safe_request_headers, timeout=5, stream=True) + + hop_by_hop = { + 'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', + 'te', 'trailers', 'transfer-encoding', 'upgrade' + } + + forwarded_headers = [] + for name, value in resp.headers.items(): + if name.lower() in hop_by_hop: + continue + if name.lower() == 'content-length': + # Let Flask set Content-Length if needed for the assembled response + continue + forwarded_headers.append((name, value)) + + def generate(): + try: + for chunk in resp.iter_content(1024 * 16): + if chunk: + yield chunk + finally: + resp.close() + + return Response(generate(), status=resp.status_code, headers=forwarded_headers) except Exception as e: return jsonify({"error": str(e)}), 500 +@app.route('/api/videos', methods=['POST']) +def videos_proxy(): + client_data = request.get_json() or {} + target_server = client_data.get('server') + client_data.pop('server', None) # Remove server from payload + if not target_server: + return jsonify({"error": "No server provided"}), 400 + if target_server.endswith('/'): + target_server = target_server[:-1] + target_server = f"{target_server.strip()}/api/videos" + # Validate target URL + parsed = urllib.parse.urlparse(target_server) + if parsed.scheme not in ('http', 'https') or not parsed.netloc: + return jsonify({"error": "Invalid target URL"}), 400 + try: + resp = session.post(target_server, json=client_data,timeout=5) + return Response(resp.content, status=resp.status_code, content_type=resp.headers.get('Content-Type', 'application/json')) + except Exception as e: + return jsonify({"error": str(e)}), 500 @app.route('/') def index(): @@ -39,24 +114,64 @@ def stream_video(): video_url = request.json.get('url') else: video_url = request.args.get('url') + + if not video_url: + return jsonify({"error": "No URL provided"}), 400 def generate(): - # yt-dlp command to get the stream and pipe to stdout - cmd = [ - 'yt-dlp', - '-o', '-', # output to stdout - '-f', 'best[ext=mp4]/best', - video_url - ] - process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) try: - while True: - chunk = process.stdout.read(1024 * 16) - if not chunk: - break - yield chunk - finally: - process.kill() + # Configure yt-dlp options + ydl_opts = { + 'format': 'best[ext=mp4]/best[vcodec^=avc1]/best[vcodec^=vp]/best', + 'quiet': True, + 'no_warnings': True, + 'socket_timeout': 30, + 'retries': 3, + 'fragment_retries': 3, + 'http_headers': { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' + }, + 'skip_unavailable_fragments': True + } + + with yt_dlp.YoutubeDL(ydl_opts) as ydl: + # Extract the info + info = ydl.extract_info(video_url, download=False) + + # Try to get the URL from the info dict (works for progressive downloads) + stream_url = info.get('url') + format_id = info.get('format_id') + + # If no direct URL, try to get it from formats + if not stream_url and 'formats' in info: + # Find the best format that has a URL + for fmt in info['formats']: + if fmt.get('url'): + stream_url = fmt.get('url') + break + + if not stream_url: + yield b"Error: Could not extract stream URL" + return + + # Prepare headers for the stream request + headers = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' + } + + # Add any cookies or authentication headers from yt-dlp + if 'http_headers' in info: + headers.update(info['http_headers']) + + # Stream the video from the extracted URL + resp = session.get(stream_url, headers=headers, stream=True, timeout=30, allow_redirects=True) + resp.raise_for_status() + + for chunk in resp.iter_content(chunk_size=1024 * 16): + if chunk: + yield chunk + except Exception as e: + yield f"Error: {str(e)}".encode() return Response(generate(), mimetype='video/mp4') diff --git a/backend/requirements.txt b/backend/requirements.txt index c508a4b..eca6323 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -3,6 +3,7 @@ certifi==2026.1.4 charset-normalizer==3.4.4 click==8.3.1 Flask==3.1.2 +Flask-Cors==4.0.0 idna==3.11 itsdangerous==2.2.0 Jinja2==3.1.6 @@ -11,3 +12,4 @@ MarkupSafe==3.0.3 requests==2.32.5 urllib3==2.6.3 Werkzeug==3.1.5 +yt-dlp==2026.1.29 diff --git a/frontend/app.js b/frontend/app.js index 5a3b7ad..1fe7485 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -1,106 +1,151 @@ +// 1. Global State and Constants (Declare these first!) let currentPage = 1; const perPage = 12; +const renderedVideoIds = new Set(); -localStorage.clear(); - -function InitializeLocalStorage() { - if (!localStorage.getItem('config')) { - localStorage.setItem('config', JSON.stringify({ servers: [{ "https://getfigleaf.com": {} }] })); - InitializeServerStatus(); - } -} - -function InitializeServerStatus() { - const config = JSON.parse(localStorage.getItem('config')); - config.servers.forEach(serverObj => { - const server = Object.keys(serverObj)[0]; - fetch(`/api/status`, { - method: "POST", - body: JSON.stringify({ server: server }), - headers: { - "Content-Type": "application/json", - }, - }) - .then(response => response.json()) - .then(status => { - serverObj[server] = status; - localStorage.setItem('config', JSON.stringify(config)); - }) - .catch(err => { - serverObj[server] = { online: false }; - localStorage.setItem('config', JSON.stringify(config)); - }); - }); -} - -async function loadVideos() { - const config = JSON.parse(localStorage.getItem('config')); - const response = await fetch('/api/videos', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - channel: config.channel, - sort: "latest", - query: "", - page: currentPage, - perPage: perPage - }) - }); - const videos = await response.json(); - renderVideos(videos); - currentPage++; -} - -function renderVideos(videos) { - const grid = document.getElementById('video-grid'); - videos.forEach(v => { - const card = document.createElement('div'); - card.className = 'video-card'; - card.innerHTML = ` - ${v.title} -

${v.title}

-

${v.channel} • ${v.duration}s

- `; - card.onclick = () => openPlayer(v.url); - grid.appendChild(card); - }); -} - -function openPlayer(videoUrl) { - const modal = document.getElementById('video-modal'); - const player = document.getElementById('player'); - // Using GET for the video tag src as it's the standard for streaming - player.src = `/api/stream?url=${encodeURIComponent(videoUrl)}`; - modal.style.display = 'block'; -} - -function closePlayer() { - const modal = document.getElementById('video-modal'); - const player = document.getElementById('player'); - player.pause(); - player.src = ""; - modal.style.display = 'none'; -} - -// UI Helpers -function toggleDrawer(id) { - document.querySelectorAll('.drawer').forEach(d => d.classList.remove('open')); - document.getElementById(`drawer-${id}`).classList.add('open'); - document.getElementById('overlay').style.display = 'block'; -} - -function closeDrawers() { - document.querySelectorAll('.drawer').forEach(d => d.classList.remove('open')); - document.getElementById('overlay').style.display = 'none'; -} - -// Infinite Scroll Observer +// 2. Observer Definition (Must be defined before initApp uses it) const observer = new IntersectionObserver((entries) => { if (entries[0].isIntersecting) loadVideos(); }, { threshold: 1.0 }); -observer.observe(document.getElementById('sentinel')); +// 3. Logic Functions +async function InitializeLocalStorage() { + if (!localStorage.getItem('config')) { + localStorage.setItem('config', JSON.stringify({ servers: [{ "https://getfigleaf.com": {} }] })); + } + // We always run this to make sure session is fresh + await InitializeServerStatus(); +} -// Init -InitializeLocalStorage(); -loadVideos(); \ No newline at end of file +async function InitializeServerStatus() { + const config = JSON.parse(localStorage.getItem('config')); + if (!config || !config.servers) return; + + const statusPromises = config.servers.map(async (serverObj) => { + const server = Object.keys(serverObj)[0]; + try { + const response = await fetch(`/api/status`, { + method: "POST", + body: JSON.stringify({ server: server }), + headers: { "Content-Type": "application/json" }, + }); + const status = await response.json(); + serverObj[server] = status; + } catch (err) { + serverObj[server] = { online: false, channels: [] }; + } + }); + + await Promise.all(statusPromises); + localStorage.setItem('config', JSON.stringify(config)); + + const firstServerKey = Object.keys(config.servers[0])[0]; + const serverData = config.servers[0][firstServerKey]; + + if (serverData.channels && serverData.channels.length > 0) { + const channel = serverData.channels[0]; + let options = {}; + + if (channel.options) { + channel.options.forEach(element => { + // Ensure the options structure matches your API expectations + options[element.id] = element.options[0]; + }); + } + + const sessionData = { + server: firstServerKey, + channel: channel, + options: options, + }; + + localStorage.setItem('session', JSON.stringify(sessionData)); + } +} + +async function loadVideos() { + const session = JSON.parse(localStorage.getItem('session')); + if (!session) return; + + // Build the request body + let body = { + channel: session.channel.id, + query: "", + page: currentPage, + perPage: perPage, + server: session.server + }; + + // Correct way to loop through the options object + Object.entries(session.options).forEach(([key, value]) => { + body[key] = value.id; + }); + + try { + const response = await fetch('/api/videos', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + const videos = await response.json(); + renderVideos(videos); + currentPage++; + } catch (err) { + console.error("Failed to load videos:", err); + } +} + +function renderVideos(videos) { + const grid = document.getElementById('video-grid'); + if (!grid) return; + + videos.items.forEach(v => { + if (renderedVideoIds.has(v.id)) return; + + const card = document.createElement('div'); + card.className = 'video-card'; + const durationText = v.duration === 0 ? '' : ` • ${v.duration}s`; + card.innerHTML = ` + ${v.title} +

${v.title}

+

${v.channel}${durationText}

+ `; + card.onclick = () => openPlayer(v.url); + grid.appendChild(card); + renderedVideoIds.add(v.id); + }); +} + +// 4. Initialization (Run this last) +async function initApp() { + // Clear old data if you want a fresh start every refresh + // localStorage.clear(); + + await InitializeLocalStorage(); + + const sentinel = document.getElementById('sentinel'); + if (sentinel) { + observer.observe(sentinel); + } + + await loadVideos(); +} + +function openPlayer(url) { + const modal = document.getElementById('video-modal'); + const video = document.getElementById('player'); + video.src = `/api/stream?url=${encodeURIComponent(url)}`; + modal.style.display = 'flex'; + document.body.style.overflow = 'hidden'; +} + +function closePlayer() { + const modal = document.getElementById('video-modal'); + const video = document.getElementById('player'); + video.pause(); + video.src = ''; + modal.style.display = 'none'; + document.body.style.overflow = 'auto'; +} + +initApp(); \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html index 6f62e25..c88aa20 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,7 +4,7 @@ Hottub - +
@@ -32,6 +32,6 @@ - + \ No newline at end of file diff --git a/frontend/style.css b/frontend/style.css index e5c7b06..86e922c 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -26,5 +26,7 @@ body { margin: 0; background: var(--bg); color: var(--text); font-family: sans-s } .modal { display: none; position: fixed; inset: 0; background: #000; z-index: 2000; } -.modal-content { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; } -video { width: 80%; max-height: 80vh; } \ No newline at end of file +.modal-content { width: 100%; height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 20px; box-sizing: border-box; } +.close { position: absolute; top: 20px; right: 20px; color: #fff; font-size: 28px; cursor: pointer; background: rgba(0,0,0,0.5); border-radius: 50%; width: 40px; height: 40px; display: flex; align-items: center; justify-content: center; z-index: 2001; } +.close:hover { background: rgba(255,255,255,0.2); } +video { width: 100%; height: 100%; max-width: 100%; max-height: 100vh; object-fit: contain; } \ No newline at end of file