Add optional VOD support

This commit is contained in:
2025-08-30 00:17:55 -03:00
parent ce30cce195
commit 4ab3207998
4 changed files with 1224 additions and 789 deletions

View File

@@ -1,11 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>xtream2m3u - M3U Playlist Generator</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="container">
<div class="header">
@@ -16,7 +18,7 @@
<p class="subtitle">Convert Xtream IPTV APIs into customizable M3U playlists</p>
</div>
<!-- Step 1: Credentials -->
<!-- Step 1: Credentials -->
<div class="step active" id="step1">
<div class="card">
<div class="card-header">
@@ -26,7 +28,8 @@
<div class="privacy-notice">
<div class="icon">🔒</div>
<div>
<strong>Privacy Notice:</strong> Your credentials are only used to connect to your IPTV service and are never saved or stored on our servers.
<strong>Privacy Notice:</strong> Your credentials are only used to connect to your IPTV
service and are never saved or stored on our servers.
</div>
</div>
@@ -45,6 +48,19 @@
<input type="password" id="password" placeholder="your_password" required>
</div>
<div class="form-group">
<div class="checkbox-wrapper">
<label class="checkbox-label">
<input type="checkbox" id="includeVod">
<span class="checkmark"></span>
<span class="checkbox-text">
<strong>Include VOD, Movies & Shows</strong>
<small>Add Video On Demand content in addition to Live TV channels</small>
</span>
</label>
</div>
</div>
<button class="btn btn-primary" onclick="loadCategories()">
<span id="loadCategoriesText">Continue to Category Selection</span>
</button>
@@ -105,7 +121,8 @@
<div class="success-state">
<div class="success-checkmark"></div>
<h2 class="success-title">Playlist Generated!</h2>
<p class="success-message">Your M3U playlist has been successfully created and is ready for download.</p>
<p class="success-message">Your M3U playlist has been successfully created and is ready for
download.</p>
<div class="success-actions">
<a class="btn btn-success download-link" id="finalDownloadLink" style="display: none;">
📥 Download M3U Playlist
@@ -126,7 +143,8 @@
<div class="modal-content">
<div class="modal-header">
<h3>Confirm Playlist Generation</h3>
<p style="color: var(--text-secondary);">Please review your selections before generating the playlist</p>
<p style="color: var(--text-secondary);">Please review your selections before generating the
playlist</p>
</div>
<div class="modal-summary" id="modalSummary">
@@ -149,4 +167,5 @@
<script src="script.js"></script>
</body>
</html>
</html>

View File

