mirror of
https://github.com/ovosimpatico/xtream2m3u.git
synced 2026-01-15 16:32:55 -03:00
Add optional VOD support
This commit is contained in:
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
1029
frontend/style.css
1029
frontend/style.css
File diff suppressed because it is too large
Load Diff
411
run.py
411
run.py
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user