handle application/vnd.apple.mpegurl.
This commit is contained in:
124
backend/main.py
124
backend/main.py
@@ -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,7 +119,99 @@ 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 is_hls(url):
|
||||||
|
return '.m3u8' in urllib.parse.urlparse(url).path
|
||||||
|
|
||||||
|
def is_direct_media(url):
|
||||||
|
path = urllib.parse.urlparse(url).path.lower()
|
||||||
|
return any(path.endswith(ext) for ext in ('.mp4', '.m4v', '.m4s', '.ts', '.webm', '.mov'))
|
||||||
|
|
||||||
|
def proxy_response(target_url, content_type_override=None):
|
||||||
|
# Extract the base domain to spoof the referer
|
||||||
|
parsed_uri = urllib.parse.urlparse(target_url)
|
||||||
|
referer = f"{parsed_uri.scheme}://{parsed_uri.netloc}/"
|
||||||
|
|
||||||
|
safe_request_headers = {
|
||||||
|
'User-Agent': request.headers.get('User-Agent'),
|
||||||
|
'Referer': referer, # Vital for bypassing CDN blocks
|
||||||
|
'Origin': referer
|
||||||
|
}
|
||||||
|
|
||||||
|
# Pass through Range headers so the browser can 'sniff' the video
|
||||||
|
if 'Range' in request.headers:
|
||||||
|
safe_request_headers['Range'] = request.headers['Range']
|
||||||
|
|
||||||
|
resp = session.get(target_url, headers=safe_request_headers, stream=True, timeout=30, allow_redirects=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':
|
||||||
|
forwarded_headers.append((name, value))
|
||||||
|
continue
|
||||||
|
if name.lower() == 'content-type' and content_type_override:
|
||||||
|
continue
|
||||||
|
forwarded_headers.append((name, value))
|
||||||
|
|
||||||
|
if content_type_override:
|
||||||
|
forwarded_headers.append(('Content-Type', content_type_override))
|
||||||
|
|
||||||
|
if request.method == 'HEAD':
|
||||||
|
resp.close()
|
||||||
|
return Response("", status=resp.status_code, headers=forwarded_headers)
|
||||||
|
|
||||||
def generate():
|
def generate():
|
||||||
|
try:
|
||||||
|
for chunk in resp.iter_content(chunk_size=1024 * 16):
|
||||||
|
if chunk:
|
||||||
|
yield chunk
|
||||||
|
finally:
|
||||||
|
resp.close()
|
||||||
|
|
||||||
|
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:
|
try:
|
||||||
# Configure yt-dlp options
|
# Configure yt-dlp options
|
||||||
ydl_opts = {
|
ydl_opts = {
|
||||||
@@ -140,7 +233,6 @@ def stream_video():
|
|||||||
|
|
||||||
# Try to get the URL from the info dict (works for progressive downloads)
|
# Try to get the URL from the info dict (works for progressive downloads)
|
||||||
stream_url = info.get('url')
|
stream_url = info.get('url')
|
||||||
format_id = info.get('format_id')
|
|
||||||
|
|
||||||
# If no direct URL, try to get it from formats
|
# If no direct URL, try to get it from formats
|
||||||
if not stream_url and 'formats' in info:
|
if not stream_url and 'formats' in info:
|
||||||
@@ -151,29 +243,15 @@ def stream_video():
|
|||||||
break
|
break
|
||||||
|
|
||||||
if not stream_url:
|
if not stream_url:
|
||||||
yield b"Error: Could not extract stream URL"
|
return jsonify({"error": "Could not extract stream URL"}), 500
|
||||||
return
|
|
||||||
|
|
||||||
# Prepare headers for the stream request
|
if is_hls(stream_url):
|
||||||
headers = {
|
return proxy_hls_playlist(stream_url)
|
||||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
|
||||||
}
|
|
||||||
|
|
||||||
# Add any cookies or authentication headers from yt-dlp
|
return proxy_response(stream_url)
|
||||||
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:
|
except Exception as e:
|
||||||
yield f"Error: {str(e)}".encode()
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
return Response(generate(), mimetype='video/mp4')
|
|
||||||
|
|
||||||
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)
|
||||||
|
|||||||
116
frontend/app.js
116
frontend/app.js
@@ -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: []
|
||||||
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -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();
|
||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user