@@ -2,108 +2,217 @@ let categories = [];
let currentStep = 1;
async function loadCategories() {
const url = document.getElementById('url').value.trim();
const username = document.getElementById('username').value.trim();
const password = document.getElementById('password').value.trim();
const url = document.getElementById("url").value.trim();
const username = document.getElementById("username").value.trim();
const password = document.getElementById("password").value.trim();
const includeVod = document.getElementById("includeVod").checked;
if (!url || !username || !password) {
showError('Please fill in all required fields');
return;
if (!url || !username || !password) {
showError("Please fill in all required fields");
return;
}
const loadingElement = document.getElementById("loading");
const loadButton = document.getElementById("loadCategoriesText");
loadButton.textContent = "Loading...";
loadingElement.style.display = "block";
hideAllSteps();
clearResults();
try {
const params = new URLSearchParams({
url: url,
username: username,
password: password,
});
if (includeVod) {
params.append("include_vod", "true");
}
const loadingElement = document.getElementById('loading');
const loadButton = document.getElementById('loadCategoriesText');
const response = await fetch(`/categories?${params}`);
const data = await response.json();
loadButton.textContent = 'Loading...';
loadingElement.style.display = 'block';
hideAllSteps();
clearResults();
try {
const params = new URLSearchParams({
url: url,
username: username,
password: password
});
const response = await fetch(`/categories?${params}`);
const data = await response.json();
if (!response.ok) {
throw new Error(data.details || data.error || 'Failed to load categories');
}
categories = data;
displayCategoryChips(categories);
showStep(2);
} catch (error) {
console.error('Error loading categories:', error);
showError(`Failed to load categories: ${error.message}`);
showStep(1);
} finally {
loadingElement.style.display = 'none';
loadButton.textContent = 'Continue to Category Selection';
if (!response.ok) {
throw new Error(
data.details || data.error || "Failed to load categories"
);
}
categories = data;
displayCategoryChips(categories);
showStep(2);
} catch (error) {
console.error("Error loading categories:", error);
showError(`Failed to load categories: ${error.message}`);
showStep(1);
} finally {
loadingElement.style.display = "none";
loadButton.textContent = "Continue to Category Selection";
}
}
function displayCategoryChips(categories) {
const categoryChips = document.getElementById('categoryChips');
categoryChips.innerHTML = '';
const categoryChips = document.getElementById("categoryChips");
categoryChips.innerHTML = "";
categories.forEach(category => {
const chip = document.createElement('div');
chip.className = 'category-chip';
// Group categories by content type
const groupedCategories = {
live: [],
vod: [],
series: [],
};
categories.forEach((category) => {
const contentType = category.content_type || "live";
if (groupedCategories[contentType]) {
groupedCategories[contentType].push(category);
}
});
// Define section headers and order
const sections = [
{ key: "live", title: "📺 Live TV", icon: "📺" },
{ key: "vod", title: "🎬 Movies & VOD", icon: "🎬" },
{ key: "series", title: "📺 TV Shows & Series", icon: "📺" },
];
sections.forEach((section) => {
const sectionCategories = groupedCategories[section.key];
if (sectionCategories && sectionCategories.length > 0) {
// Create section header
const sectionHeader = document.createElement("div");
sectionHeader.className = "category-section-header";
sectionHeader.innerHTML = `
<h3>${section.title}</h3>
<div class="section-header-actions">
<button class="btn-section-select-all" data-section="${section.key}">Select All</button>
<span class="category-count">${sectionCategories.length} categories</span>
</div>
`;
categoryChips.appendChild(sectionHeader);
// Create section container
const sectionContainer = document.createElement("div");
sectionContainer.className = "category-section";
sectionCategories.forEach((category) => {
const chip = document.createElement("div");
chip.className = "category-chip";
chip.dataset.categoryId = category.category_id;
chip.dataset.categoryName = category.category_name;
chip.dataset.contentType = category.content_type || "live";
chip.onclick = () => toggleChip(chip);
chip.innerHTML = `<span class="chip-text">${category.category_name}</span>`;
categoryChips.appendChild(chip);
});
sectionContainer.appendChild(chip);
});
updateSelectionCounter();
categoryChips.appendChild(sectionContainer);
}
});
// Add event listeners for section select all buttons
document.querySelectorAll(".btn-section-select-all").forEach((button) => {
button.addEventListener("click", (e) => {
e.stopPropagation();
const section = e.target.dataset.section;
const sectionChips = document.querySelectorAll(
`[data-content-type="${section}"]`
);
const allSelected = Array.from(sectionChips).every((chip) =>
chip.classList.contains("selected")
);
// Toggle all chips in this section
sectionChips.forEach((chip) => {
if (allSelected) {
chip.classList.remove("selected");
} else {
chip.classList.add("selected");
}
});
// Update button text
e.target.textContent = allSelected ? "Select All" : "Clear All";
updateSelectionCounter();
});
});
updateSelectionCounter();
}
function toggleChip(chip) {
chip.classList.toggle('selected');
updateSelectionCounter();
chip.classList.toggle("selected");
updateSelectionCounter();
}
function updateSelectionCounter() {
const selectedCount = document.querySelectorAll('.category-chip.selected').length;
const counter = document.getElementById('selectionCounter');
const text = document.getElementById('selectionText');
const selectedChips = document.querySelectorAll(".category-chip.selected");
const selectedCount = selectedChips.length;
const counter = document.getElementById("selectionCounter");
const text = document.getElementById("selectionText");
if (selectedCount === 0) {
text.textContent = 'Click categories to select them (or leave empty to include all)';
counter.classList.remove('has-selection');
} else {
const filterMode = document.querySelector('input[name="filterMode"]:checked').value;
const action = filterMode === 'include' ? 'included' : 'excluded';
text.textContent = `${selectedCount} categories will be ${action}`;
counter.classList.add('has-selection');
}
if (selectedCount === 0) {
text.textContent =
"Click categories to select them (or leave empty to include all)";
counter.classList.remove("has-selection");
} else {
const filterMode = document.querySelector(
'input[name="filterMode"]:checked'
).value;
const action = filterMode === "include" ? "included" : "excluded";
// Count by content type
const contentTypeCounts = { live: 0, vod: 0, series: 0 };
selectedChips.forEach((chip) => {
const contentType = chip.dataset.contentType || "live";
if (contentTypeCounts.hasOwnProperty(contentType)) {
contentTypeCounts[contentType]++;
}
});
// Build detailed text
const parts = [];
if (contentTypeCounts.live > 0)
parts.push(`${contentTypeCounts.live} Live TV`);
if (contentTypeCounts.vod > 0)
parts.push(`${contentTypeCounts.vod} Movies/VOD`);
if (contentTypeCounts.series > 0)
parts.push(`${contentTypeCounts.series} TV Shows`);
const breakdown = parts.length > 0 ? ` (${parts.join(", ")})` : "";
text.textContent = `${selectedCount} categories will be ${action}${breakdown}`;
counter.classList.add("has-selection");
}
}
function showConfirmation() {
const selectedCategories = getSelectedCategories();
const filterMode = document.querySelector('input[name="filterMode"]:checked').value;
const modal = document.getElementById('confirmationModal');
const summary = document.getElementById('modalSummary');
const selectedCategories = getSelectedCategories();
const filterMode = document.querySelector(
'input[name="filterMode"]:checked'
).value;
const includeVod = document.getElementById("includeVod").checked;
const modal = document.getElementById("confirmationModal");
const summary = document.getElementById("modalSummary");
const url = document.getElementById('url').value.trim();
const username = document.getElementById('username').value.trim();
const url = document.getElementById("url").value.trim();
const username = document.getElementById("username").value.trim();
let categoryText;
if (selectedCategories.length === 0) {
categoryText = `All ${categories.length} categories`;
} else {
const action = filterMode === 'include' ? 'Include' : 'Exclude';
categoryText = `${action} ${selectedCategories.length} selected categories`;
}
let categoryText;
if (selectedCategories.length === 0) {
categoryText = `All ${categories.length} categories`;
} else {
const action = filterMode === "include" ? "Include" : "Exclude";
categoryText = `${action} ${selectedCategories.length} selected categories`;
}
summary.innerHTML = `
const contentType = includeVod
? "Live TV + VOD/Movies/Shows"
: "Live TV only";
summary.innerHTML = `
<div class="summary-row">
<span class="summary-label">Service URL:</span>
<span class="summary-value">${url}</span>
@@ -112,6 +221,10 @@ function showConfirmation() {
<span class="summary-label">Username:</span>
<span class="summary-value">${username}</span>
</div>
<div class="summary-row">
<span class="summary-label">Content Type:</span>
<span class="summary-value">${contentType}</span>
</div>
<div class="summary-row">
<span class="summary-label">Filter Mode:</span>
<span class="summary-value">${categoryText}</span>
@@ -122,201 +235,222 @@ function showConfirmation() {
</div>
`;
modal.classList.add('active');
modal.classList.add("active");
}
function closeModal() {
document.getElementById('confirmationModal').classList.remove('active');
document.getElementById("confirmationModal").classList.remove("active");
}
async function confirmGeneration() {
closeModal();
closeModal();
const url = document.getElementById('url').value.trim();
const username = document.getElementById('username').value.trim();
const password = document.getElementById('password').value.trim();
const selectedCategories = getSelectedCategories();
const filterMode = document.querySelector('input[name="filterMode"]:checked').value;
const url = document.getElementById("url").value.trim();
const username = document.getElementById("username").value.trim();
const password = document.getElementById("password").value.trim();
const includeVod = document.getElementById("includeVod").checked;
const selectedCategories = getSelectedCategories();
const filterMode = document.querySelector(
'input[name="filterMode"]:checked'
).value;
hideAllSteps();
document.getElementById('loading').style.display = 'block';
document.querySelector('#loading p').textContent = 'Generating your playlist...';
hideAllSteps();
document.getElementById("loading").style.display = "block";
document.querySelector("#loading p").textContent =
"Generating your playlist...";
try {
const params = new URLSearchParams({
url: url,
username: username,
password: password,
nostreamproxy: 'true'
});
try {
const params = new URLSearchParams({
url: url,
username: username,
password: password,
nostreamproxy: "true",
});
if (selectedCategories.length > 0) {
if (filterMode === 'include') {
params.append('wanted_groups', selectedCategories.join(','));
} else {
params.append('unwanted_groups', selectedCategories.join(','));
}
}
const response = await fetch(`/m3u?${params}`);
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText || 'Failed to generate M3U playlist');
}
const blob = await response.blob();
const downloadUrl = window.URL.createObjectURL(blob);
const downloadLink = document.getElementById('finalDownloadLink');
downloadLink.href = downloadUrl;
downloadLink.download = 'playlist.m3u';
downloadLink.style.display = 'inline-flex';
showStep(3);
} catch (error) {
console.error('Error generating M3U:', error);
showError(`Failed to generate M3U: ${error.message}`);
showStep(2);
} finally {
document.getElementById('loading').style.display = 'none';
document.querySelector('#loading p').textContent = 'Loading categories...';
if (includeVod) {
params.append("include_vod", "true");
}
if (selectedCategories.length > 0) {
if (filterMode === "include") {
params.append("wanted_groups", selectedCategories.join(","));
} else {
params.append("unwanted_groups", selectedCategories.join(","));
}
}
const response = await fetch(`/m3u?${params}`);
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText || "Failed to generate M3U playlist");
}
const blob = await response.blob();
const downloadUrl = window.URL.createObjectURL(blob);
const downloadLink = document.getElementById("finalDownloadLink");
downloadLink.href = downloadUrl;
downloadLink.download = "playlist.m3u";
downloadLink.style.display = "inline-flex";
showStep(3);
} catch (error) {
console.error("Error generating M3U:", error);
showError(`Failed to generate M3U: ${error.message}`);
showStep(2);
} finally {
document.getElementById("loading").style.display = "none";
document.querySelector("#loading p").textContent = "Loading categories...";
}
}
function getSelectedCategories() {
const selectedChips = document.querySelectorAll('.category-chip.selected');
return Array.from(selectedChips).map(chip => chip.dataset.categoryName);
const selectedChips = document.querySelectorAll(".category-chip.selected");
return Array.from(selectedChips).map((chip) => chip.dataset.categoryName);
}
function clearSelection() {
const chips = document.querySelectorAll('.category-chip');
chips.forEach(chip => chip.classList.remove('selected'));
updateSelectionCounter();
const chips = document.querySelectorAll(".category-chip");
chips.forEach((chip) => chip.classList.remove("selected"));
// Reset section select all buttons
const selectAllButtons = document.querySelectorAll(".btn-section-select-all");
selectAllButtons.forEach((button) => {
button.textContent = "Select All";
});
updateSelectionCounter();
}
// Flow management functions
function hideAllSteps() {
document.querySelectorAll('.step').forEach(step => {
step.classList.remove('active');
});
document.querySelectorAll(".step").forEach((step) => {
step.classList.remove("active");
});
}
function showStep(stepNumber) {
hideAllSteps();
document.getElementById(`step${stepNumber}`).classList.add('active');
currentStep = stepNumber;
hideAllSteps();
document.getElementById(`step${stepNumber}`).classList.add("active");
currentStep = stepNumber;
}
function goBackToStep1() {
showStep(1);
showStep(1);
}
function startOver() {
// Clear all form data
document.getElementById('url').value = '';
document.getElementById('username').value = '';
document.getElementById('password').value = '';
// Clear all form data
document.getElementById("url").value = "";
document.getElementById("username").value = "";
document.getElementById("password").value = "";
document.getElementById("includeVod").checked = false;
// Reset categories and chips
categories = [];
document.getElementById('categoryChips').innerHTML = '';
// Reset categories and chips
categories = [];
document.getElementById("categoryChips").innerHTML = "";
// Clear any download link
const downloadLink = document.getElementById('finalDownloadLink');
if (downloadLink.href && downloadLink.href.startsWith('blob:')) {
URL.revokeObjectURL(downloadLink.href);
}
downloadLink.style.display = 'none';
// Clear any download link
const downloadLink = document.getElementById("finalDownloadLink");
if (downloadLink.href && downloadLink.href.startsWith("blob:")) {
URL.revokeObjectURL(downloadLink.href);
}
downloadLink.style.display = "none";
clearResults();
showStep(1);
clearResults();
showStep(1);
}
function useOtherCredentials() {
// Keep categories but clear credentials
document.getElementById('url').value = '';
document.getElementById('username').value = '';
document.getElementById('password').value = '';
// Keep categories but clear credentials
document.getElementById("url").value = "";
document.getElementById("username").value = "";
document.getElementById("password").value = "";
clearResults();
showStep(1);
clearResults();
showStep(1);
}
function showError(message) {
const resultsDiv = document.getElementById('results');
resultsDiv.innerHTML = `<div class="alert alert-error">⚠️ ${message}</div>`;
const resultsDiv = document.getElementById("results");
resultsDiv.innerHTML = `<div class="alert alert-error">⚠️ ${message}</div>`;
}
function showSuccess(message) {
const resultsDiv = document.getElementById('results');
resultsDiv.innerHTML = `<div class="alert alert-success">✅ ${message}</div>`;
const resultsDiv = document.getElementById("results");
resultsDiv.innerHTML = `<div class="alert alert-success">✅ ${message}</div>`;
}
function clearResults() {
document.getElementById('results').innerHTML = '';
document.getElementById("results").innerHTML = "";
}
// Trim input fields on blur to prevent extra spaces
function setupInputTrimming() {
const textInputs = document.querySelectorAll('input[type="text"], input[type="url"], input[type="password"]');
textInputs.forEach(input => {
input.addEventListener('blur', function() {
this.value = this.value.trim();
});
const textInputs = document.querySelectorAll(
'input[type="text"], input[type="url"], input[type="password"]'
);
textInputs.forEach((input) => {
input.addEventListener("blur", function () {
this.value = this.value.trim();
});
});
}
// Initialize input trimming when page loads
document.addEventListener('DOMContentLoaded', setupInputTrimming);
document.addEventListener("DOMContentLoaded", setupInputTrimming);
// Update filter mode selection counter
document.addEventListener('change', function(e) {
if (e.target.name === 'filterMode') {
updateSelectionCounter();
}
document.addEventListener("change", function (e) {
if (e.target.name === "filterMode") {
updateSelectionCounter();
}
});
// Modal click outside to close
document.getElementById('confirmationModal').addEventListener('click', function(e) {
document
.getElementById("confirmationModal")
.addEventListener("click", function (e) {
if (e.target === this) {
closeModal();
closeModal();
}
});
});
// Keyboard shortcuts
document.addEventListener('keydown', function(e) {
// Escape to close modal
if (e.key === 'Escape') {
closeModal();
return;
}
document.addEventListener("keydown", function (e) {
// Escape to close modal
if (e.key === "Escape") {
closeModal();
return;
}
if (e.ctrlKey || e.metaKey) {
switch(e.key) {
case 'Enter':
e.preventDefault();
if (currentStep === 1) {
loadCategories();
} else if (currentStep === 2) {
showConfirmation();
}
break;
case 'a':
e.preventDefault();
if (currentStep === 2) {
const chips = document.querySelectorAll('.category-chip');
const allSelected = Array.from(chips).every(chip => chip.classList.contains('selected'));
chips.forEach(chip => {
if (allSelected) {
chip.classList.remove('selected');
} else {
chip.classList.add('selected');
}
});
updateSelectionCounter();
}
break;
if (e.ctrlKey || e.metaKey) {
switch (e.key) {
case "Enter":
e.preventDefault();
if (currentStep === 1) {
loadCategories();
} else if (currentStep === 2) {
showConfirmation();
}
break;
case "a":
e.preventDefault();
if (currentStep === 2) {
const chips = document.querySelectorAll(".category-chip");
const allSelected = Array.from(chips).every((chip) =>
chip.classList.contains("selected")
);
chips.forEach((chip) => {
if (allSelected) {
chip.classList.remove("selected");
} else {
chip.classList.add("selected");
}
});
updateSelectionCounter();
}
break;
}
}
});

File diff suppressed because it is too large Load Diff

411
run.py
View File

@@ -19,41 +19,46 @@ logger = logging.getLogger(__name__)
app = Flask(__name__)
@app.route('/')
@app.route("/")
def serve_frontend():
"""Serve the frontend index.html file"""
return send_from_directory('frontend', 'index.html')
return send_from_directory("frontend", "index.html")
@app.route('/assets/<path:filename>')
@app.route("/assets/<path:filename>")
def serve_assets(filename):
"""Serve assets from the docs/assets directory"""
try:
return send_from_directory('docs/assets', filename)
return send_from_directory("docs/assets", filename)
except:
return "Asset not found", 404
@app.route('/<path:filename>')
@app.route("/<path:filename>")
def serve_static_files(filename):
"""Serve static files from the frontend directory"""
# Don't serve API routes through static file handler
api_routes = ['m3u', 'xmltv', 'categories', 'image-proxy', 'stream-proxy', 'assets']
if filename.split('/')[0] in api_routes:
api_routes = ["m3u", "xmltv", "categories", "image-proxy", "stream-proxy", "assets"]
if filename.split("/")[0] in api_routes:
return "Not found", 404
# Only serve files that exist in the frontend directory
try:
return send_from_directory('frontend', filename)
return send_from_directory("frontend", filename)
except:
# If file doesn't exist in frontend, return 404
return "File not found", 404
# Get default proxy URL from environment variable
DEFAULT_PROXY_URL = os.environ.get('PROXY_URL')
DEFAULT_PROXY_URL = os.environ.get("PROXY_URL")
# Set up custom DNS resolver
def setup_custom_dns():
"""Configure a custom DNS resolver using reliable DNS services"""
dns_servers = ['1.1.1.1', '1.0.0.1', '8.8.8.8', '8.8.4.4', '9.9.9.9']
dns_servers = ["1.1.1.1", "1.0.0.1", "8.8.8.8", "8.8.4.4", "9.9.9.9"]
custom_resolver = dns.resolver.Resolver()
custom_resolver.nameservers = dns_servers
@@ -80,23 +85,25 @@ def setup_custom_dns():
socket.getaddrinfo = new_getaddrinfo
logger.info("Custom DNS resolver set up")
# Initialize DNS resolver
setup_custom_dns()
# Common request function with caching for API endpoints
@lru_cache(maxsize=128)
def fetch_api_data(url, timeout=10):
"""Make a request to an API endpoint with caching"""
ua = UserAgent()
headers = {
'User-Agent': ua.chrome,
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.5',
'Connection': 'keep-alive',
"User-Agent": ua.chrome,
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.5",
"Connection": "keep-alive",
}
try:
hostname = urllib.parse.urlparse(url).netloc.split(':')[0]
hostname = urllib.parse.urlparse(url).netloc.split(":")[0]
logger.info(f"Making request to host: {hostname}")
response = requests.get(url, headers=headers, timeout=timeout)
@@ -110,28 +117,31 @@ def fetch_api_data(url, timeout=10):
return response.text
except requests.exceptions.SSLError:
return {'error': 'SSL Error', 'details': 'Failed to verify SSL certificate'}, 503
return {"error": "SSL Error", "details": "Failed to verify SSL certificate"}, 503
except requests.exceptions.RequestException as e:
logger.error(f"RequestException: {e}")
return {'error': 'Request Exception', 'details': str(e)}, 503
return {"error": "Request Exception", "details": str(e)}, 503
def stream_request(url, headers=None, timeout=10):
"""Make a streaming request that doesn't buffer the full response"""
if not headers:
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"
}
return requests.get(url, stream=True, headers=headers, timeout=timeout)
def encode_url(url):
"""Safely encode a URL for use in proxy endpoints"""
return urllib.parse.quote(url, safe='') if url else ''
return urllib.parse.quote(url, safe="") if url else ""
def generate_streaming_response(response, content_type=None):
"""Generate a streaming response with appropriate headers"""
if not content_type:
content_type = response.headers.get('Content-Type', 'application/octet-stream')
content_type = response.headers.get("Content-Type", "application/octet-stream")
def generate():
try:
@@ -146,24 +156,20 @@ def generate_streaming_response(response, content_type=None):
raise
headers = {
'Access-Control-Allow-Origin': '*',
'Content-Type': content_type,
"Access-Control-Allow-Origin": "*",
"Content-Type": content_type,
}
# Add content length if available and not using chunked transfer
if 'Content-Length' in response.headers and 'Transfer-Encoding' not in response.headers:
headers['Content-Length'] = response.headers['Content-Length']
if "Content-Length" in response.headers and "Transfer-Encoding" not in response.headers:
headers["Content-Length"] = response.headers["Content-Length"]
else:
headers['Transfer-Encoding'] = 'chunked'
headers["Transfer-Encoding"] = "chunked"
return Response(
generate(),
mimetype=content_type,
headers=headers,
direct_passthrough=True
)
return Response(generate(), mimetype=content_type, headers=headers, direct_passthrough=True)
@app.route('/image-proxy/<path:image_url>')
@app.route("/image-proxy/<path:image_url>")
def proxy_image(image_url):
"""Proxy endpoint for images to avoid CORS issues"""
try:
@@ -173,22 +179,23 @@ def proxy_image(image_url):
response = requests.get(original_url, stream=True, timeout=10)
response.raise_for_status()
content_type = response.headers.get('Content-Type', '')
content_type = response.headers.get("Content-Type", "")
if not content_type.startswith('image/'):
if not content_type.startswith("image/"):
logger.error(f"Invalid content type for image: {content_type}")
return Response('Invalid image type', status=415)
return Response("Invalid image type", status=415)
return generate_streaming_response(response, content_type)
except requests.Timeout:
return Response('Image fetch timeout', status=504)
return Response("Image fetch timeout", status=504)
except requests.HTTPError as e:
return Response(f'Failed to fetch image: {str(e)}', status=e.response.status_code)
return Response(f"Failed to fetch image: {str(e)}", status=e.response.status_code)
except Exception as e:
logger.error(f"Image proxy error: {str(e)}")
return Response('Failed to process image', status=500)
return Response("Failed to process image", status=500)
@app.route('/stream-proxy/<path:stream_url>')
@app.route("/stream-proxy/<path:stream_url>")
def proxy_stream(stream_url):
"""Proxy endpoint for streams"""
try:
@@ -199,28 +206,30 @@ def proxy_stream(stream_url):
response.raise_for_status()
# Determine content type
content_type = response.headers.get('Content-Type')
content_type = response.headers.get("Content-Type")
if not content_type:
if original_url.endswith('.ts'):
content_type = 'video/MP2T'
elif original_url.endswith('.m3u8'):
content_type = 'application/vnd.apple.mpegurl'
if original_url.endswith(".ts"):
content_type = "video/MP2T"
elif original_url.endswith(".m3u8"):
content_type = "application/vnd.apple.mpegurl"
else:
content_type = 'application/octet-stream'
content_type = "application/octet-stream"
logger.info(f"Using content type: {content_type}")
return generate_streaming_response(response, content_type)
except requests.Timeout:
return Response('Stream timeout', status=504)
return Response("Stream timeout", status=504)
except requests.HTTPError as e:
return Response(f'Failed to fetch stream: {str(e)}', status=e.response.status_code)
return Response(f"Failed to fetch stream: {str(e)}", status=e.response.status_code)
except Exception as e:
logger.error(f"Stream proxy error: {str(e)}")
return Response('Failed to process stream', status=500)
return Response("Failed to process stream", status=500)
def parse_group_list(group_string):
"""Parse a comma-separated string into a list of trimmed strings"""
return [group.strip() for group in group_string.split(',')] if group_string else []
return [group.strip() for group in group_string.split(",")] if group_string else []
def group_matches(group_title, pattern):
"""Check if a group title matches a pattern, supporting wildcards and exact matching"""
@@ -229,7 +238,7 @@ def group_matches(group_title, pattern):
pattern_lower = pattern.lower()
# Handle spaces in pattern
if ' ' in pattern_lower:
if " " in pattern_lower:
# For patterns with spaces, split and check each part
pattern_parts = pattern_lower.split()
group_parts = group_lower.split()
@@ -242,7 +251,7 @@ def group_matches(group_title, pattern):
for i, part in enumerate(pattern_parts):
if i >= len(group_parts):
return False
if '*' in part or '?' in part:
if "*" in part or "?" in part:
if not fnmatch.fnmatch(group_parts[i], part):
return False
else:
@@ -251,69 +260,142 @@ def group_matches(group_title, pattern):
return True
# Check for wildcard patterns
if '*' in pattern_lower or '?' in pattern_lower:
if "*" in pattern_lower or "?" in pattern_lower:
return fnmatch.fnmatch(group_lower, pattern_lower)
else:
# Simple substring match for non-wildcard patterns
return pattern_lower in group_lower
def get_required_params():
"""Get and validate the required parameters from the request"""
url = request.args.get('url')
username = request.args.get('username')
password = request.args.get('password')
url = request.args.get("url")
username = request.args.get("username")
password = request.args.get("password")
if not url or not username or not password:
return None, None, None, json.dumps({
'error': 'Missing Parameters',
'details': 'Required parameters: url, username, and password'
}), 400
return (
None,
None,
None,
json.dumps({"error": "Missing Parameters", "details": "Required parameters: url, username, and password"}),
400,
)
proxy_url = request.args.get('proxy_url', DEFAULT_PROXY_URL) or request.host_url.rstrip('/')
proxy_url = request.args.get("proxy_url", DEFAULT_PROXY_URL) or request.host_url.rstrip("/")
return url, username, password, proxy_url, None
def validate_xtream_credentials(url, username, password):
"""Validate the Xtream API credentials"""
api_url = f'{url}/player_api.php?username={username}&password={password}'
api_url = f"{url}/player_api.php?username={username}&password={password}"
data = fetch_api_data(api_url)
if isinstance(data, tuple): # Error response
return None, data[0], data[1]
if 'user_info' not in data or 'server_info' not in data:
return None, json.dumps({
'error': 'Invalid Response',
'details': 'Server response missing required data (user_info or server_info)'
}), 400
if "user_info" not in data or "server_info" not in data:
return (
None,
json.dumps(
{
"error": "Invalid Response",
"details": "Server response missing required data (user_info or server_info)",
}
),
400,
)
return data, None, None
def fetch_categories_and_channels(url, username, password):
def fetch_categories_and_channels(url, username, password, include_vod=False):
"""Fetch categories and channels from the Xtream API"""
# Fetch categories
category_url = f'{url}/player_api.php?username={username}&password={password}&action=get_live_categories'
categories = fetch_api_data(category_url)
all_categories = []
all_streams = []
if isinstance(categories, tuple): # Error response
return None, None, categories[0], categories[1]
# Fetch live categories and streams
live_category_url = f"{url}/player_api.php?username={username}&password={password}&action=get_live_categories"
live_categories = fetch_api_data(live_category_url)
# Fetch live channels
channel_url = f'{url}/player_api.php?username={username}&password={password}&action=get_live_streams'
channels = fetch_api_data(channel_url)
if isinstance(live_categories, tuple): # Error response
return None, None, live_categories[0], live_categories[1]
if isinstance(channels, tuple): # Error response
return None, None, channels[0], channels[1]
live_channel_url = f"{url}/player_api.php?username={username}&password={password}&action=get_live_streams"
live_channels = fetch_api_data(live_channel_url)
if not isinstance(categories, list) or not isinstance(channels, list):
return None, None, json.dumps({
'error': 'Invalid Data Format',
'details': 'Categories or channels data is not in the expected format'
}), 500
if isinstance(live_channels, tuple): # Error response
return None, None, live_channels[0], live_channels[1]
return categories, channels, None, None
if not isinstance(live_categories, list) or not isinstance(live_channels, list):
return (
None,
None,
json.dumps(
{
"error": "Invalid Data Format",
"details": "Live categories or channels data is not in the expected format",
}
),
500,
)
@app.route('/categories', methods=['GET'])
# Add content type to live categories and streams
for category in live_categories:
category["content_type"] = "live"
for stream in live_channels:
stream["content_type"] = "live"
all_categories.extend(live_categories)
all_streams.extend(live_channels)
# If VOD is requested, fetch VOD and series content
if include_vod:
# Fetch VOD categories and streams
vod_category_url = f"{url}/player_api.php?username={username}&password={password}&action=get_vod_categories"
vod_categories = fetch_api_data(vod_category_url)
if isinstance(vod_categories, list):
# Add content type to VOD categories
for category in vod_categories:
category["content_type"] = "vod"
all_categories.extend(vod_categories)
vod_streams_url = f"{url}/player_api.php?username={username}&password={password}&action=get_vod_streams"
vod_streams = fetch_api_data(vod_streams_url)
if isinstance(vod_streams, list):
# Add content type to VOD streams
for stream in vod_streams:
stream["content_type"] = "vod"
all_streams.extend(vod_streams)
# Fetch series categories and content
series_category_url = (
f"{url}/player_api.php?username={username}&password={password}&action=get_series_categories"
)
series_categories = fetch_api_data(series_category_url)
if isinstance(series_categories, list):
# Add content type to series categories
for category in series_categories:
category["content_type"] = "series"
all_categories.extend(series_categories)
series_url = f"{url}/player_api.php?username={username}&password={password}&action=get_series"
series = fetch_api_data(series_url)
if isinstance(series, list):
# Add content type to series
for show in series:
show["content_type"] = "series"
all_streams.extend(series)
return all_categories, all_streams, None, None
@app.route("/categories", methods=["GET"])
def get_categories():
"""Get all available categories from the Xtream API"""
# Get and validate parameters
@@ -321,20 +403,24 @@ def get_categories():
if error:
return error
# Check for VOD parameter
include_vod = request.args.get("include_vod", "").lower() == "true"
# Validate credentials
user_data, error_json, error_code = validate_xtream_credentials(url, username, password)
if error_json:
return error_json, error_code, {'Content-Type': 'application/json'}
return error_json, error_code, {"Content-Type": "application/json"}
# Fetch categories
categories, channels, error_json, error_code = fetch_categories_and_channels(url, username, password)
categories, channels, error_json, error_code = fetch_categories_and_channels(url, username, password, include_vod)
if error_json:
return error_json, error_code, {'Content-Type': 'application/json'}
return error_json, error_code, {"Content-Type": "application/json"}
# Return categories as JSON
return json.dumps(categories), 200, {'Content-Type': 'application/json'}
return json.dumps(categories), 200, {"Content-Type": "application/json"}
@app.route('/xmltv', methods=['GET'])
@app.route("/xmltv", methods=["GET"])
def generate_xmltv():
"""Generate a filtered XMLTV file from the Xtream API"""
# Get and validate parameters
@@ -347,22 +433,20 @@ def generate_xmltv():
# Validate credentials
user_data, error_json, error_code = validate_xtream_credentials(url, username, password)
if error_json:
return error_json, error_code, {'Content-Type': 'application/json'}
return error_json, error_code, {"Content-Type": "application/json"}
# Fetch XMLTV data
base_url = url.rstrip('/')
xmltv_url = f'{base_url}/xmltv.php?username={username}&password={password}'
base_url = url.rstrip("/")
xmltv_url = f"{base_url}/xmltv.php?username={username}&password={password}"
xmltv_data = fetch_api_data(xmltv_url, timeout=20) # Longer timeout for XMLTV
if isinstance(xmltv_data, tuple): # Error response
return json.dumps(xmltv_data[0]), xmltv_data[1], {'Content-Type': 'application/json'}
return json.dumps(xmltv_data[0]), xmltv_data[1], {"Content-Type": "application/json"}
# If not proxying, return the original XMLTV
if not proxy_url:
return Response(
xmltv_data,
mimetype='application/xml',
headers={"Content-Disposition": "attachment; filename=guide.xml"}
xmltv_data, mimetype="application/xml", headers={"Content-Disposition": "attachment; filename=guide.xml"}
)
# Replace image URLs in the XMLTV content with proxy URLs
@@ -371,20 +455,15 @@ def generate_xmltv():
proxied_url = f"{proxy_url}/image-proxy/{encode_url(original_url)}"
return f'<icon src="{proxied_url}"'
xmltv_data = re.sub(
r'<icon src="([^"]+)"',
replace_icon_url,
xmltv_data
)
xmltv_data = re.sub(r'<icon src="([^"]+)"', replace_icon_url, xmltv_data)
# Return the XMLTV data
return Response(
xmltv_data,
mimetype='application/xml',
headers={"Content-Disposition": "attachment; filename=guide.xml"}
xmltv_data, mimetype="application/xml", headers={"Content-Disposition": "attachment; filename=guide.xml"}
)
@app.route('/m3u', methods=['GET'])
@app.route("/m3u", methods=["GET"])
def generate_m3u():
"""Generate a filtered M3U playlist from the Xtream API"""
# Get and validate parameters
@@ -393,32 +472,34 @@ def generate_m3u():
return error
# Parse filter parameters
unwanted_groups = parse_group_list(request.args.get('unwanted_groups', ''))
wanted_groups = parse_group_list(request.args.get('wanted_groups', ''))
no_stream_proxy = request.args.get('nostreamproxy', '').lower() == 'true'
unwanted_groups = parse_group_list(request.args.get("unwanted_groups", ""))
wanted_groups = parse_group_list(request.args.get("wanted_groups", ""))
no_stream_proxy = request.args.get("nostreamproxy", "").lower() == "true"
include_vod = request.args.get("include_vod", "").lower() == "true"
# Log filter parameters
logger.info(f"Filter parameters - wanted_groups: {wanted_groups}, unwanted_groups: {unwanted_groups}")
logger.info(
f"Filter parameters - wanted_groups: {wanted_groups}, unwanted_groups: {unwanted_groups}, include_vod: {include_vod}"
)
# Validate credentials
user_data, error_json, error_code = validate_xtream_credentials(url, username, password)
if error_json:
return error_json, error_code, {'Content-Type': 'application/json'}
return error_json, error_code, {"Content-Type": "application/json"}
# Fetch categories and channels
categories, channels, error_json, error_code = fetch_categories_and_channels(url, username, password)
categories, streams, error_json, error_code = fetch_categories_and_channels(url, username, password, include_vod)
if error_json:
return error_json, error_code, {'Content-Type': 'application/json'}
return error_json, error_code, {"Content-Type": "application/json"}
# Extract user info and server URL
username = user_data['user_info']['username']
password = user_data['user_info']['password']
username = user_data["user_info"]["username"]
password = user_data["user_info"]["password"]
server_url = f"http://{user_data['server_info']['url']}:{user_data['server_info']['port']}"
stream_base_url = f"{server_url}/live/{username}/{password}/"
# Create category name lookup
category_names = {cat['category_id']: cat['category_name'] for cat in categories}
category_names = {cat["category_id"]: cat["category_name"] for cat in categories}
# Log all available groups
all_groups = set(category_names.values())
@@ -430,48 +511,86 @@ def generate_m3u():
# Track included groups
included_groups = set()
for channel in channels:
if channel['stream_type'] == 'live':
group_title = category_names.get(channel["category_id"], "Uncategorized")
for stream in streams:
content_type = stream.get("content_type", "live")
# Handle filtering logic
include_channel = True
# Determine group title based on content type
if content_type == "series":
# For series, use series name as group title
group_title = f"Series - {category_names.get(stream.get('category_id'), 'Uncategorized')}"
stream_name = stream.get("name", "Unknown Series")
else:
# For live and VOD content
group_title = category_names.get(stream.get("category_id"), "Uncategorized")
stream_name = stream.get("name", "Unknown")
if wanted_groups:
# Only include channels from specified groups
include_channel = any(group_matches(group_title, wanted_group) for wanted_group in wanted_groups)
elif unwanted_groups:
# Exclude channels from unwanted groups
include_channel = not any(group_matches(group_title, unwanted_group) for unwanted_group in unwanted_groups)
# Add content type prefix for VOD
if content_type == "vod":
group_title = f"VOD - {group_title}"
if include_channel:
included_groups.add(group_title)
# Handle logo URL - proxy only if stream proxying is enabled
original_logo = channel.get('stream_icon', '')
if original_logo and not no_stream_proxy:
logo_url = f"{proxy_url}/image-proxy/{encode_url(original_logo)}"
# Handle filtering logic
include_stream = True
if wanted_groups:
# Only include streams from specified groups
include_stream = any(group_matches(group_title, wanted_group) for wanted_group in wanted_groups)
elif unwanted_groups:
# Exclude streams from unwanted groups
include_stream = not any(group_matches(group_title, unwanted_group) for unwanted_group in unwanted_groups)
if include_stream:
included_groups.add(group_title)
# Handle logo URL - proxy only if stream proxying is enabled
original_logo = stream.get("stream_icon", "")
if original_logo and not no_stream_proxy:
logo_url = f"{proxy_url}/image-proxy/{encode_url(original_logo)}"
else:
logo_url = original_logo
# Create the stream URL based on content type
if content_type == "live":
# Live TV streams
stream_url = f"{server_url}/live/{username}/{password}/{stream['stream_id']}.ts"
elif content_type == "vod":
# VOD streams
stream_url = f"{server_url}/movie/{username}/{password}/{stream['stream_id']}.{stream.get('container_extension', 'mp4')}"
elif content_type == "series":
# Series streams - use the first episode if available
if "episodes" in stream and stream["episodes"]:
first_episode = list(stream["episodes"].values())[0][0] if stream["episodes"] else None
if first_episode:
episode_id = first_episode.get("id", stream.get("series_id", ""))
stream_url = f"{server_url}/series/{username}/{password}/{episode_id}.{first_episode.get('container_extension', 'mp4')}"
else:
continue # Skip series without episodes
else:
logo_url = original_logo
# Fallback for series without episode data
series_id = stream.get("series_id", stream.get("stream_id", ""))
stream_url = f"{server_url}/series/{username}/{password}/{series_id}.mp4"
# Create the stream URL with or without proxying
stream_url = f'{stream_base_url}{channel["stream_id"]}.ts'
if not no_stream_proxy:
stream_url = f"{proxy_url}/stream-proxy/{encode_url(stream_url)}"
# Apply stream proxying if enabled
if not no_stream_proxy:
stream_url = f"{proxy_url}/stream-proxy/{encode_url(stream_url)}"
# Add channel to playlist
m3u_playlist += f'#EXTINF:0 tvg-name="{channel["name"]}" group-title="{group_title}" tvg-logo="{logo_url}",{channel["name"]}\n'
m3u_playlist += f'{stream_url}\n'
# Add stream to playlist
m3u_playlist += (
f'#EXTINF:0 tvg-name="{stream_name}" group-title="{group_title}" tvg-logo="{logo_url}",{stream_name}\n'
)
m3u_playlist += f"{stream_url}\n"
# Log included groups after filtering
logger.info(f"Groups included after filtering: {sorted(included_groups)}")
logger.info(f"Groups excluded after filtering: {sorted(all_groups - included_groups)}")
# Determine filename based on content included
filename = "FullPlaylist.m3u" if include_vod else "LiveStream.m3u"
# Return the M3U playlist
return Response(
m3u_playlist,
mimetype='audio/x-scpls',
headers={"Content-Disposition": "attachment; filename=LiveStream.m3u"}
m3u_playlist, mimetype="audio/x-scpls", headers={"Content-Disposition": f"attachment; filename={filename}"}
)
if __name__ == '__main__':
app.run(debug=True, host='0.0.0.0')
if __name__ == "__main__":
app.run(debug=True, host="0.0.0.0")