handle application/vnd.apple.mpegurl.

This commit is contained in:
Simon
2026-02-08 15:23:01 +00:00
parent b9f49530e4
commit c67a5cde16
2 changed files with 232 additions and 80 deletions

View File

@@ -6,6 +6,7 @@ from requests.adapters import HTTPAdapter
from urllib3.util import Retry from urllib3.util import Retry
import yt_dlp import yt_dlp
import io import io
from urllib.parse import urljoin
# Serve frontend static files under `/static` to avoid colliding with API routes # Serve frontend static files under `/static` to avoid colliding with API routes
app = Flask(__name__, static_folder='../frontend', static_url_path='/static') app = Flask(__name__, static_folder='../frontend', static_url_path='/static')
@@ -105,7 +106,7 @@ def videos_proxy():
def index(): def index():
return send_from_directory(app.static_folder, 'index.html') return send_from_directory(app.static_folder, 'index.html')
@app.route('/api/stream', methods=['POST', 'GET']) @app.route('/api/stream', methods=['POST', 'GET', 'HEAD'])
def stream_video(): def stream_video():
# Note: <video> tags perform GET. To support your POST requirement, # Note: <video> tags perform GET. To support your POST requirement,
# we handle the URL via JSON post or URL params. # we handle the URL via JSON post or URL params.
@@ -118,62 +119,139 @@ def stream_video():
if not video_url: if not video_url:
return jsonify({"error": "No URL provided"}), 400 return jsonify({"error": "No URL provided"}), 400
def generate(): def is_hls(url):
try: return '.m3u8' in urllib.parse.urlparse(url).path
# Configure yt-dlp options
ydl_opts = { def is_direct_media(url):
'format': 'best[ext=mp4]/best[vcodec^=avc1]/best[vcodec^=vp]/best', path = urllib.parse.urlparse(url).path.lower()
'quiet': True, return any(path.endswith(ext) for ext in ('.mp4', '.m4v', '.m4s', '.ts', '.webm', '.mov'))
'no_warnings': True,
'socket_timeout': 30, def proxy_response(target_url, content_type_override=None):
'retries': 3, # Extract the base domain to spoof the referer
'fragment_retries': 3, parsed_uri = urllib.parse.urlparse(target_url)
'http_headers': { referer = f"{parsed_uri.scheme}://{parsed_uri.netloc}/"
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
}, safe_request_headers = {
'skip_unavailable_fragments': True 'User-Agent': request.headers.get('User-Agent'),
} 'Referer': referer, # Vital for bypassing CDN blocks
'Origin': referer
with yt_dlp.YoutubeDL(ydl_opts) as ydl: }
# Extract the info
info = ydl.extract_info(video_url, download=False) # Pass through Range headers so the browser can 'sniff' the video
if 'Range' in request.headers:
# Try to get the URL from the info dict (works for progressive downloads) safe_request_headers['Range'] = request.headers['Range']
stream_url = info.get('url')
format_id = info.get('format_id') resp = session.get(target_url, headers=safe_request_headers, stream=True, timeout=30, allow_redirects=True)
# If no direct URL, try to get it from formats hop_by_hop = {
if not stream_url and 'formats' in info: 'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization',
# Find the best format that has a URL 'te', 'trailers', 'transfer-encoding', 'upgrade'
for fmt in info['formats']: }
if fmt.get('url'):
stream_url = fmt.get('url') forwarded_headers = []
break for name, value in resp.headers.items():
if name.lower() in hop_by_hop:
if not stream_url: continue
yield b"Error: Could not extract stream URL" if name.lower() == 'content-length':
return forwarded_headers.append((name, value))
continue
# Prepare headers for the stream request if name.lower() == 'content-type' and content_type_override:
headers = { continue
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' forwarded_headers.append((name, value))
}
if content_type_override:
# Add any cookies or authentication headers from yt-dlp forwarded_headers.append(('Content-Type', content_type_override))
if 'http_headers' in info:
headers.update(info['http_headers']) if request.method == 'HEAD':
resp.close()
# Stream the video from the extracted URL return Response("", status=resp.status_code, headers=forwarded_headers)
resp = session.get(stream_url, headers=headers, stream=True, timeout=30, allow_redirects=True)
resp.raise_for_status() def generate():
try:
for chunk in resp.iter_content(chunk_size=1024 * 16): for chunk in resp.iter_content(chunk_size=1024 * 16):
if chunk: if chunk:
yield chunk yield chunk
except Exception as e: finally:
yield f"Error: {str(e)}".encode() resp.close()
return Response(generate(), mimetype='video/mp4') return Response(generate(), status=resp.status_code, headers=forwarded_headers)
def proxy_hls_playlist(playlist_url):
headers = {
'User-Agent': request.headers.get('User-Agent', 'Mozilla/5.0'),
'Accept': request.headers.get('Accept', '*/*')
}
resp = session.get(playlist_url, headers=headers, timeout=30)
resp.raise_for_status()
base_url = resp.url
lines = resp.text.splitlines()
rewritten = []
for line in lines:
stripped = line.strip()
if not stripped or stripped.startswith('#'):
rewritten.append(line)
continue
absolute = urljoin(base_url, stripped)
proxied = f"/api/stream?url={urllib.parse.quote(absolute, safe='')}"
rewritten.append(proxied)
if request.method == 'HEAD':
return Response("", status=200, content_type='application/vnd.apple.mpegurl')
body = "\n".join(rewritten)
return Response(body, status=200, content_type='application/vnd.apple.mpegurl')
if is_hls(video_url):
try:
return proxy_hls_playlist(video_url)
except Exception as e:
return jsonify({"error": str(e)}), 500
if is_direct_media(video_url):
try:
return proxy_response(video_url)
except Exception as e:
return jsonify({"error": str(e)}), 500
try:
# 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')
# 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:
return jsonify({"error": "Could not extract stream URL"}), 500
if is_hls(stream_url):
return proxy_hls_playlist(stream_url)
return proxy_response(stream_url)
except Exception as e:
return jsonify({"error": str(e)}), 500
if __name__ == '__main__': if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000) # threaded=True allows multiple segments to be proxied at once
app.run(host='0.0.0.0', port=5000, threaded=True)

View File

@@ -4,16 +4,23 @@ const perPage = 12;
const renderedVideoIds = new Set(); const renderedVideoIds = new Set();
let hasNextPage = true; let hasNextPage = true;
let isLoading = false; let isLoading = false;
let hlsPlayer = null;
// 2. Observer Definition (Must be defined before initApp uses it) // 2. Observer Definition (Must be defined before initApp uses it)
const observer = new IntersectionObserver((entries) => { const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) loadVideos(); if (entries[0].isIntersecting) loadVideos();
}, { threshold: 1.0 }); }, {
threshold: 1.0
});
// 3. Logic Functions // 3. Logic Functions
async function InitializeLocalStorage() { async function InitializeLocalStorage() {
if (!localStorage.getItem('config')) { if (!localStorage.getItem('config')) {
localStorage.setItem('config', JSON.stringify({ servers: [{ "https://getfigleaf.com": {} }] })); localStorage.setItem('config', JSON.stringify({
servers: [{
"https://getfigleaf.com": {}
}]
}));
} }
if (!localStorage.getItem('theme')) { if (!localStorage.getItem('theme')) {
localStorage.setItem('theme', 'dark'); localStorage.setItem('theme', 'dark');
@@ -31,13 +38,20 @@ async function InitializeServerStatus() {
try { try {
const response = await fetch(`/api/status`, { const response = await fetch(`/api/status`, {
method: "POST", method: "POST",
body: JSON.stringify({ server: server }), body: JSON.stringify({
headers: { "Content-Type": "application/json" }, server: server
}),
headers: {
"Content-Type": "application/json"
},
}); });
const status = await response.json(); const status = await response.json();
serverObj[server] = status; serverObj[server] = status;
} catch (err) { } catch (err) {
serverObj[server] = { online: false, channels: [] }; serverObj[server] = {
online: false,
channels: []
};
} }
}); });
@@ -50,7 +64,7 @@ async function InitializeServerStatus() {
if (serverData.channels && serverData.channels.length > 0) { if (serverData.channels && serverData.channels.length > 0) {
const channel = serverData.channels[0]; const channel = serverData.channels[0];
let options = {}; let options = {};
if (channel.options) { if (channel.options) {
channel.options.forEach(element => { channel.options.forEach(element => {
// Ensure the options structure matches your API expectations // Ensure the options structure matches your API expectations
@@ -74,7 +88,7 @@ async function InitializeServerStatus() {
async function loadVideos() { async function loadVideos() {
const session = JSON.parse(localStorage.getItem('session')); const session = JSON.parse(localStorage.getItem('session'));
if (!session) return; if (!session) return;
if (isLoading || !hasNextPage) return; if (isLoading || !hasNextPage) return;
const searchInput = document.getElementById('search-input'); const searchInput = document.getElementById('search-input');
@@ -92,7 +106,7 @@ async function loadVideos() {
// Correct way to loop through the options object // Correct way to loop through the options object
Object.entries(session.options).forEach(([key, value]) => { Object.entries(session.options).forEach(([key, value]) => {
if (Array.isArray(value)) { if (Array.isArray(value)) {
body[key] = value.map((entry) => entry.id); body[key] = value.map((entry) => entry.id).join(", ");
} else if (value && value.id) { } else if (value && value.id) {
body[key] = value.id; body[key] = value.id;
} }
@@ -102,7 +116,9 @@ async function loadVideos() {
isLoading = true; isLoading = true;
const response = await fetch('/api/videos', { const response = await fetch('/api/videos', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(body) body: JSON.stringify(body)
}); });
const videos = await response.json(); const videos = await response.json();
@@ -124,7 +140,7 @@ function renderVideos(videos) {
const items = videos && Array.isArray(videos.items) ? videos.items : []; const items = videos && Array.isArray(videos.items) ? videos.items : [];
items.forEach(v => { items.forEach(v => {
if (renderedVideoIds.has(v.id)) return; if (renderedVideoIds.has(v.id)) return;
const card = document.createElement('div'); const card = document.createElement('div');
card.className = 'video-card'; card.className = 'video-card';
const durationText = v.duration === 0 ? '' : `${v.duration}s`; const durationText = v.duration === 0 ? '' : `${v.duration}s`;
@@ -144,10 +160,10 @@ async function initApp() {
// Clear old data if you want a fresh start every refresh // Clear old data if you want a fresh start every refresh
// localStorage.clear(); // localStorage.clear();
await InitializeLocalStorage(); await InitializeLocalStorage();
applyTheme(); applyTheme();
renderMenu(); renderMenu();
const sentinel = document.getElementById('sentinel'); const sentinel = document.getElementById('sentinel');
if (sentinel) { if (sentinel) {
observer.observe(sentinel); observer.observe(sentinel);
@@ -163,10 +179,57 @@ function applyTheme() {
if (select) select.value = theme; if (select) select.value = theme;
} }
function openPlayer(url) { async function openPlayer(url) {
const modal = document.getElementById('video-modal'); const modal = document.getElementById('video-modal');
const video = document.getElementById('player'); const video = document.getElementById('player');
video.src = `/api/stream?url=${encodeURIComponent(url)}`;
// 1. Define isHls (the missing piece!)
const streamUrl = `/api/stream?url=${encodeURIComponent(url)}`;
let isHls = /\.m3u8($|\?)/i.test(url);
// 2. Cleanup existing player instance to prevent aborted bindings
if (hlsPlayer) {
hlsPlayer.stopLoad();
hlsPlayer.detachMedia();
hlsPlayer.destroy();
hlsPlayer = null;
}
// 3. Reset the video element
video.pause();
video.removeAttribute('src');
video.load();
if (!isHls) {
try {
const headResp = await fetch(streamUrl, { method: 'HEAD' });
const contentType = headResp.headers.get('Content-Type') || '';
if (contentType.includes('application/vnd.apple.mpegurl')) {
isHls = true;
}
} catch (err) {
console.warn('Failed to detect stream type', err);
}
}
if (isHls) {
if (window.Hls && window.Hls.isSupported()) {
hlsPlayer = new window.Hls();
hlsPlayer.loadSource(streamUrl);
hlsPlayer.attachMedia(video);
hlsPlayer.on(window.Hls.Events.MANIFEST_PARSED, function() {
video.play();
});
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = streamUrl;
} else {
console.error("HLS not supported in this browser.");
return;
}
} else {
video.src = streamUrl;
}
modal.style.display = 'flex'; modal.style.display = 'flex';
document.body.style.overflow = 'hidden'; document.body.style.overflow = 'hidden';
} }
@@ -174,6 +237,10 @@ function openPlayer(url) {
function closePlayer() { function closePlayer() {
const modal = document.getElementById('video-modal'); const modal = document.getElementById('video-modal');
const video = document.getElementById('player'); const video = document.getElementById('player');
if (hlsPlayer) {
hlsPlayer.destroy();
hlsPlayer = null;
}
video.pause(); video.pause();
video.src = ''; video.src = '';
modal.style.display = 'none'; modal.style.display = 'none';
@@ -190,7 +257,9 @@ function handleSearch(value) {
} }
function getConfig() { function getConfig() {
return JSON.parse(localStorage.getItem('config')) || { servers: [] }; return JSON.parse(localStorage.getItem('config')) || {
servers: []
};
} }
function getSession() { function getSession() {
@@ -206,7 +275,10 @@ function getServerEntries() {
if (!config.servers || !Array.isArray(config.servers)) return []; if (!config.servers || !Array.isArray(config.servers)) return [];
return config.servers.map((serverObj) => { return config.servers.map((serverObj) => {
const server = Object.keys(serverObj)[0]; const server = Object.keys(serverObj)[0];
return { url: server, data: serverObj[server] || null }; return {
url: server,
data: serverObj[server] || null
};
}); });
} }
@@ -280,9 +352,9 @@ function renderMenu() {
sourceSelect.onchange = () => { sourceSelect.onchange = () => {
const selectedServerUrl = sourceSelect.value; const selectedServerUrl = sourceSelect.value;
const selectedServer = serverEntries.find((entry) => entry.url === selectedServerUrl); const selectedServer = serverEntries.find((entry) => entry.url === selectedServerUrl);
const channels = selectedServer && selectedServer.data && selectedServer.data.channels const channels = selectedServer && selectedServer.data && selectedServer.data.channels ?
? selectedServer.data.channels selectedServer.data.channels :
: []; [];
const nextChannel = channels.length > 0 ? channels[0] : null; const nextChannel = channels.length > 0 ? channels[0] : null;
const nextSession = { const nextSession = {
server: selectedServerUrl, server: selectedServerUrl,
@@ -295,9 +367,9 @@ function renderMenu() {
}; };
const activeServer = serverEntries.find((entry) => entry.url === (session && session.server)); const activeServer = serverEntries.find((entry) => entry.url === (session && session.server));
const availableChannels = activeServer && activeServer.data && activeServer.data.channels const availableChannels = activeServer && activeServer.data && activeServer.data.channels ?
? activeServer.data.channels activeServer.data.channels :
: []; [];
channelSelect.innerHTML = ""; channelSelect.innerHTML = "";
availableChannels.forEach((channel) => { availableChannels.forEach((channel) => {
@@ -389,7 +461,9 @@ function renderMenu() {
const exists = (config.servers || []).some((serverObj) => Object.keys(serverObj)[0] === normalized); const exists = (config.servers || []).some((serverObj) => Object.keys(serverObj)[0] === normalized);
if (!exists) { if (!exists) {
config.servers = config.servers || []; config.servers = config.servers || [];
config.servers.push({ [normalized]: {} }); config.servers.push({
[normalized]: {}
});
setConfig(config); setConfig(config);
sourceInput.value = ''; sourceInput.value = '';
await refreshServerStatus(); await refreshServerStatus();
@@ -398,9 +472,9 @@ function renderMenu() {
if (!session || session.server !== normalized) { if (!session || session.server !== normalized) {
const entries = getServerEntries(); const entries = getServerEntries();
const addedEntry = entries.find((entry) => entry.url === normalized); const addedEntry = entries.find((entry) => entry.url === normalized);
const nextChannel = addedEntry && addedEntry.data && addedEntry.data.channels const nextChannel = addedEntry && addedEntry.data && addedEntry.data.channels ?
? addedEntry.data.channels[0] addedEntry.data.channels[0] :
: null; null;
setSession({ setSession({
server: normalized, server: normalized,
channel: nextChannel, channel: nextChannel,