mirror of
https://github.com/ovosimpatico/xtream2m3u.git
synced 2026-01-15 08:22:56 -03:00
improved website and readme inconsistencies, and fixed shows m3u generation
This commit is contained in:
237
README.md
237
README.md
@@ -11,11 +11,12 @@
|
||||
|
||||
<p align="center">
|
||||
<a href="#about">About</a> •
|
||||
<a href="#features">Features</a> •
|
||||
<a href="#prerequisites">Prerequisites</a> •
|
||||
<a href="#installation">Installation</a> •
|
||||
<a href="#usage">Usage</a> •
|
||||
<a href="#license">License</a> •
|
||||
<a href="#disclaimer">Disclaimer</a>
|
||||
<a href="#api-documentation">API</a> •
|
||||
<a href="#license">License</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -32,169 +33,141 @@
|
||||
|
||||
## About
|
||||
|
||||
xtream2m3u is a powerful and flexible tool designed to bridge the gap between Xtream API-based IPTV services and M3U playlist-compatible media players. It provides a simple API that fetches live streams from Xtream IPTV services, filters out unwanted channel groups, and generates a customized M3U playlist file.
|
||||
**xtream2m3u** is a powerful and flexible tool designed to bridge the gap between Xtream API-based IPTV services and M3U playlist-compatible media players. It offers a **user-friendly web interface** and a **comprehensive API** to generate customized playlists.
|
||||
|
||||
### Why xtream2m3u?
|
||||
Many IPTV providers use the Xtream API, which isn't directly compatible with all players. xtream2m3u allows you to:
|
||||
1. Connect to your Xtream IPTV provider.
|
||||
2. Select exactly which channel groups (Live TV) or VOD categories (Movies/Series) you want.
|
||||
3. Generate a standard M3U playlist compatible with almost any player (VLC, TiviMate, Televizo, etc.).
|
||||
|
||||
Many IPTV providers use the Xtream API, which isn't directly compatible with media players that accept M3U playlists. xtream2m3u solves this problem by:
|
||||
## Features
|
||||
|
||||
1. Connecting to Xtream API-based IPTV services
|
||||
2. Fetching the list of available live streams
|
||||
3. Allowing users to filter channels by including only wanted groups or excluding unwanted groups
|
||||
4. Generating a standard M3U playlist that's compatible with a wide range of media players
|
||||
* **Web Interface:** Easy-to-use UI for managing credentials and selecting categories.
|
||||
* **Custom Playlists:** Filter channels by including or excluding specific groups.
|
||||
* **VOD Support:** Optionally include Movies and Series in your playlist.
|
||||
* **Stream Proxying:** built-in proxy to handle CORS issues or hide upstream URLs.
|
||||
* **Custom DNS:** Uses reliable DNS resolvers (Cloudflare, Google) to ensure connection stability.
|
||||
* **XMLTV EPG:** Generates a compatible XMLTV guide for your playlist.
|
||||
* **Docker Ready:** Simple deployment with Docker and Docker Compose.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
To use xtream2m3u, you'll need:
|
||||
* An active subscription to an IPTV service that uses the Xtream API.
|
||||
|
||||
- An active subscription to an IPTV service that uses the Xtream API
|
||||
|
||||
For deployment, you'll need one of the following:
|
||||
|
||||
- Docker and Docker Compose
|
||||
- Python 3.12 or higher
|
||||
|
||||
## Environment Variables
|
||||
|
||||
The application supports the following environment variables:
|
||||
|
||||
- `PROXY_URL`: [Optional] Set a default custom base URL for all proxied content (can be overridden with the `proxy_url` parameter)
|
||||
For deployment:
|
||||
* **Docker & Docker Compose** (Recommended)
|
||||
* OR **Python 3.9+**
|
||||
|
||||
## Installation
|
||||
|
||||
### Using Docker (Recommended)
|
||||
|
||||
1. Install Docker and Docker Compose
|
||||
2. Clone the repository:
|
||||
```
|
||||
git clone https://github.com/ovosimpatico/xtream2m3u.git
|
||||
cd xtream2m3u
|
||||
```
|
||||
3. Run the application:
|
||||
```
|
||||
docker-compose up -d
|
||||
```
|
||||
1. Clone the repository:
|
||||
```bash
|
||||
git clone https://github.com/ovosimpatico/xtream2m3u.git
|
||||
cd xtream2m3u
|
||||
```
|
||||
2. Run the application:
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
3. Open your browser and navigate to `http://localhost:5000`.
|
||||
|
||||
### Native Python Installation
|
||||
|
||||
1. Install Python (3.9 or higher)
|
||||
2. Clone the repository:
|
||||
```
|
||||
git clone https://github.com/ovosimpatico/xtream2m3u.git
|
||||
cd xtream2m3u
|
||||
```
|
||||
3. Create a virtual environment:
|
||||
```
|
||||
python -m venv venv
|
||||
source venv/bin/activate # On Windows, use `venv\Scripts\activate`
|
||||
```
|
||||
4. Install the required packages:
|
||||
```
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
5. Run the application:
|
||||
```
|
||||
python run.py
|
||||
```
|
||||
1. Clone the repository and enter the directory:
|
||||
```bash
|
||||
git clone https://github.com/ovosimpatico/xtream2m3u.git
|
||||
cd xtream2m3u
|
||||
```
|
||||
2. Create and activate a virtual environment:
|
||||
```bash
|
||||
python -m venv venv
|
||||
source venv/bin/activate # On Windows: venv\Scripts\activate
|
||||
```
|
||||
3. Install dependencies:
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
4. Run the server:
|
||||
```bash
|
||||
python run.py
|
||||
```
|
||||
5. Open your browser and navigate to `http://localhost:5000`.
|
||||
|
||||
## Usage
|
||||
|
||||
### API Endpoints
|
||||
### Web Interface
|
||||
The easiest way to use xtream2m3u is via the web interface at `http://localhost:5000`.
|
||||
1. **Enter Credentials:** Input your IPTV provider's URL, username, and password.
|
||||
2. **Select Content:** Choose whether to include VOD (Movies & Series).
|
||||
3. **Filter Categories:** Load categories and select which ones to include or exclude.
|
||||
4. **Generate:** Click "Generate Playlist" to download your custom M3U file.
|
||||
|
||||
The application provides several endpoints for generating playlists and proxying media:
|
||||
### Environment Variables
|
||||
* `PROXY_URL`: [Optional] Set a custom base URL for proxied content (useful if running behind a reverse proxy).
|
||||
* `PORT`: [Optional] Port to run the server on (default: 5000).
|
||||
|
||||
#### M3U Playlist Generation
|
||||
## API Documentation
|
||||
|
||||
For advanced users or automation, you can use the API endpoints directly.
|
||||
|
||||
### 1. Generate M3U Playlist
|
||||
`GET /m3u` or `POST /m3u`
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `url` | string | Yes | IPTV Service URL |
|
||||
| `username` | string | Yes | IPTV Username |
|
||||
| `password` | string | Yes | IPTV Password |
|
||||
| `unwanted_groups` | string | No | Comma-separated list of groups to **exclude** |
|
||||
| `wanted_groups` | string | No | Comma-separated list of groups to **include** (takes precedence) |
|
||||
| `include_vod` | boolean | No | Set `true` to include Movies & Series (default: `false`) |
|
||||
| `nostreamproxy` | boolean | No | Set `true` to disable stream proxying (direct links) |
|
||||
| `proxy_url` | string | No | Custom base URL for proxied streams |
|
||||
| `include_channel_id` | boolean | No | Set `true` to include `epg_channel_id` tag |
|
||||
| `channel_id_tag` | string | No | Custom tag name for channel ID (default: `channel-id`) |
|
||||
|
||||
**Wildcard Support:** `unwanted_groups` and `wanted_groups` support `*` (wildcard) and `?` (single char).
|
||||
* Example: `*Sports*` matches "Sky Sports", "BeIN Sports", etc.
|
||||
|
||||
**Example:**
|
||||
```
|
||||
GET /m3u
|
||||
http://localhost:5000/m3u?url=http://iptv.com&username=user&password=pass&wanted_groups=Sports*,News&include_vod=true
|
||||
```
|
||||
|
||||
##### Query Parameters
|
||||
### 2. Generate XMLTV Guide
|
||||
`GET /xmltv`
|
||||
|
||||
- `url` (required): The base URL of your IPTV service
|
||||
- `username` (required): Your IPTV service username
|
||||
- `password` (required): Your IPTV service password
|
||||
- `unwanted_groups` (optional): A comma-separated list of group names to exclude
|
||||
- `wanted_groups` (optional): A comma-separated list of group names to include (takes precedence over unwanted_groups)
|
||||
- `nostreamproxy` (optional): Set to 'true' to disable stream proxying
|
||||
- `proxy_url` (optional): Custom base URL for proxied content (overrides auto-detection)
|
||||
- `include_channel_id` (optional): Set to 'true' to include `epg_channel_id` in M3U, useful for [Channels](https://getchannels.com)
|
||||
- `channel_id_tag` (optional): Name of the tag to use for `epg_channel_id` data in M3U, defaults to `channel-id`
|
||||
| Parameter | Type | Required | Description |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `url` | string | Yes | IPTV Service URL |
|
||||
| `username` | string | Yes | IPTV Username |
|
||||
| `password` | string | Yes | IPTV Password |
|
||||
| `proxy_url` | string | No | Custom base URL for proxied images |
|
||||
|
||||
Note: For `unwanted_groups` and `wanted_groups`, you can use wildcard patterns with `*` and `?` characters. For example:
|
||||
- `US*` will match all groups starting with "US"
|
||||
- `*Sports*` will match any group containing "Sports"
|
||||
- `US| ?/?/?` will match groups like "US| 24/7"
|
||||
### 3. Get Categories
|
||||
`GET /categories`
|
||||
|
||||
##### Example Request
|
||||
Returns a JSON list of all available categories.
|
||||
|
||||
```
|
||||
http://localhost:5000/m3u?url=http://your-iptv-service.com&username=your_username&password=your_password&unwanted_groups=news,sports
|
||||
```
|
||||
| Parameter | Type | Required | Description |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `url` | string | Yes | IPTV Service URL |
|
||||
| `username` | string | Yes | IPTV Username |
|
||||
| `password` | string | Yes | IPTV Password |
|
||||
| `include_vod` | boolean | No | Set `true` to include VOD categories |
|
||||
|
||||
Or to only include specific groups:
|
||||
|
||||
```
|
||||
http://localhost:5000/m3u?url=http://your-iptv-service.com&username=your_username&password=your_password&wanted_groups=movies,series
|
||||
```
|
||||
|
||||
With a custom proxy URL:
|
||||
|
||||
```
|
||||
http://localhost:5000/m3u?url=http://your-iptv-service.com&username=your_username&password=your_password&proxy_url=https://your-public-domain.com
|
||||
```
|
||||
|
||||
#### XMLTV Guide Generation
|
||||
|
||||
```
|
||||
GET /xmltv
|
||||
```
|
||||
|
||||
##### Query Parameters
|
||||
|
||||
- `url` (required): The base URL of your IPTV service
|
||||
- `username` (required): Your IPTV service username
|
||||
- `password` (required): Your IPTV service password
|
||||
- `proxy_url` (optional): Custom base URL for proxied content (overrides auto-detection)
|
||||
|
||||
|
||||
##### Example Request
|
||||
|
||||
```
|
||||
http://localhost:5000/xmltv?url=http://your-iptv-service.com&username=your_username&password=your_password
|
||||
```
|
||||
|
||||
With a custom proxy URL:
|
||||
|
||||
```
|
||||
http://localhost:5000/xmltv?url=http://your-iptv-service.com&username=your_username&password=your_password&proxy_url=https://your-public-domain.com
|
||||
```
|
||||
|
||||
#### Image Proxy
|
||||
|
||||
```
|
||||
GET /image-proxy/<encoded_image_url>
|
||||
```
|
||||
|
||||
Proxies image requests, like channel logos and EPG images.
|
||||
|
||||
#### Stream Proxy
|
||||
|
||||
```
|
||||
GET /stream-proxy/<encoded_stream_url>
|
||||
```
|
||||
|
||||
Proxies video streams. Supports the following formats:
|
||||
- MPEG-TS (.ts)
|
||||
- HLS (.m3u8)
|
||||
- Generic video streams
|
||||
### 4. Proxy Endpoints
|
||||
* `GET /image-proxy/<encoded_url>`: Proxies images (logos, covers).
|
||||
* `GET /stream-proxy/<encoded_url>`: Proxies video streams.
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the GNU Affero General Public License v3.0 (AGPLv3). This license requires that any modifications to the code must also be made available under the same license, even when the software is run as a service (e.g., over a network). See the [LICENSE](LICENSE) file for details.
|
||||
This project is licensed under the **GNU Affero General Public License v3.0 (AGPLv3)**.
|
||||
See the [LICENSE](LICENSE) file for details.
|
||||
|
||||
## Disclaimer
|
||||
|
||||
xtream2m3u is a tool for generating M3U playlists from Xtream API-based IPTV services but does not provide IPTV services itself. A valid subscription to an IPTV service using the Xtream API is required to use this tool.
|
||||
|
||||
xtream2m3u does not endorse piracy and requires users to ensure they have the necessary rights and permissions. The developers are not responsible for any misuse of the software or violations of IPTV providers' terms of service.
|
||||
xtream2m3u is a tool for managing your own legal IPTV subscriptions. It **does not** provide any content, channels, or streams. The developers are not responsible for how this tool is used.
|
||||
|
||||
32
app/__init__.py
Normal file
32
app/__init__.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""Flask application factory and configuration"""
|
||||
import logging
|
||||
import os
|
||||
|
||||
from flask import Flask
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def create_app():
|
||||
"""Create and configure the Flask application"""
|
||||
app = Flask(__name__,
|
||||
static_folder='../frontend',
|
||||
template_folder='../frontend')
|
||||
|
||||
# Get default proxy URL from environment variable
|
||||
app.config['DEFAULT_PROXY_URL'] = os.environ.get("PROXY_URL")
|
||||
|
||||
# Register blueprints
|
||||
from app.routes.api import api_bp
|
||||
from app.routes.proxy import proxy_bp
|
||||
from app.routes.static import static_bp
|
||||
|
||||
app.register_blueprint(static_bp)
|
||||
app.register_blueprint(proxy_bp)
|
||||
app.register_blueprint(api_bp)
|
||||
|
||||
logger.info("Flask application created and configured")
|
||||
|
||||
return app
|
||||
6
app/routes/__init__.py
Normal file
6
app/routes/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""Routes package - Register blueprints here"""
|
||||
from .api import api_bp
|
||||
from .proxy import proxy_bp
|
||||
from .static import static_bp
|
||||
|
||||
__all__ = ['static_bp', 'proxy_bp', 'api_bp']
|
||||
208
app/routes/api.py
Normal file
208
app/routes/api.py
Normal file
@@ -0,0 +1,208 @@
|
||||
"""API routes for Xtream Codes proxy (categories, M3U, XMLTV)"""
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
|
||||
from flask import Blueprint, Response, current_app, jsonify, request
|
||||
|
||||
from app.services import (
|
||||
fetch_api_data,
|
||||
fetch_categories_and_channels,
|
||||
generate_m3u_playlist,
|
||||
validate_xtream_credentials,
|
||||
)
|
||||
from app.utils import encode_url, parse_group_list
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
api_bp = Blueprint('api', __name__)
|
||||
|
||||
|
||||
def get_required_params():
|
||||
"""Get and validate the required parameters from the request (supports both GET and POST)"""
|
||||
# Handle both GET and POST requests
|
||||
if request.method == "POST":
|
||||
data = request.get_json() or {}
|
||||
url = data.get("url")
|
||||
username = data.get("username")
|
||||
password = data.get("password")
|
||||
proxy_url = data.get("proxy_url", current_app.config['DEFAULT_PROXY_URL']) or request.host_url.rstrip("/")
|
||||
else:
|
||||
url = request.args.get("url")
|
||||
username = request.args.get("username")
|
||||
password = request.args.get("password")
|
||||
proxy_url = request.args.get("proxy_url", current_app.config['DEFAULT_PROXY_URL']) or request.host_url.rstrip("/")
|
||||
|
||||
if not url or not username or not password:
|
||||
return (
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
jsonify({"error": "Missing Parameters", "details": "Required parameters: url, username, and password"}),
|
||||
400
|
||||
)
|
||||
|
||||
return url, username, password, proxy_url, None, None
|
||||
|
||||
|
||||
@api_bp.route("/categories", methods=["GET"])
|
||||
def get_categories():
|
||||
"""Get all available categories from the Xtream API"""
|
||||
# Get and validate parameters
|
||||
url, username, password, proxy_url, error, status_code = get_required_params()
|
||||
if error:
|
||||
return error, status_code
|
||||
|
||||
# Check for VOD parameter - default to false to avoid timeouts (VOD is massive and slow!)
|
||||
include_vod = request.args.get("include_vod", "false").lower() == "true"
|
||||
logger.info(f"VOD content requested: {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"}
|
||||
|
||||
# Fetch categories
|
||||
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 categories as JSON
|
||||
return json.dumps(categories), 200, {"Content-Type": "application/json"}
|
||||
|
||||
|
||||
@api_bp.route("/xmltv", methods=["GET"])
|
||||
def generate_xmltv():
|
||||
"""Generate a filtered XMLTV file from the Xtream API"""
|
||||
# Get and validate parameters
|
||||
url, username, password, proxy_url, error, status_code = get_required_params()
|
||||
if error:
|
||||
return error, status_code
|
||||
|
||||
# No filtering supported for XMLTV endpoint
|
||||
|
||||
# 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"}
|
||||
|
||||
# Fetch XMLTV data
|
||||
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"}
|
||||
|
||||
# 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"}
|
||||
)
|
||||
|
||||
# Replace image URLs in the XMLTV content with proxy URLs
|
||||
def replace_icon_url(match):
|
||||
original_url = match.group(1)
|
||||
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)
|
||||
|
||||
# Return the XMLTV data
|
||||
return Response(
|
||||
xmltv_data, mimetype="application/xml", headers={"Content-Disposition": "attachment; filename=guide.xml"}
|
||||
)
|
||||
|
||||
|
||||
@api_bp.route("/m3u", methods=["GET", "POST"])
|
||||
def generate_m3u():
|
||||
"""Generate a filtered M3U playlist from the Xtream API"""
|
||||
# Get and validate parameters
|
||||
url, username, password, proxy_url, error, status_code = get_required_params()
|
||||
if error:
|
||||
return error, status_code
|
||||
|
||||
# Parse filter parameters (support both GET and POST for large filter lists)
|
||||
if request.method == "POST":
|
||||
data = request.get_json() or {}
|
||||
unwanted_groups = parse_group_list(data.get("unwanted_groups", ""))
|
||||
wanted_groups = parse_group_list(data.get("wanted_groups", ""))
|
||||
no_stream_proxy = str(data.get("nostreamproxy", "")).lower() == "true"
|
||||
include_vod = str(data.get("include_vod", "false")).lower() == "true"
|
||||
include_channel_id = str(data.get("include_channel_id", "false")).lower() == "true"
|
||||
channel_id_tag = str(data.get("channel_id_tag", "channel-id"))
|
||||
logger.info("🔄 Processing POST request for M3U generation")
|
||||
else:
|
||||
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", "false").lower() == "true"
|
||||
include_channel_id = request.args.get("include_channel_id", "false") == "true"
|
||||
channel_id_tag = request.args.get("channel_id_tag", "channel-id")
|
||||
logger.info("🔄 Processing GET request for M3U generation")
|
||||
|
||||
# For M3U generation, warn about VOD performance impact
|
||||
if include_vod:
|
||||
logger.warning("⚠️ M3U generation with VOD enabled - expect 2-5 minute generation time!")
|
||||
else:
|
||||
logger.info("⚡ M3U generation for live content only - should be fast!")
|
||||
|
||||
# Log filter parameters (truncate if too long for readability)
|
||||
wanted_display = f"{len(wanted_groups)} groups" if len(wanted_groups) > 10 else str(wanted_groups)
|
||||
unwanted_display = f"{len(unwanted_groups)} groups" if len(unwanted_groups) > 10 else str(unwanted_groups)
|
||||
logger.info(f"Filter parameters - wanted_groups: {wanted_display}, unwanted_groups: {unwanted_display}, include_vod: {include_vod}")
|
||||
|
||||
# Warn about massive filter lists
|
||||
total_filters = len(wanted_groups) + len(unwanted_groups)
|
||||
if total_filters > 20:
|
||||
logger.warning(f"⚠️ Large filter list detected ({total_filters} categories) - this will be slower!")
|
||||
if total_filters > 50:
|
||||
logger.warning(f"🐌 MASSIVE filter list ({total_filters} categories) - expect 3-5 minute processing time!")
|
||||
|
||||
# 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"}
|
||||
|
||||
# Fetch categories and channels
|
||||
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"}
|
||||
|
||||
# Extract user info and server URL
|
||||
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']}"
|
||||
|
||||
# Generate M3U playlist
|
||||
m3u_playlist = generate_m3u_playlist(
|
||||
url=url,
|
||||
username=username,
|
||||
password=password,
|
||||
server_url=server_url,
|
||||
categories=categories,
|
||||
streams=streams,
|
||||
wanted_groups=wanted_groups,
|
||||
unwanted_groups=unwanted_groups,
|
||||
no_stream_proxy=no_stream_proxy,
|
||||
include_vod=include_vod,
|
||||
include_channel_id=include_channel_id,
|
||||
channel_id_tag=channel_id_tag,
|
||||
proxy_url=proxy_url
|
||||
)
|
||||
|
||||
# Determine filename based on content included
|
||||
filename = "FullPlaylist.m3u" if include_vod else "LiveStream.m3u"
|
||||
|
||||
# Return the M3U playlist with proper CORS headers for frontend
|
||||
headers = {
|
||||
"Content-Disposition": f"attachment; filename={filename}",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type"
|
||||
}
|
||||
|
||||
return Response(m3u_playlist, mimetype="audio/x-scpls", headers=headers)
|
||||
71
app/routes/proxy.py
Normal file
71
app/routes/proxy.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""Proxy routes for images and streams"""
|
||||
import logging
|
||||
import urllib.parse
|
||||
|
||||
import requests
|
||||
from flask import Blueprint, Response
|
||||
|
||||
from app.utils.streaming import generate_streaming_response, stream_request
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
proxy_bp = Blueprint('proxy', __name__)
|
||||
|
||||
|
||||
@proxy_bp.route("/image-proxy/<path:image_url>")
|
||||
def proxy_image(image_url):
|
||||
"""Proxy endpoint for images to avoid CORS issues"""
|
||||
try:
|
||||
original_url = urllib.parse.unquote(image_url)
|
||||
logger.info(f"Image proxy request for: {original_url}")
|
||||
|
||||
response = requests.get(original_url, stream=True, timeout=10)
|
||||
response.raise_for_status()
|
||||
|
||||
content_type = response.headers.get("Content-Type", "")
|
||||
|
||||
if not content_type.startswith("image/"):
|
||||
logger.error(f"Invalid content type for image: {content_type}")
|
||||
return Response("Invalid image type", status=415)
|
||||
|
||||
return generate_streaming_response(response, content_type)
|
||||
except requests.Timeout:
|
||||
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)
|
||||
except Exception as e:
|
||||
logger.error(f"Image proxy error: {str(e)}")
|
||||
return Response("Failed to process image", status=500)
|
||||
|
||||
|
||||
@proxy_bp.route("/stream-proxy/<path:stream_url>")
|
||||
def proxy_stream(stream_url):
|
||||
"""Proxy endpoint for streams"""
|
||||
try:
|
||||
original_url = urllib.parse.unquote(stream_url)
|
||||
logger.info(f"Stream proxy request for: {original_url}")
|
||||
|
||||
response = stream_request(original_url, timeout=60) # Longer timeout for live streams
|
||||
response.raise_for_status()
|
||||
|
||||
# Determine 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"
|
||||
else:
|
||||
content_type = "application/octet-stream"
|
||||
|
||||
logger.info(f"Using content type: {content_type}")
|
||||
return generate_streaming_response(response, content_type)
|
||||
except requests.Timeout:
|
||||
logger.error(f"Timeout connecting to stream: {original_url}")
|
||||
return Response("Stream timeout", status=504)
|
||||
except requests.HTTPError as e:
|
||||
logger.error(f"HTTP error fetching stream: {e.response.status_code} - {original_url}")
|
||||
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)} - {original_url}")
|
||||
return Response("Failed to process stream", status=500)
|
||||
45
app/routes/static.py
Normal file
45
app/routes/static.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""Static file and frontend routes"""
|
||||
import logging
|
||||
import os
|
||||
|
||||
from flask import Blueprint, send_from_directory
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
static_bp = Blueprint('static', __name__)
|
||||
|
||||
# Get the base directory (project root)
|
||||
BASE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
|
||||
FRONTEND_DIR = os.path.join(BASE_DIR, 'frontend')
|
||||
ASSETS_DIR = os.path.join(BASE_DIR, 'docs', 'assets')
|
||||
|
||||
|
||||
@static_bp.route("/")
|
||||
def serve_frontend():
|
||||
"""Serve the frontend index.html file"""
|
||||
return send_from_directory(FRONTEND_DIR, "index.html")
|
||||
|
||||
|
||||
@static_bp.route("/assets/<path:filename>")
|
||||
def serve_assets(filename):
|
||||
"""Serve assets from the docs/assets directory"""
|
||||
try:
|
||||
return send_from_directory(ASSETS_DIR, filename)
|
||||
except:
|
||||
return "Asset not found", 404
|
||||
|
||||
|
||||
@static_bp.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:
|
||||
return "Not found", 404
|
||||
|
||||
# Only serve files that exist in the frontend directory
|
||||
try:
|
||||
return send_from_directory(FRONTEND_DIR, filename)
|
||||
except:
|
||||
# If file doesn't exist in frontend, return 404
|
||||
return "File not found", 404
|
||||
16
app/services/__init__.py
Normal file
16
app/services/__init__.py
Normal file
@@ -0,0 +1,16 @@
|
||||
"""Services package"""
|
||||
from .m3u_generator import generate_m3u_playlist
|
||||
from .xtream_api import (
|
||||
fetch_api_data,
|
||||
fetch_categories_and_channels,
|
||||
fetch_series_episodes,
|
||||
validate_xtream_credentials,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'fetch_api_data',
|
||||
'validate_xtream_credentials',
|
||||
'fetch_categories_and_channels',
|
||||
'fetch_series_episodes',
|
||||
'generate_m3u_playlist'
|
||||
]
|
||||
250
app/services/m3u_generator.py
Normal file
250
app/services/m3u_generator.py
Normal file
@@ -0,0 +1,250 @@
|
||||
"""M3U playlist generation service"""
|
||||
import logging
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
from app.services.xtream_api import fetch_series_episodes
|
||||
from app.utils import encode_url, group_matches
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def generate_m3u_playlist(
|
||||
url,
|
||||
username,
|
||||
password,
|
||||
server_url,
|
||||
categories,
|
||||
streams,
|
||||
wanted_groups=None,
|
||||
unwanted_groups=None,
|
||||
no_stream_proxy=False,
|
||||
include_vod=False,
|
||||
include_channel_id=False,
|
||||
channel_id_tag="channel-id",
|
||||
proxy_url=None
|
||||
):
|
||||
"""
|
||||
Generate an M3U playlist from Xtream API data
|
||||
|
||||
Args:
|
||||
url: Xtream API base URL
|
||||
username: Xtream API username
|
||||
password: Xtream API password
|
||||
server_url: Server URL for streaming
|
||||
categories: List of categories
|
||||
streams: List of streams
|
||||
wanted_groups: List of group patterns to include (optional)
|
||||
unwanted_groups: List of group patterns to exclude (optional)
|
||||
no_stream_proxy: Whether to disable stream proxying
|
||||
include_vod: Whether VOD content is included
|
||||
include_channel_id: Whether to include channel IDs
|
||||
channel_id_tag: Tag name for channel IDs
|
||||
proxy_url: Proxy URL for images and streams
|
||||
|
||||
Returns:
|
||||
M3U playlist string
|
||||
"""
|
||||
# Create category name lookup
|
||||
category_names = {cat["category_id"]: cat["category_name"] for cat in categories}
|
||||
|
||||
# Log all available groups
|
||||
all_groups = set(category_names.values())
|
||||
logger.info(f"All available groups: {sorted(all_groups)}")
|
||||
|
||||
# Generate M3U playlist
|
||||
m3u_playlist = "#EXTM3U\n"
|
||||
|
||||
# Track included groups
|
||||
included_groups = set()
|
||||
processed_streams = 0
|
||||
total_streams = len(streams)
|
||||
|
||||
# Pre-compile filter patterns for massive filter lists (performance optimization)
|
||||
wanted_patterns = [pattern.lower() for pattern in wanted_groups] if wanted_groups else []
|
||||
unwanted_patterns = [pattern.lower() for pattern in unwanted_groups] if unwanted_groups else []
|
||||
|
||||
logger.info(f"🔍 Starting to filter {total_streams} streams...")
|
||||
batch_size = 10000 # Process streams in batches for better performance
|
||||
|
||||
# Filter series to fetch episodes for (optimization to avoid fetching episodes for excluded series)
|
||||
series_episodes_map = {}
|
||||
if include_vod:
|
||||
series_streams = [s for s in streams if s.get("content_type") == "series"]
|
||||
if series_streams:
|
||||
logger.info(f"Found {len(series_streams)} series. Filtering to determine which need episodes...")
|
||||
|
||||
series_to_fetch = []
|
||||
for stream in series_streams:
|
||||
# Get raw category name for filtering
|
||||
category_name = category_names.get(stream.get('category_id'), 'Uncategorized')
|
||||
|
||||
# Calculate group_title (prefixed)
|
||||
group_title = f"Series - {category_name}"
|
||||
|
||||
# Check filter against both raw category name and prefixed name
|
||||
# This ensures we match "Action" (raw) AND "Series - Action" (prefixed)
|
||||
should_fetch = True
|
||||
if wanted_patterns:
|
||||
should_fetch = any(
|
||||
group_matches(category_name, w) or group_matches(group_title, w)
|
||||
for w in wanted_groups
|
||||
)
|
||||
elif unwanted_patterns:
|
||||
should_fetch = not any(
|
||||
group_matches(category_name, u) or group_matches(group_title, u)
|
||||
for u in unwanted_groups
|
||||
)
|
||||
|
||||
if should_fetch:
|
||||
series_to_fetch.append(stream)
|
||||
|
||||
if series_to_fetch:
|
||||
logger.info(f"Fetching episodes for {len(series_to_fetch)} series (this might take a while)...")
|
||||
|
||||
with ThreadPoolExecutor(max_workers=5) as executor:
|
||||
future_to_series = {
|
||||
executor.submit(fetch_series_episodes, url, username, password, s.get("series_id")): s.get("series_id")
|
||||
for s in series_to_fetch
|
||||
}
|
||||
|
||||
completed_fetches = 0
|
||||
for future in as_completed(future_to_series):
|
||||
s_id, episodes = future.result()
|
||||
if episodes:
|
||||
series_episodes_map[s_id] = episodes
|
||||
|
||||
completed_fetches += 1
|
||||
if completed_fetches % 50 == 0:
|
||||
logger.info(f" Fetched episodes for {completed_fetches}/{len(series_to_fetch)} series")
|
||||
|
||||
logger.info(f"✅ Fetched episodes for {len(series_episodes_map)} series")
|
||||
|
||||
for stream in streams:
|
||||
content_type = stream.get("content_type", "live")
|
||||
|
||||
# Get raw category name
|
||||
category_name = category_names.get(stream.get("category_id"), "Uncategorized")
|
||||
|
||||
# Determine group title based on content type
|
||||
if content_type == "series":
|
||||
# For series, use series name as group title
|
||||
group_title = f"Series - {category_name}"
|
||||
stream_name = stream.get("name", "Unknown Series")
|
||||
else:
|
||||
# For live and VOD content
|
||||
group_title = category_name
|
||||
stream_name = stream.get("name", "Unknown")
|
||||
|
||||
# Add content type prefix for VOD
|
||||
if content_type == "vod":
|
||||
group_title = f"VOD - {category_name}"
|
||||
|
||||
# Optimized filtering logic using pre-compiled patterns
|
||||
include_stream = True
|
||||
|
||||
if wanted_patterns:
|
||||
# Only include streams from specified groups (optimized matching)
|
||||
# Check both raw category name and final group title to support flexible filtering
|
||||
include_stream = any(
|
||||
group_matches(category_name, wanted_group) or group_matches(group_title, wanted_group)
|
||||
for wanted_group in wanted_groups
|
||||
)
|
||||
elif unwanted_patterns:
|
||||
# Exclude streams from unwanted groups (optimized matching)
|
||||
include_stream = not any(
|
||||
group_matches(category_name, unwanted_group) or group_matches(group_title, unwanted_group)
|
||||
for unwanted_group in unwanted_groups
|
||||
)
|
||||
|
||||
processed_streams += 1
|
||||
|
||||
# Progress logging for large datasets
|
||||
if processed_streams % batch_size == 0:
|
||||
logger.info(f" 📊 Processed {processed_streams}/{total_streams} streams ({(processed_streams/total_streams)*100:.1f}%)")
|
||||
|
||||
if include_stream:
|
||||
included_groups.add(group_title)
|
||||
|
||||
tags = [
|
||||
f'tvg-name="{stream_name}"',
|
||||
f'group-title="{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
|
||||
tags.append(f'tvg-logo="{logo_url}"')
|
||||
|
||||
# Handle channel id if enabled
|
||||
if include_channel_id:
|
||||
channel_id = stream.get("epg_channel_id")
|
||||
if channel_id:
|
||||
tags.append(f'{channel_id_tag}="{channel_id}"')
|
||||
|
||||
# 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 - check if we have episodes
|
||||
episodes_data = series_episodes_map.get(stream.get("series_id"))
|
||||
|
||||
if episodes_data:
|
||||
# Sort seasons numerically if possible
|
||||
try:
|
||||
sorted_seasons = sorted(episodes_data.items(), key=lambda x: int(x[0]) if str(x[0]).isdigit() else 999)
|
||||
except:
|
||||
sorted_seasons = episodes_data.items()
|
||||
|
||||
for season_num, episodes in sorted_seasons:
|
||||
for episode in episodes:
|
||||
episode_id = episode.get("id")
|
||||
episode_num = episode.get("episode_num")
|
||||
episode_title = episode.get("title")
|
||||
container_ext = episode.get("container_extension", "mp4")
|
||||
|
||||
# Format title: Series Name - S01E01 - Episode Title
|
||||
full_title = f"{stream_name} - S{str(season_num).zfill(2)}E{str(episode_num).zfill(2)} - {episode_title}"
|
||||
|
||||
# Build stream URL for episode
|
||||
ep_stream_url = f"{server_url}/series/{username}/{password}/{episode_id}.{container_ext}"
|
||||
|
||||
# Apply stream proxying if enabled
|
||||
if not no_stream_proxy:
|
||||
ep_stream_url = f"{proxy_url}/stream-proxy/{encode_url(ep_stream_url)}"
|
||||
|
||||
# Add to playlist
|
||||
m3u_playlist += (
|
||||
f'#EXTINF:0 {" ".join(tags)},{full_title}\n'
|
||||
)
|
||||
m3u_playlist += f"{ep_stream_url}\n"
|
||||
|
||||
# Continue to next stream as we've added all episodes
|
||||
continue
|
||||
else:
|
||||
# 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"
|
||||
|
||||
# Apply stream proxying if enabled (for non-series, or series fallback)
|
||||
if not no_stream_proxy:
|
||||
stream_url = f"{proxy_url}/stream-proxy/{encode_url(stream_url)}"
|
||||
|
||||
# Add stream to playlist
|
||||
m3u_playlist += (
|
||||
f'#EXTINF:0 {" ".join(tags)},{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)}")
|
||||
logger.info(f"✅ M3U generation complete! Generated playlist with {len(included_groups)} groups")
|
||||
|
||||
return m3u_playlist
|
||||
281
app/services/xtream_api.py
Normal file
281
app/services/xtream_api.py
Normal file
@@ -0,0 +1,281 @@
|
||||
"""Xtream Codes API client service"""
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import urllib.parse
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
import requests
|
||||
from fake_useragent import UserAgent
|
||||
from flask import request
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def fetch_api_data(url, timeout=10):
|
||||
"""Make a request to an API endpoint"""
|
||||
ua = UserAgent()
|
||||
headers = {
|
||||
"User-Agent": ua.chrome,
|
||||
"Accept": "application/json,text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
|
||||
"Accept-Language": "en-US,en;q=0.5",
|
||||
"Connection": "close",
|
||||
"Accept-Encoding": "gzip, deflate",
|
||||
}
|
||||
|
||||
try:
|
||||
hostname = urllib.parse.urlparse(url).netloc.split(":")[0]
|
||||
logger.debug(f"Making request to host: {hostname}")
|
||||
|
||||
# Use fresh connection for each request to avoid stale connection issues
|
||||
response = requests.get(url, headers=headers, timeout=timeout, stream=True)
|
||||
response.raise_for_status()
|
||||
|
||||
# For large responses, use streaming JSON parsing
|
||||
try:
|
||||
# Check content length to decide parsing strategy
|
||||
content_length = response.headers.get('Content-Length')
|
||||
if content_length and int(content_length) > 10_000_000: # > 10MB
|
||||
logger.info(f"Large response detected ({content_length} bytes), using optimized parsing")
|
||||
|
||||
# Stream the JSON content for better memory efficiency
|
||||
response.encoding = 'utf-8' # Ensure proper encoding
|
||||
return response.json()
|
||||
except json.JSONDecodeError:
|
||||
# Fallback to text for non-JSON responses
|
||||
return response.text
|
||||
|
||||
except requests.exceptions.SSLError:
|
||||
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
|
||||
|
||||
|
||||
def validate_xtream_credentials(url, username, password):
|
||||
"""Validate the Xtream API credentials"""
|
||||
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,
|
||||
)
|
||||
|
||||
return data, None, None
|
||||
|
||||
|
||||
def fetch_api_endpoint(url_info):
|
||||
"""Fetch a single API endpoint - used for concurrent requests"""
|
||||
url, name, timeout = url_info
|
||||
try:
|
||||
logger.info(f"🚀 Fetching {name}...")
|
||||
start_time = time.time()
|
||||
data = fetch_api_data(url, timeout=timeout)
|
||||
end_time = time.time()
|
||||
|
||||
if isinstance(data, list):
|
||||
logger.info(f"✅ Completed {name} in {end_time-start_time:.1f}s - got {len(data)} items")
|
||||
else:
|
||||
logger.info(f"✅ Completed {name} in {end_time-start_time:.1f}s")
|
||||
return name, data
|
||||
except Exception as e:
|
||||
logger.warning(f"❌ Failed to fetch {name}: {e}")
|
||||
return name, None
|
||||
|
||||
|
||||
def fetch_series_episodes(url, username, password, series_id):
|
||||
"""Fetch episodes for a specific series"""
|
||||
api_url = f"{url}/player_api.php?username={username}&password={password}&action=get_series_info&series_id={series_id}"
|
||||
start_time = time.time()
|
||||
try:
|
||||
# Use a shorter timeout for individual series as we might fetch many
|
||||
data = fetch_api_data(api_url, timeout=20)
|
||||
|
||||
# Check if we got a valid response with episodes
|
||||
# The API returns 'episodes' as a dict {season_num: [episodes]}
|
||||
if isinstance(data, dict) and "episodes" in data and data["episodes"]:
|
||||
logger.info(f"✅ Fetched episodes for series {series_id} in {time.time() - start_time:.1f}s")
|
||||
return series_id, data["episodes"]
|
||||
else:
|
||||
logger.error(f"No episodes found for series {series_id}")
|
||||
return series_id, None
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch episodes for series {series_id} in {time.time() - start_time:.1f}s: {e}")
|
||||
return series_id, None
|
||||
|
||||
|
||||
def fetch_categories_and_channels(url, username, password, include_vod=False):
|
||||
"""Fetch categories and channels from the Xtream API using concurrent requests"""
|
||||
all_categories = []
|
||||
all_streams = []
|
||||
|
||||
try:
|
||||
# Prepare all API endpoints to fetch concurrently
|
||||
api_endpoints = [
|
||||
(f"{url}/player_api.php?username={username}&password={password}&action=get_live_categories",
|
||||
"live_categories", 60),
|
||||
(f"{url}/player_api.php?username={username}&password={password}&action=get_live_streams",
|
||||
"live_streams", 180),
|
||||
]
|
||||
|
||||
# Add VOD endpoints if requested (WARNING: This will be much slower!)
|
||||
if include_vod:
|
||||
logger.warning("⚠️ Including VOD content - this will take significantly longer!")
|
||||
logger.info("💡 For faster loading, use the API without include_vod=true")
|
||||
|
||||
# Only add the most essential VOD endpoints - skip the massive streams for categories-only requests
|
||||
api_endpoints.extend([
|
||||
(f"{url}/player_api.php?username={username}&password={password}&action=get_vod_categories",
|
||||
"vod_categories", 60),
|
||||
(f"{url}/player_api.php?username={username}&password={password}&action=get_series_categories",
|
||||
"series_categories", 60),
|
||||
])
|
||||
|
||||
# Only fetch the massive stream lists if explicitly needed for M3U generation
|
||||
vod_for_m3u = request.endpoint == 'api.generate_m3u'
|
||||
if vod_for_m3u:
|
||||
logger.warning("🐌 Fetching massive VOD/Series streams for M3U generation...")
|
||||
api_endpoints.extend([
|
||||
(f"{url}/player_api.php?username={username}&password={password}&action=get_vod_streams",
|
||||
"vod_streams", 240),
|
||||
(f"{url}/player_api.php?username={username}&password={password}&action=get_series",
|
||||
"series", 240),
|
||||
])
|
||||
else:
|
||||
logger.info("⚡ Skipping massive VOD streams for categories-only request")
|
||||
|
||||
# Fetch all endpoints concurrently using ThreadPoolExecutor
|
||||
logger.info(f"Starting concurrent fetch of {len(api_endpoints)} API endpoints...")
|
||||
results = {}
|
||||
|
||||
with ThreadPoolExecutor(max_workers=10) as executor: # Increased workers for better concurrency
|
||||
# Submit all API calls
|
||||
future_to_name = {executor.submit(fetch_api_endpoint, endpoint): endpoint[1]
|
||||
for endpoint in api_endpoints}
|
||||
|
||||
# Collect results as they complete
|
||||
for future in as_completed(future_to_name):
|
||||
name, data = future.result()
|
||||
results[name] = data
|
||||
|
||||
logger.info("All concurrent API calls completed!")
|
||||
|
||||
# Process live categories and streams (required)
|
||||
live_categories = results.get("live_categories")
|
||||
live_streams = results.get("live_streams")
|
||||
|
||||
if isinstance(live_categories, tuple): # Error response
|
||||
return None, None, live_categories[0], live_categories[1]
|
||||
if isinstance(live_streams, tuple): # Error response
|
||||
return None, None, live_streams[0], live_streams[1]
|
||||
|
||||
if not isinstance(live_categories, list) or not isinstance(live_streams, list):
|
||||
return (
|
||||
None,
|
||||
None,
|
||||
json.dumps(
|
||||
{
|
||||
"error": "Invalid Data Format",
|
||||
"details": "Live categories or streams data is not in the expected format",
|
||||
}
|
||||
),
|
||||
500,
|
||||
)
|
||||
|
||||
# Optimized data processing - batch operations for massive datasets
|
||||
logger.info("Processing live content...")
|
||||
|
||||
# Batch set content_type for live content
|
||||
if live_categories:
|
||||
for category in live_categories:
|
||||
category["content_type"] = "live"
|
||||
all_categories.extend(live_categories)
|
||||
|
||||
if live_streams:
|
||||
for stream in live_streams:
|
||||
stream["content_type"] = "live"
|
||||
all_streams.extend(live_streams)
|
||||
|
||||
logger.info(f"✅ Added {len(live_categories)} live categories and {len(live_streams)} live streams")
|
||||
|
||||
# Process VOD content if requested and available
|
||||
if include_vod:
|
||||
logger.info("Processing VOD content...")
|
||||
|
||||
# Process VOD categories
|
||||
vod_categories = results.get("vod_categories")
|
||||
if isinstance(vod_categories, list) and vod_categories:
|
||||
for category in vod_categories:
|
||||
category["content_type"] = "vod"
|
||||
all_categories.extend(vod_categories)
|
||||
logger.info(f"✅ Added {len(vod_categories)} VOD categories")
|
||||
|
||||
# Process series categories first (lightweight)
|
||||
series_categories = results.get("series_categories")
|
||||
if isinstance(series_categories, list) and series_categories:
|
||||
for category in series_categories:
|
||||
category["content_type"] = "series"
|
||||
all_categories.extend(series_categories)
|
||||
logger.info(f"✅ Added {len(series_categories)} series categories")
|
||||
|
||||
# Only process massive stream lists if they were actually fetched
|
||||
vod_streams = results.get("vod_streams")
|
||||
if isinstance(vod_streams, list) and vod_streams:
|
||||
logger.info(f"🔥 Processing {len(vod_streams)} VOD streams (this is the slow part)...")
|
||||
|
||||
# Batch process for better performance
|
||||
batch_size = 5000
|
||||
for i in range(0, len(vod_streams), batch_size):
|
||||
batch = vod_streams[i:i + batch_size]
|
||||
for stream in batch:
|
||||
stream["content_type"] = "vod"
|
||||
if i + batch_size < len(vod_streams):
|
||||
logger.info(f" Processed {i + batch_size}/{len(vod_streams)} VOD streams...")
|
||||
|
||||
all_streams.extend(vod_streams)
|
||||
logger.info(f"✅ Added {len(vod_streams)} VOD streams")
|
||||
|
||||
# Process series (this can also be huge!)
|
||||
series = results.get("series")
|
||||
if isinstance(series, list) and series:
|
||||
logger.info(f"🔥 Processing {len(series)} series (this is also slow)...")
|
||||
|
||||
# Batch process for better performance
|
||||
batch_size = 5000
|
||||
for i in range(0, len(series), batch_size):
|
||||
batch = series[i:i + batch_size]
|
||||
for show in batch:
|
||||
show["content_type"] = "series"
|
||||
if i + batch_size < len(series):
|
||||
logger.info(f" Processed {i + batch_size}/{len(series)} series...")
|
||||
|
||||
all_streams.extend(series)
|
||||
logger.info(f"✅ Added {len(series)} series")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Critical error fetching API data: {e}")
|
||||
return (
|
||||
None,
|
||||
None,
|
||||
json.dumps(
|
||||
{
|
||||
"error": "API Fetch Error",
|
||||
"details": f"Failed to fetch data from IPTV service: {str(e)}",
|
||||
}
|
||||
),
|
||||
500,
|
||||
)
|
||||
|
||||
logger.info(f"🚀 CONCURRENT FETCH COMPLETE: {len(all_categories)} total categories and {len(all_streams)} total streams")
|
||||
return all_categories, all_streams, None, None
|
||||
12
app/utils/__init__.py
Normal file
12
app/utils/__init__.py
Normal file
@@ -0,0 +1,12 @@
|
||||
"""Utility functions package"""
|
||||
from .helpers import encode_url, group_matches, parse_group_list, setup_custom_dns
|
||||
from .streaming import generate_streaming_response, stream_request
|
||||
|
||||
__all__ = [
|
||||
'setup_custom_dns',
|
||||
'encode_url',
|
||||
'parse_group_list',
|
||||
'group_matches',
|
||||
'stream_request',
|
||||
'generate_streaming_response'
|
||||
]
|
||||
93
app/utils/helpers.py
Normal file
93
app/utils/helpers.py
Normal file
@@ -0,0 +1,93 @@
|
||||
"""Utility functions for URL encoding, filtering, and DNS setup"""
|
||||
import fnmatch
|
||||
import ipaddress
|
||||
import logging
|
||||
import socket
|
||||
import urllib.parse
|
||||
|
||||
import dns.resolver
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
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"]
|
||||
|
||||
custom_resolver = dns.resolver.Resolver()
|
||||
custom_resolver.nameservers = dns_servers
|
||||
|
||||
original_getaddrinfo = socket.getaddrinfo
|
||||
|
||||
def new_getaddrinfo(host, port, family=0, type=0, proto=0, flags=0):
|
||||
if host:
|
||||
try:
|
||||
# Skip DNS resolution for IP addresses
|
||||
try:
|
||||
ipaddress.ip_address(host)
|
||||
# If we get here, the host is already an IP address
|
||||
logger.debug(f"Host is already an IP address: {host}, skipping DNS resolution")
|
||||
except ValueError:
|
||||
# Not an IP address, so try system DNS first
|
||||
try:
|
||||
result = original_getaddrinfo(host, port, family, type, proto, flags)
|
||||
logger.debug(f"System DNS resolved {host}")
|
||||
return result
|
||||
except Exception as system_error:
|
||||
logger.info(f"System DNS resolution failed for {host}: {system_error}, falling back to custom DNS")
|
||||
# Fall back to custom DNS
|
||||
answers = custom_resolver.resolve(host)
|
||||
host = str(answers[0])
|
||||
logger.debug(f"Custom DNS resolved {host}")
|
||||
except Exception as e:
|
||||
logger.info(f"Custom DNS resolution also failed for {host}: {e}, using original getaddrinfo")
|
||||
return original_getaddrinfo(host, port, family, type, proto, flags)
|
||||
|
||||
socket.getaddrinfo = new_getaddrinfo
|
||||
logger.info("Custom DNS resolver set up")
|
||||
|
||||
|
||||
def encode_url(url):
|
||||
"""Safely encode a URL for use in proxy endpoints"""
|
||||
return urllib.parse.quote(url, safe="") if url else ""
|
||||
|
||||
|
||||
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 []
|
||||
|
||||
|
||||
def group_matches(group_title, pattern):
|
||||
"""Check if a group title matches a pattern, supporting wildcards and exact matching"""
|
||||
# Convert to lowercase for case-insensitive matching
|
||||
group_lower = group_title.lower()
|
||||
pattern_lower = pattern.lower()
|
||||
|
||||
# Handle spaces in pattern
|
||||
if " " in pattern_lower:
|
||||
# For patterns with spaces, split and check each part
|
||||
pattern_parts = pattern_lower.split()
|
||||
group_parts = group_lower.split()
|
||||
|
||||
# If pattern has more parts than group, can't match
|
||||
if len(pattern_parts) > len(group_parts):
|
||||
return False
|
||||
|
||||
# Check each part of the pattern against group parts
|
||||
for i, part in enumerate(pattern_parts):
|
||||
if i >= len(group_parts):
|
||||
return False
|
||||
if "*" in part or "?" in part:
|
||||
if not fnmatch.fnmatch(group_parts[i], part):
|
||||
return False
|
||||
else:
|
||||
if part not in group_parts[i]:
|
||||
return False
|
||||
return True
|
||||
|
||||
# Check for wildcard patterns
|
||||
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
|
||||
65
app/utils/streaming.py
Normal file
65
app/utils/streaming.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""Streaming and proxy utilities"""
|
||||
import logging
|
||||
|
||||
import requests
|
||||
from flask import Response
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def stream_request(url, headers=None, timeout=30):
|
||||
"""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",
|
||||
"Connection": "keep-alive",
|
||||
}
|
||||
|
||||
# Use longer timeout for streams and set both connect and read timeouts
|
||||
return requests.get(url, stream=True, headers=headers, timeout=(10, timeout))
|
||||
|
||||
|
||||
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")
|
||||
|
||||
def generate():
|
||||
try:
|
||||
bytes_sent = 0
|
||||
for chunk in response.iter_content(chunk_size=8192):
|
||||
if chunk:
|
||||
bytes_sent += len(chunk)
|
||||
yield chunk
|
||||
logger.info(f"Stream completed, sent {bytes_sent} bytes")
|
||||
except requests.exceptions.ChunkedEncodingError as e:
|
||||
# Chunked encoding error from upstream - log and stop gracefully
|
||||
logger.warning(f"Upstream chunked encoding error after {bytes_sent} bytes: {str(e)}")
|
||||
# Don't raise - just stop yielding to close stream gracefully
|
||||
except requests.exceptions.ConnectionError as e:
|
||||
# Connection error (reset, timeout, etc.) - log and stop gracefully
|
||||
logger.warning(f"Connection error after {bytes_sent} bytes: {str(e)}")
|
||||
# Don't raise - just stop yielding to close stream gracefully
|
||||
except Exception as e:
|
||||
logger.error(f"Streaming error after {bytes_sent} bytes: {str(e)}")
|
||||
# Don't raise exceptions in generators after headers are sent!
|
||||
# Raising here causes Flask to inject "HTTP/1.1 500" into the chunked body,
|
||||
finally:
|
||||
# Always close the upstream response to free resources
|
||||
try:
|
||||
response.close()
|
||||
except:
|
||||
pass
|
||||
|
||||
headers = {
|
||||
"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"]
|
||||
else:
|
||||
headers["Transfer-Encoding"] = "chunked"
|
||||
|
||||
return Response(generate(), mimetype=content_type, headers=headers, direct_passthrough=True)
|
||||
@@ -4,168 +4,163 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>xtream2m3u - M3U Playlist Generator</title>
|
||||
<title>xtream2m3u - Playlist Generator</title>
|
||||
<meta name="description" content="Convert Xtream IPTV APIs into customizable M3U playlists">
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<link rel="icon" type="image/png" href="/assets/logo.png">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<!-- Header -->
|
||||
<header class="header">
|
||||
<div class="logo">
|
||||
<img src="/assets/logo.png" alt="xtream2m3u Logo">
|
||||
</div>
|
||||
<h1>xtream2m3u</h1>
|
||||
<p class="subtitle">Convert Xtream IPTV APIs into customizable M3U playlists</p>
|
||||
</div>
|
||||
<p class="subtitle">Generate custom M3U playlists from your Xtream IPTV subscription.</p>
|
||||
</header>
|
||||
|
||||
<!-- Step 1: Credentials -->
|
||||
<div class="step active" id="step1">
|
||||
<section class="step active" id="step1">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
🔐 Xtream API Credentials
|
||||
<span>🔐 Service Credentials</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<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.
|
||||
<form id="credentialsForm" onsubmit="event.preventDefault(); loadCategories();">
|
||||
<div class="form-group">
|
||||
<label for="url">Service URL (DNS)</label>
|
||||
<input type="url" id="url" placeholder="http://iptv.provider.com:8080" required autocomplete="url">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="url">IPTV Service URL (DNS)</label>
|
||||
<input type="url" id="url" placeholder="http://your-iptv-service.com" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="username">Username</label>
|
||||
<input type="text" id="username" placeholder="your_username" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<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 class="form-group">
|
||||
<label for="username">Username</label>
|
||||
<input type="text" id="username" placeholder="Enter your username" required autocomplete="username">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary" onclick="loadCategories()">
|
||||
<span id="loadCategoriesText">Continue to Category Selection</span>
|
||||
</button>
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" placeholder="Enter your password" required autocomplete="current-password">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="checkbox-wrapper">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="includeVod">
|
||||
<div class="checkmark"></div>
|
||||
<div class="checkbox-text">
|
||||
<strong>Include VOD Content</strong>
|
||||
<small>Movies & Series (May increase loading time)</small>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary" id="loadBtn">
|
||||
<span id="loadCategoriesText">Connect & Load Categories</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div class="loading" id="loading">
|
||||
<div class="spinner"></div>
|
||||
<p>Loading categories...</p>
|
||||
<p id="loadingText">Connecting to service...</p>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Category Selection -->
|
||||
<div class="step" id="step2">
|
||||
<section class="step" id="step2">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
📁 Select Categories
|
||||
<span>📁 Customize Playlist</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="filter-mode">
|
||||
<label>
|
||||
<input type="radio" name="filterMode" value="include" checked>
|
||||
<span>Include only selected</span>
|
||||
</label>
|
||||
<label>
|
||||
<input type="radio" name="filterMode" value="exclude">
|
||||
<span>Exclude selected</span>
|
||||
</label>
|
||||
<div class="toolbar">
|
||||
<div class="filter-mode">
|
||||
<label>
|
||||
<input type="radio" name="filterMode" value="include" checked>
|
||||
<span>Include Selected</span>
|
||||
</label>
|
||||
<label>
|
||||
<input type="radio" name="filterMode" value="exclude">
|
||||
<span>Exclude Selected</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="search-box">
|
||||
<input type="text" id="categorySearch" placeholder="Search categories..." autocomplete="off">
|
||||
<span class="search-icon">🔍</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="selection-counter" id="selectionCounter">
|
||||
<span id="selectionText">Click categories to select them (or leave empty to include all)</span>
|
||||
<span id="selectionText">Select categories to include in your playlist</span>
|
||||
<div class="selection-actions">
|
||||
<button class="btn-text" onclick="selectAllVisible()">Select Visible</button>
|
||||
<button class="btn-text" onclick="clearSelection()">Clear All</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="category-chips" id="categoryChips">
|
||||
<!-- Categories will be populated here -->
|
||||
<!-- Categories populated via JS -->
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 1rem; justify-content: center; margin-top: 2rem; flex-wrap: wrap;">
|
||||
<button class="btn btn-success" onclick="showConfirmation()">
|
||||
⚡ Generate Playlist
|
||||
</button>
|
||||
<button class="btn btn-secondary" onclick="clearSelection()">
|
||||
🗑️ Clear Selection
|
||||
</button>
|
||||
<button class="btn btn-secondary" onclick="goBackToStep1()">
|
||||
← Back to Credentials
|
||||
Back
|
||||
</button>
|
||||
<button class="btn btn-success" onclick="showConfirmation()">
|
||||
Generate Playlist
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Step 3: Success State -->
|
||||
<div class="step" id="step3">
|
||||
<!-- Step 3: Success -->
|
||||
<section class="step" id="step3">
|
||||
<div class="card">
|
||||
<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>
|
||||
<h2 class="success-title">Playlist Ready!</h2>
|
||||
<p class="success-message">Your custom M3U playlist has been generated successfully.</p>
|
||||
|
||||
<div class="success-actions">
|
||||
<a class="btn btn-success download-link" id="finalDownloadLink" style="display: none;">
|
||||
📥 Download M3U Playlist
|
||||
<a class="btn btn-success download-link" id="finalDownloadLink">
|
||||
<span>📥 Download .m3u</span>
|
||||
</a>
|
||||
<button class="btn btn-primary" onclick="startOver()">
|
||||
🔄 Start Over
|
||||
</button>
|
||||
<button class="btn btn-secondary" onclick="useOtherCredentials()">
|
||||
👤 Use Other Credentials
|
||||
Create Another
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Confirmation Modal -->
|
||||
<div class="modal" id="confirmationModal">
|
||||
<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>
|
||||
<h3>Review & Generate</h3>
|
||||
</div>
|
||||
|
||||
<div class="modal-summary" id="modalSummary">
|
||||
<!-- Summary will be populated here -->
|
||||
<!-- Summary populated via JS -->
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-success" onclick="confirmGeneration()">
|
||||
✓ Generate Playlist
|
||||
</button>
|
||||
<button class="btn btn-secondary" onclick="closeModal()">
|
||||
Cancel
|
||||
</button>
|
||||
<button class="btn btn-secondary" onclick="closeModal()">Cancel</button>
|
||||
<button class="btn btn-success" onclick="confirmGeneration()">Generate</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="results" id="results"></div>
|
||||
<div id="results"></div>
|
||||
</div>
|
||||
|
||||
<script src="script.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -1,486 +1,483 @@
|
||||
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 includeVod = document.getElementById("includeVod").checked;
|
||||
|
||||
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");
|
||||
// State Management
|
||||
let state = {
|
||||
categories: [],
|
||||
currentStep: 1,
|
||||
filterMode: 'include',
|
||||
selectedCategories: new Set(),
|
||||
collapsedSections: new Set(),
|
||||
searchTerm: '',
|
||||
credentials: {
|
||||
url: '',
|
||||
username: '',
|
||||
password: '',
|
||||
includeVod: false
|
||||
}
|
||||
};
|
||||
|
||||
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";
|
||||
}
|
||||
}
|
||||
|
||||
function displayCategoryChips(categories) {
|
||||
const categoryChips = document.getElementById("categoryChips");
|
||||
categoryChips.innerHTML = "";
|
||||
|
||||
// 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>`;
|
||||
sectionContainer.appendChild(chip);
|
||||
});
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
function updateSelectionCounter() {
|
||||
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";
|
||||
|
||||
// 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 with method info
|
||||
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(", ")})` : "";
|
||||
const methodInfo = selectedCount > 10 ? " • Using POST method for large request" : "";
|
||||
const timeEstimate = selectedCount > 20 ? " • Est. 2-4 min" : selectedCount > 10 ? " • Est. 1-2 min" : "";
|
||||
|
||||
text.textContent = `${selectedCount} categories will be ${action}${breakdown}${methodInfo}${timeEstimate}`;
|
||||
counter.classList.add("has-selection");
|
||||
}
|
||||
}
|
||||
|
||||
function showConfirmation() {
|
||||
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();
|
||||
|
||||
let categoryText;
|
||||
if (selectedCategories.length === 0) {
|
||||
categoryText = `All ${categories.length} categories`;
|
||||
} else {
|
||||
const action = filterMode === "include" ? "Include" : "Exclude";
|
||||
categoryText = `${action} ${selectedCategories.length} selected categories`;
|
||||
}
|
||||
|
||||
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>
|
||||
</div>
|
||||
<div class="summary-row">
|
||||
<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>
|
||||
</div>
|
||||
<div class="summary-row">
|
||||
<span class="summary-label">Total Categories:</span>
|
||||
<span class="summary-value">${categories.length}</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
modal.classList.add("active");
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
document.getElementById("confirmationModal").classList.remove("active");
|
||||
}
|
||||
|
||||
async function confirmGeneration() {
|
||||
closeModal();
|
||||
|
||||
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...";
|
||||
|
||||
try {
|
||||
// Build request data
|
||||
const requestData = {
|
||||
url: url,
|
||||
username: username,
|
||||
password: password,
|
||||
nostreamproxy: "true",
|
||||
};
|
||||
|
||||
if (includeVod) {
|
||||
requestData.include_vod = "true";
|
||||
}
|
||||
|
||||
if (selectedCategories.length > 0) {
|
||||
if (filterMode === "include") {
|
||||
requestData.wanted_groups = selectedCategories.join(",");
|
||||
} else {
|
||||
requestData.unwanted_groups = selectedCategories.join(",");
|
||||
}
|
||||
}
|
||||
|
||||
// Use POST for large filter lists to avoid URL length limits
|
||||
const shouldUsePost = selectedCategories.length > 10 ||
|
||||
JSON.stringify(requestData).length > 2000;
|
||||
|
||||
console.log(`Using ${shouldUsePost ? 'POST' : 'GET'} method for ${selectedCategories.length} categories`);
|
||||
|
||||
let response;
|
||||
if (shouldUsePost) {
|
||||
// Show better progress message for large requests
|
||||
document.querySelector("#loading p").textContent =
|
||||
`Processing ${selectedCategories.length} categories - this may take 2-4 minutes...`;
|
||||
|
||||
response = await fetch("/m3u", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(requestData)
|
||||
});
|
||||
} else {
|
||||
// Use GET for small requests
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(requestData)) {
|
||||
params.append(key, value);
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
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");
|
||||
});
|
||||
}
|
||||
// DOM Elements
|
||||
const elements = {
|
||||
steps: {
|
||||
1: document.getElementById('step1'),
|
||||
2: document.getElementById('step2'),
|
||||
3: document.getElementById('step3')
|
||||
},
|
||||
loading: document.getElementById('loading'),
|
||||
loadingText: document.getElementById('loadingText'),
|
||||
categoryChips: document.getElementById('categoryChips'),
|
||||
selectionCounter: document.getElementById('selectionCounter'),
|
||||
selectionText: document.getElementById('selectionText'),
|
||||
confirmationModal: document.getElementById('confirmationModal'),
|
||||
modalSummary: document.getElementById('modalSummary'),
|
||||
results: document.getElementById('results'),
|
||||
downloadLink: document.getElementById('finalDownloadLink'),
|
||||
searchInput: document.getElementById('categorySearch')
|
||||
};
|
||||
|
||||
// Step Navigation
|
||||
function showStep(stepNumber) {
|
||||
hideAllSteps();
|
||||
document.getElementById(`step${stepNumber}`).classList.add("active");
|
||||
currentStep = stepNumber;
|
||||
// Hide all steps
|
||||
Object.values(elements.steps).forEach(step => step.classList.remove('active'));
|
||||
// Show target step
|
||||
elements.steps[stepNumber].classList.add('active');
|
||||
state.currentStep = stepNumber;
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
|
||||
function goBackToStep1() {
|
||||
showStep(1);
|
||||
showStep(1);
|
||||
}
|
||||
|
||||
function startOver() {
|
||||
// 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 = "";
|
||||
|
||||
// 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);
|
||||
function showLoading(message = 'Loading...') {
|
||||
// Hide all steps
|
||||
Object.values(elements.steps).forEach(step => step.classList.remove('active'));
|
||||
elements.loading.style.display = 'block';
|
||||
elements.loadingText.textContent = message;
|
||||
}
|
||||
|
||||
function useOtherCredentials() {
|
||||
// Keep categories but clear credentials
|
||||
document.getElementById("url").value = "";
|
||||
document.getElementById("username").value = "";
|
||||
document.getElementById("password").value = "";
|
||||
|
||||
clearResults();
|
||||
showStep(1);
|
||||
function hideLoading() {
|
||||
elements.loading.style.display = 'none';
|
||||
}
|
||||
|
||||
function showError(message) {
|
||||
const resultsDiv = document.getElementById("results");
|
||||
resultsDiv.innerHTML = `<div class="alert alert-error">⚠️ ${message}</div>`;
|
||||
elements.results.innerHTML = `
|
||||
<div class="alert alert-error">
|
||||
<span>⚠️</span> ${message}
|
||||
</div>
|
||||
`;
|
||||
setTimeout(() => {
|
||||
elements.results.innerHTML = '';
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function showSuccess(message) {
|
||||
const resultsDiv = document.getElementById("results");
|
||||
resultsDiv.innerHTML = `<div class="alert alert-success">✅ ${message}</div>`;
|
||||
// Data Fetching
|
||||
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 includeVod = document.getElementById('includeVod').checked;
|
||||
|
||||
if (!url || !username || !password) {
|
||||
showError('Please fill in all required fields');
|
||||
return;
|
||||
}
|
||||
|
||||
// Update state
|
||||
state.credentials = { url, username, password, includeVod };
|
||||
|
||||
showLoading('Connecting to IPTV service...');
|
||||
document.getElementById('loadBtn').disabled = true;
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
url, username, password,
|
||||
include_vod: includeVod
|
||||
});
|
||||
|
||||
const response = await fetch(`/categories?${params}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.details || data.error || 'Failed to fetch categories');
|
||||
}
|
||||
|
||||
state.categories = data;
|
||||
state.searchTerm = '';
|
||||
elements.searchInput.value = '';
|
||||
renderCategories();
|
||||
showStep(2);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
showError(error.message);
|
||||
showStep(1);
|
||||
} finally {
|
||||
hideLoading();
|
||||
document.getElementById('loadBtn').disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function clearResults() {
|
||||
document.getElementById("results").innerHTML = "";
|
||||
}
|
||||
// Category Rendering
|
||||
function renderCategories() {
|
||||
elements.categoryChips.innerHTML = '';
|
||||
// Preserve selection if just re-rendering, but currently we usually re-fetch on Step 1 -> 2.
|
||||
// If we want to support search without re-rendering everything, we can just hide elements.
|
||||
// But initially, we render all.
|
||||
|
||||
// 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();
|
||||
// Group categories
|
||||
const groups = {
|
||||
live: [],
|
||||
vod: [],
|
||||
series: []
|
||||
};
|
||||
|
||||
state.categories.forEach(cat => {
|
||||
const type = cat.content_type || 'live';
|
||||
if (groups[type]) groups[type].push(cat);
|
||||
});
|
||||
});
|
||||
|
||||
const sectionConfig = [
|
||||
{ key: 'live', title: '📺 Live Channels' },
|
||||
{ key: 'vod', title: '🎬 Movies' },
|
||||
{ key: 'series', title: '🍿 TV Series' }
|
||||
];
|
||||
|
||||
sectionConfig.forEach(section => {
|
||||
const cats = groups[section.key];
|
||||
if (cats && cats.length > 0) {
|
||||
// Wrapper
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'category-group-wrapper';
|
||||
wrapper.dataset.section = section.key;
|
||||
|
||||
// Header
|
||||
const header = document.createElement('div');
|
||||
header.className = 'category-section-header';
|
||||
if (state.collapsedSections.has(section.key)) {
|
||||
header.classList.add('collapsed');
|
||||
}
|
||||
header.dataset.section = section.key;
|
||||
|
||||
// Header content
|
||||
header.innerHTML = `
|
||||
<h3>
|
||||
<span class="chevron">▼</span>
|
||||
${section.title}
|
||||
<span style="font-size:0.8em; opacity:0.7">(${cats.length})</span>
|
||||
</h3>
|
||||
<button class="btn-section-select-all" data-section="${section.key}">Select All</button>
|
||||
`;
|
||||
|
||||
// Click handler for collapsing
|
||||
header.onclick = (e) => {
|
||||
// Prevent collapsing when clicking the select all button
|
||||
if (e.target.classList.contains('btn-section-select-all')) return;
|
||||
toggleSection(section.key, header);
|
||||
};
|
||||
|
||||
wrapper.appendChild(header);
|
||||
|
||||
// Grid
|
||||
const grid = document.createElement('div');
|
||||
grid.className = 'category-section';
|
||||
grid.dataset.section = section.key;
|
||||
if (state.collapsedSections.has(section.key)) {
|
||||
grid.classList.add('hidden');
|
||||
}
|
||||
|
||||
cats.forEach(cat => {
|
||||
const chip = document.createElement('div');
|
||||
chip.className = 'category-chip';
|
||||
if (state.selectedCategories.has(cat.category_name)) {
|
||||
chip.classList.add('selected');
|
||||
}
|
||||
chip.dataset.id = cat.category_id;
|
||||
chip.dataset.name = cat.category_name;
|
||||
chip.dataset.type = section.key;
|
||||
chip.title = cat.category_name;
|
||||
chip.textContent = cat.category_name;
|
||||
|
||||
chip.onclick = () => toggleCategory(chip);
|
||||
grid.appendChild(chip);
|
||||
});
|
||||
|
||||
wrapper.appendChild(grid);
|
||||
elements.categoryChips.appendChild(wrapper);
|
||||
}
|
||||
});
|
||||
|
||||
setupSectionToggles();
|
||||
updateCounter();
|
||||
}
|
||||
|
||||
// Initialize input trimming when page loads
|
||||
document.addEventListener("DOMContentLoaded", setupInputTrimming);
|
||||
|
||||
// Update filter mode selection counter
|
||||
document.addEventListener("change", function (e) {
|
||||
if (e.target.name === "filterMode") {
|
||||
updateSelectionCounter();
|
||||
}
|
||||
});
|
||||
|
||||
// Modal click outside to close
|
||||
document
|
||||
.getElementById("confirmationModal")
|
||||
.addEventListener("click", function (e) {
|
||||
if (e.target === this) {
|
||||
closeModal();
|
||||
function toggleCategory(chip) {
|
||||
const name = chip.dataset.name;
|
||||
if (state.selectedCategories.has(name)) {
|
||||
state.selectedCategories.delete(name);
|
||||
chip.classList.remove('selected');
|
||||
} else {
|
||||
state.selectedCategories.add(name);
|
||||
chip.classList.add('selected');
|
||||
}
|
||||
});
|
||||
updateCounter();
|
||||
}
|
||||
|
||||
// Keyboard shortcuts
|
||||
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();
|
||||
function toggleSection(sectionKey, headerElement) {
|
||||
const grid = document.querySelector(`.category-section[data-section="${sectionKey}"]`);
|
||||
if (grid) {
|
||||
if (grid.classList.contains('hidden')) {
|
||||
grid.classList.remove('hidden');
|
||||
headerElement.classList.remove('collapsed');
|
||||
state.collapsedSections.delete(sectionKey);
|
||||
} else {
|
||||
grid.classList.add('hidden');
|
||||
headerElement.classList.add('collapsed');
|
||||
state.collapsedSections.add(sectionKey);
|
||||
}
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
function setupSectionToggles() {
|
||||
document.querySelectorAll('.btn-section-select-all').forEach(btn => {
|
||||
btn.onclick = (e) => {
|
||||
e.stopPropagation(); // Prevent header collapse
|
||||
const section = e.target.dataset.section;
|
||||
// Get visible chips only if we want to respect search?
|
||||
// Usually "Select All" in a section implies all in that section,
|
||||
// but if search is active, maybe only visible ones.
|
||||
// Let's make it select all visible ones in that section.
|
||||
|
||||
const chips = document.querySelectorAll(`.category-chip[data-type="${section}"]:not(.hidden)`);
|
||||
if (chips.length === 0) return;
|
||||
|
||||
const allSelected = Array.from(chips).every(c => state.selectedCategories.has(c.dataset.name));
|
||||
|
||||
chips.forEach(chip => {
|
||||
const name = chip.dataset.name;
|
||||
if (allSelected) {
|
||||
state.selectedCategories.delete(name);
|
||||
chip.classList.remove('selected');
|
||||
} else {
|
||||
state.selectedCategories.add(name);
|
||||
chip.classList.add('selected');
|
||||
}
|
||||
});
|
||||
updateCounter();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
state.selectedCategories.clear();
|
||||
document.querySelectorAll('.category-chip').forEach(c => c.classList.remove('selected'));
|
||||
updateCounter();
|
||||
}
|
||||
|
||||
function selectAllVisible() {
|
||||
const chips = document.querySelectorAll('.category-chip:not(.hidden)');
|
||||
chips.forEach(chip => {
|
||||
state.selectedCategories.add(chip.dataset.name);
|
||||
chip.classList.add('selected');
|
||||
});
|
||||
updateCounter();
|
||||
}
|
||||
|
||||
function updateCounter() {
|
||||
const count = state.selectedCategories.size;
|
||||
const mode = document.querySelector('input[name="filterMode"]:checked').value;
|
||||
state.filterMode = mode;
|
||||
|
||||
if (count === 0) {
|
||||
elements.selectionText.textContent = 'Select categories to include in your playlist';
|
||||
elements.selectionCounter.classList.remove('has-selection');
|
||||
} else {
|
||||
const action = mode === 'include' ? 'included' : 'excluded';
|
||||
elements.selectionText.innerHTML = `<strong>${count}</strong> categories will be ${action}`;
|
||||
elements.selectionCounter.classList.add('has-selection');
|
||||
}
|
||||
}
|
||||
|
||||
function filterCategories(searchTerm) {
|
||||
state.searchTerm = searchTerm.toLowerCase();
|
||||
const chips = document.querySelectorAll('.category-chip');
|
||||
|
||||
chips.forEach(chip => {
|
||||
const name = chip.dataset.name.toLowerCase();
|
||||
if (name.includes(state.searchTerm)) {
|
||||
chip.classList.remove('hidden');
|
||||
} else {
|
||||
chip.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
// Also hide empty sections?
|
||||
document.querySelectorAll('.category-group-wrapper').forEach(wrapper => {
|
||||
const sectionKey = wrapper.dataset.section;
|
||||
const visibleChips = wrapper.querySelectorAll('.category-chip:not(.hidden)');
|
||||
|
||||
if (visibleChips.length === 0) {
|
||||
wrapper.style.display = 'none';
|
||||
} else {
|
||||
wrapper.style.display = 'block';
|
||||
|
||||
// Restore grid display if not collapsed
|
||||
const grid = wrapper.querySelector('.category-section');
|
||||
if (grid && !state.collapsedSections.has(sectionKey)) {
|
||||
// Grid should be visible (css handles grid display usually, but let's ensure)
|
||||
// The grid class .hidden handles it. If it doesn't have .hidden, it shows.
|
||||
// But wait, if we previously set style.display = 'none' on the grid directly...
|
||||
}
|
||||
});
|
||||
updateSelectionCounter();
|
||||
}
|
||||
break;
|
||||
});
|
||||
}
|
||||
|
||||
// Confirmation & Generation
|
||||
function showConfirmation() {
|
||||
const count = state.selectedCategories.size;
|
||||
elements.confirmationModal.classList.add('active');
|
||||
|
||||
// Check filter mode again just in case
|
||||
state.filterMode = document.querySelector('input[name="filterMode"]:checked').value;
|
||||
const action = state.filterMode === 'include' ? 'Include' : 'Exclude';
|
||||
const desc = count === 0 ? 'All Categories' : `${action} ${count} categories`;
|
||||
|
||||
// Check for TV Series selection
|
||||
let seriesWarning = '';
|
||||
const hasSeriesSelected = Array.from(state.selectedCategories).some(name => {
|
||||
// Find category object to check type
|
||||
const cat = state.categories.find(c => c.category_name === name);
|
||||
return cat && cat.content_type === 'series';
|
||||
});
|
||||
|
||||
if (state.credentials.includeVod && (state.filterMode === 'include' && hasSeriesSelected)) {
|
||||
seriesWarning = `
|
||||
<div class="alert alert-warning" style="margin-top: 1rem; font-size: 0.85rem; align-items: flex-start;">
|
||||
<span style="font-size: 1.2rem; line-height: 1;">⚠️</span>
|
||||
<div>
|
||||
<strong>TV Series Selected</strong><br>
|
||||
Fetching episode data is limited by the Xtream API speed.<br>
|
||||
<span style="opacity: 0.9">Processing may take a significant amount of time (minutes to hours) depending on the number of series.</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
elements.modalSummary.innerHTML = `
|
||||
<div class="summary-row">
|
||||
<span class="summary-label">Service URL</span>
|
||||
<span class="summary-value" style="max-width: 200px; overflow: hidden; text-overflow: ellipsis;">${state.credentials.url}</span>
|
||||
</div>
|
||||
<div class="summary-row">
|
||||
<span class="summary-label">Content</span>
|
||||
<span class="summary-value">${state.credentials.includeVod ? 'Live TV + VOD' : 'Live TV Only'}</span>
|
||||
</div>
|
||||
<div class="summary-row">
|
||||
<span class="summary-label">Filter Config</span>
|
||||
<span class="summary-value">${desc}</span>
|
||||
</div>
|
||||
${seriesWarning}
|
||||
`;
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
elements.confirmationModal.classList.remove('active');
|
||||
}
|
||||
|
||||
async function confirmGeneration() {
|
||||
closeModal();
|
||||
showLoading('Generating Playlist...');
|
||||
|
||||
const requestData = {
|
||||
...state.credentials,
|
||||
nostreamproxy: true,
|
||||
include_vod: state.credentials.includeVod
|
||||
};
|
||||
|
||||
// Remove the original camelCase property to avoid confusion/duplication
|
||||
delete requestData.includeVod;
|
||||
|
||||
const categories = Array.from(state.selectedCategories);
|
||||
if (categories.length > 0) {
|
||||
if (state.filterMode === 'include') {
|
||||
requestData.wanted_groups = categories.join(',');
|
||||
} else {
|
||||
requestData.unwanted_groups = categories.join(',');
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Decide method based on payload size
|
||||
const usePost = categories.length > 10 || JSON.stringify(requestData).length > 1500;
|
||||
|
||||
let response;
|
||||
if (usePost) {
|
||||
response = await fetch('/m3u', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(requestData)
|
||||
});
|
||||
} else {
|
||||
const params = new URLSearchParams(requestData);
|
||||
response = await fetch(`/m3u?${params}`);
|
||||
}
|
||||
|
||||
if (!response.ok) throw new Error('Generation failed');
|
||||
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
|
||||
elements.downloadLink.href = url;
|
||||
elements.downloadLink.download = state.credentials.includeVod ? 'Full_Playlist.m3u' : 'Live_Playlist.m3u';
|
||||
|
||||
showStep(3);
|
||||
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
showError('Failed to generate playlist. Please check your inputs and try again.');
|
||||
showStep(2);
|
||||
} finally {
|
||||
hideLoading();
|
||||
}
|
||||
}
|
||||
|
||||
function startOver() {
|
||||
// Reset inputs
|
||||
document.getElementById('url').value = '';
|
||||
document.getElementById('username').value = '';
|
||||
document.getElementById('password').value = '';
|
||||
document.getElementById('includeVod').checked = false;
|
||||
|
||||
// Clear state
|
||||
state.categories = [];
|
||||
state.selectedCategories.clear();
|
||||
state.searchTerm = '';
|
||||
elements.searchInput.value = '';
|
||||
|
||||
showStep(1);
|
||||
}
|
||||
|
||||
// Event Listeners
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Filter mode change
|
||||
document.querySelectorAll('input[name="filterMode"]').forEach(radio => {
|
||||
radio.addEventListener('change', updateCounter);
|
||||
});
|
||||
|
||||
// Search input
|
||||
elements.searchInput.addEventListener('input', (e) => {
|
||||
filterCategories(e.target.value);
|
||||
});
|
||||
|
||||
// Close modal on outside click
|
||||
elements.confirmationModal.addEventListener('click', (e) => {
|
||||
if (e.target === elements.confirmationModal) closeModal();
|
||||
});
|
||||
|
||||
// Input trim handlers
|
||||
document.querySelectorAll('input').forEach(input => {
|
||||
input.addEventListener('blur', (e) => {
|
||||
if(e.target.type !== 'checkbox' && e.target.type !== 'radio') {
|
||||
e.target.value = e.target.value.trim();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
1282
frontend/style.css
1282
frontend/style.css
File diff suppressed because it is too large
Load Diff
820
run.py
820
run.py
@@ -1,806 +1,38 @@
|
||||
import fnmatch
|
||||
import ipaddress
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import time
|
||||
import urllib.parse
|
||||
import argparse
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
"""Xtream2M3U - Xtream Codes API to M3U converter
|
||||
|
||||
import dns.resolver
|
||||
import requests
|
||||
from fake_useragent import UserAgent
|
||||
from flask import Flask, Response, jsonify, request, send_from_directory
|
||||
This is the main entry point for the application.
|
||||
Run with: python run.py [--port PORT]
|
||||
"""
|
||||
import argparse
|
||||
import logging
|
||||
|
||||
from app import create_app
|
||||
from app.utils import setup_custom_dns
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
|
||||
@app.route("/")
|
||||
def serve_frontend():
|
||||
"""Serve the frontend index.html file"""
|
||||
return send_from_directory("frontend", "index.html")
|
||||
|
||||
|
||||
@app.route("/assets/<path:filename>")
|
||||
def serve_assets(filename):
|
||||
"""Serve assets from the docs/assets directory"""
|
||||
try:
|
||||
return send_from_directory("docs/assets", filename)
|
||||
except:
|
||||
return "Asset not found", 404
|
||||
|
||||
|
||||
@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:
|
||||
return "Not found", 404
|
||||
|
||||
# Only serve files that exist in the frontend directory
|
||||
try:
|
||||
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")
|
||||
|
||||
|
||||
# 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"]
|
||||
|
||||
custom_resolver = dns.resolver.Resolver()
|
||||
custom_resolver.nameservers = dns_servers
|
||||
|
||||
original_getaddrinfo = socket.getaddrinfo
|
||||
|
||||
def new_getaddrinfo(host, port, family=0, type=0, proto=0, flags=0):
|
||||
if host:
|
||||
try:
|
||||
# Skip DNS resolution for IP addresses
|
||||
try:
|
||||
ipaddress.ip_address(host)
|
||||
# If we get here, the host is already an IP address
|
||||
logger.debug(f"Host is already an IP address: {host}, skipping DNS resolution")
|
||||
except ValueError:
|
||||
# Not an IP address, so use DNS resolution
|
||||
answers = custom_resolver.resolve(host)
|
||||
host = str(answers[0])
|
||||
logger.debug(f"Custom DNS resolved {host}")
|
||||
except Exception as e:
|
||||
logger.info(f"Custom DNS resolution failed for {host}: {e}, falling back to system DNS")
|
||||
return original_getaddrinfo(host, port, family, type, proto, flags)
|
||||
|
||||
socket.getaddrinfo = new_getaddrinfo
|
||||
logger.info("Custom DNS resolver set up")
|
||||
|
||||
|
||||
# Initialize DNS resolver
|
||||
setup_custom_dns()
|
||||
|
||||
|
||||
# No persistent connections - fresh connection for each request to avoid stale connection issues
|
||||
|
||||
# Common request function for API endpoints
|
||||
def fetch_api_data(url, timeout=10):
|
||||
"""Make a request to an API endpoint"""
|
||||
ua = UserAgent()
|
||||
headers = {
|
||||
"User-Agent": ua.chrome,
|
||||
"Accept": "application/json,text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
|
||||
"Accept-Language": "en-US,en;q=0.5",
|
||||
"Connection": "close",
|
||||
"Accept-Encoding": "gzip, deflate",
|
||||
}
|
||||
|
||||
try:
|
||||
hostname = urllib.parse.urlparse(url).netloc.split(":")[0]
|
||||
logger.info(f"Making request to host: {hostname}")
|
||||
|
||||
# Use fresh connection for each request to avoid stale connection issues
|
||||
response = requests.get(url, headers=headers, timeout=timeout, stream=True)
|
||||
response.raise_for_status()
|
||||
|
||||
# For large responses, use streaming JSON parsing
|
||||
try:
|
||||
# Check content length to decide parsing strategy
|
||||
content_length = response.headers.get('Content-Length')
|
||||
if content_length and int(content_length) > 10_000_000: # > 10MB
|
||||
logger.info(f"Large response detected ({content_length} bytes), using optimized parsing")
|
||||
|
||||
# Stream the JSON content for better memory efficiency
|
||||
response.encoding = 'utf-8' # Ensure proper encoding
|
||||
return response.json()
|
||||
except json.JSONDecodeError:
|
||||
# Fallback to text for non-JSON responses
|
||||
return response.text
|
||||
|
||||
except requests.exceptions.SSLError:
|
||||
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
|
||||
|
||||
|
||||
def stream_request(url, headers=None, timeout=30):
|
||||
"""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",
|
||||
"Connection": "keep-alive",
|
||||
}
|
||||
|
||||
# Use longer timeout for streams and set both connect and read timeouts
|
||||
return requests.get(url, stream=True, headers=headers, timeout=(10, timeout))
|
||||
|
||||
|
||||
def encode_url(url):
|
||||
"""Safely encode a URL for use in proxy endpoints"""
|
||||
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")
|
||||
|
||||
def generate():
|
||||
try:
|
||||
bytes_sent = 0
|
||||
for chunk in response.iter_content(chunk_size=8192):
|
||||
if chunk:
|
||||
bytes_sent += len(chunk)
|
||||
yield chunk
|
||||
logger.info(f"Stream completed, sent {bytes_sent} bytes")
|
||||
except requests.exceptions.ChunkedEncodingError as e:
|
||||
# Chunked encoding error from upstream - log and stop gracefully
|
||||
logger.warning(f"Upstream chunked encoding error after {bytes_sent} bytes: {str(e)}")
|
||||
# Don't raise - just stop yielding to close stream gracefully
|
||||
except requests.exceptions.ConnectionError as e:
|
||||
# Connection error (reset, timeout, etc.) - log and stop gracefully
|
||||
logger.warning(f"Connection error after {bytes_sent} bytes: {str(e)}")
|
||||
# Don't raise - just stop yielding to close stream gracefully
|
||||
except Exception as e:
|
||||
logger.error(f"Streaming error after {bytes_sent} bytes: {str(e)}")
|
||||
# Don't raise exceptions in generators after headers are sent!
|
||||
# Raising here causes Flask to inject "HTTP/1.1 500" into the chunked body,
|
||||
finally:
|
||||
# Always close the upstream response to free resources
|
||||
try:
|
||||
response.close()
|
||||
except:
|
||||
pass
|
||||
|
||||
headers = {
|
||||
"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"]
|
||||
else:
|
||||
headers["Transfer-Encoding"] = "chunked"
|
||||
|
||||
return Response(generate(), mimetype=content_type, headers=headers, direct_passthrough=True)
|
||||
|
||||
|
||||
@app.route("/image-proxy/<path:image_url>")
|
||||
def proxy_image(image_url):
|
||||
"""Proxy endpoint for images to avoid CORS issues"""
|
||||
try:
|
||||
original_url = urllib.parse.unquote(image_url)
|
||||
logger.info(f"Image proxy request for: {original_url}")
|
||||
|
||||
response = requests.get(original_url, stream=True, timeout=10)
|
||||
response.raise_for_status()
|
||||
|
||||
content_type = response.headers.get("Content-Type", "")
|
||||
|
||||
if not content_type.startswith("image/"):
|
||||
logger.error(f"Invalid content type for image: {content_type}")
|
||||
return Response("Invalid image type", status=415)
|
||||
|
||||
return generate_streaming_response(response, content_type)
|
||||
except requests.Timeout:
|
||||
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)
|
||||
except Exception as e:
|
||||
logger.error(f"Image proxy error: {str(e)}")
|
||||
return Response("Failed to process image", status=500)
|
||||
|
||||
|
||||
@app.route("/stream-proxy/<path:stream_url>")
|
||||
def proxy_stream(stream_url):
|
||||
"""Proxy endpoint for streams"""
|
||||
try:
|
||||
original_url = urllib.parse.unquote(stream_url)
|
||||
logger.info(f"Stream proxy request for: {original_url}")
|
||||
|
||||
response = stream_request(original_url, timeout=60) # Longer timeout for live streams
|
||||
response.raise_for_status()
|
||||
|
||||
# Determine 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"
|
||||
else:
|
||||
content_type = "application/octet-stream"
|
||||
|
||||
logger.info(f"Using content type: {content_type}")
|
||||
return generate_streaming_response(response, content_type)
|
||||
except requests.Timeout:
|
||||
logger.error(f"Timeout connecting to stream: {original_url}")
|
||||
return Response("Stream timeout", status=504)
|
||||
except requests.HTTPError as e:
|
||||
logger.error(f"HTTP error fetching stream: {e.response.status_code} - {original_url}")
|
||||
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)} - {original_url}")
|
||||
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 []
|
||||
|
||||
|
||||
def group_matches(group_title, pattern):
|
||||
"""Check if a group title matches a pattern, supporting wildcards and exact matching"""
|
||||
# Convert to lowercase for case-insensitive matching
|
||||
group_lower = group_title.lower()
|
||||
pattern_lower = pattern.lower()
|
||||
|
||||
# Handle spaces in pattern
|
||||
if " " in pattern_lower:
|
||||
# For patterns with spaces, split and check each part
|
||||
pattern_parts = pattern_lower.split()
|
||||
group_parts = group_lower.split()
|
||||
|
||||
# If pattern has more parts than group, can't match
|
||||
if len(pattern_parts) > len(group_parts):
|
||||
return False
|
||||
|
||||
# Check each part of the pattern against group parts
|
||||
for i, part in enumerate(pattern_parts):
|
||||
if i >= len(group_parts):
|
||||
return False
|
||||
if "*" in part or "?" in part:
|
||||
if not fnmatch.fnmatch(group_parts[i], part):
|
||||
return False
|
||||
else:
|
||||
if part not in group_parts[i]:
|
||||
return False
|
||||
return True
|
||||
|
||||
# Check for wildcard patterns
|
||||
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 (supports both GET and POST)"""
|
||||
# Handle both GET and POST requests
|
||||
if request.method == "POST":
|
||||
data = request.get_json() or {}
|
||||
url = data.get("url")
|
||||
username = data.get("username")
|
||||
password = data.get("password")
|
||||
proxy_url = data.get("proxy_url", DEFAULT_PROXY_URL) or request.host_url.rstrip("/")
|
||||
else:
|
||||
url = request.args.get("url")
|
||||
username = request.args.get("username")
|
||||
password = request.args.get("password")
|
||||
proxy_url = request.args.get("proxy_url", DEFAULT_PROXY_URL) or request.host_url.rstrip("/")
|
||||
|
||||
if not url or not username or not password:
|
||||
return (
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
jsonify({"error": "Missing Parameters", "details": "Required parameters: url, username, and password"}),
|
||||
400
|
||||
)
|
||||
|
||||
return url, username, password, proxy_url, None, None
|
||||
|
||||
|
||||
def validate_xtream_credentials(url, username, password):
|
||||
"""Validate the Xtream API credentials"""
|
||||
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,
|
||||
)
|
||||
|
||||
return data, None, None
|
||||
|
||||
|
||||
def fetch_api_endpoint(url_info):
|
||||
"""Fetch a single API endpoint - used for concurrent requests"""
|
||||
url, name, timeout = url_info
|
||||
try:
|
||||
logger.info(f"🚀 Fetching {name}...")
|
||||
start_time = time.time()
|
||||
data = fetch_api_data(url, timeout=timeout)
|
||||
end_time = time.time()
|
||||
|
||||
if isinstance(data, list):
|
||||
logger.info(f"✅ Completed {name} in {end_time-start_time:.1f}s - got {len(data)} items")
|
||||
else:
|
||||
logger.info(f"✅ Completed {name} in {end_time-start_time:.1f}s")
|
||||
return name, data
|
||||
except Exception as e:
|
||||
logger.warning(f"❌ Failed to fetch {name}: {e}")
|
||||
return name, None
|
||||
|
||||
def fetch_categories_and_channels(url, username, password, include_vod=False):
|
||||
"""Fetch categories and channels from the Xtream API using concurrent requests"""
|
||||
all_categories = []
|
||||
all_streams = []
|
||||
|
||||
try:
|
||||
# Prepare all API endpoints to fetch concurrently
|
||||
api_endpoints = [
|
||||
(f"{url}/player_api.php?username={username}&password={password}&action=get_live_categories",
|
||||
"live_categories", 60),
|
||||
(f"{url}/player_api.php?username={username}&password={password}&action=get_live_streams",
|
||||
"live_streams", 180),
|
||||
]
|
||||
|
||||
# Add VOD endpoints if requested (WARNING: This will be much slower!)
|
||||
if include_vod:
|
||||
logger.warning("⚠️ Including VOD content - this will take significantly longer!")
|
||||
logger.info("💡 For faster loading, use the API without include_vod=true")
|
||||
|
||||
# Only add the most essential VOD endpoints - skip the massive streams for categories-only requests
|
||||
api_endpoints.extend([
|
||||
(f"{url}/player_api.php?username={username}&password={password}&action=get_vod_categories",
|
||||
"vod_categories", 60),
|
||||
(f"{url}/player_api.php?username={username}&password={password}&action=get_series_categories",
|
||||
"series_categories", 60),
|
||||
])
|
||||
|
||||
# Only fetch the massive stream lists if explicitly needed for M3U generation
|
||||
vod_for_m3u = request.endpoint == 'generate_m3u'
|
||||
if vod_for_m3u:
|
||||
logger.warning("🐌 Fetching massive VOD/Series streams for M3U generation...")
|
||||
api_endpoints.extend([
|
||||
(f"{url}/player_api.php?username={username}&password={password}&action=get_vod_streams",
|
||||
"vod_streams", 240),
|
||||
(f"{url}/player_api.php?username={username}&password={password}&action=get_series",
|
||||
"series", 240),
|
||||
])
|
||||
else:
|
||||
logger.info("⚡ Skipping massive VOD streams for categories-only request")
|
||||
|
||||
# Fetch all endpoints concurrently using ThreadPoolExecutor
|
||||
logger.info(f"Starting concurrent fetch of {len(api_endpoints)} API endpoints...")
|
||||
results = {}
|
||||
|
||||
with ThreadPoolExecutor(max_workers=10) as executor: # Increased workers for better concurrency
|
||||
# Submit all API calls
|
||||
future_to_name = {executor.submit(fetch_api_endpoint, endpoint): endpoint[1]
|
||||
for endpoint in api_endpoints}
|
||||
|
||||
# Collect results as they complete
|
||||
for future in as_completed(future_to_name):
|
||||
name, data = future.result()
|
||||
results[name] = data
|
||||
|
||||
logger.info("All concurrent API calls completed!")
|
||||
|
||||
# Process live categories and streams (required)
|
||||
live_categories = results.get("live_categories")
|
||||
live_streams = results.get("live_streams")
|
||||
|
||||
if isinstance(live_categories, tuple): # Error response
|
||||
return None, None, live_categories[0], live_categories[1]
|
||||
if isinstance(live_streams, tuple): # Error response
|
||||
return None, None, live_streams[0], live_streams[1]
|
||||
|
||||
if not isinstance(live_categories, list) or not isinstance(live_streams, list):
|
||||
return (
|
||||
None,
|
||||
None,
|
||||
json.dumps(
|
||||
{
|
||||
"error": "Invalid Data Format",
|
||||
"details": "Live categories or streams data is not in the expected format",
|
||||
}
|
||||
),
|
||||
500,
|
||||
)
|
||||
|
||||
# Optimized data processing - batch operations for massive datasets
|
||||
logger.info("Processing live content...")
|
||||
|
||||
# Batch set content_type for live content
|
||||
if live_categories:
|
||||
for category in live_categories:
|
||||
category["content_type"] = "live"
|
||||
all_categories.extend(live_categories)
|
||||
|
||||
if live_streams:
|
||||
for stream in live_streams:
|
||||
stream["content_type"] = "live"
|
||||
all_streams.extend(live_streams)
|
||||
|
||||
logger.info(f"✅ Added {len(live_categories)} live categories and {len(live_streams)} live streams")
|
||||
|
||||
# Process VOD content if requested and available
|
||||
if include_vod:
|
||||
logger.info("Processing VOD content...")
|
||||
|
||||
# Process VOD categories
|
||||
vod_categories = results.get("vod_categories")
|
||||
if isinstance(vod_categories, list) and vod_categories:
|
||||
for category in vod_categories:
|
||||
category["content_type"] = "vod"
|
||||
all_categories.extend(vod_categories)
|
||||
logger.info(f"✅ Added {len(vod_categories)} VOD categories")
|
||||
|
||||
# Process series categories first (lightweight)
|
||||
series_categories = results.get("series_categories")
|
||||
if isinstance(series_categories, list) and series_categories:
|
||||
for category in series_categories:
|
||||
category["content_type"] = "series"
|
||||
all_categories.extend(series_categories)
|
||||
logger.info(f"✅ Added {len(series_categories)} series categories")
|
||||
|
||||
# Only process massive stream lists if they were actually fetched
|
||||
vod_streams = results.get("vod_streams")
|
||||
if isinstance(vod_streams, list) and vod_streams:
|
||||
logger.info(f"🔥 Processing {len(vod_streams)} VOD streams (this is the slow part)...")
|
||||
|
||||
# Batch process for better performance
|
||||
batch_size = 5000
|
||||
for i in range(0, len(vod_streams), batch_size):
|
||||
batch = vod_streams[i:i + batch_size]
|
||||
for stream in batch:
|
||||
stream["content_type"] = "vod"
|
||||
if i + batch_size < len(vod_streams):
|
||||
logger.info(f" Processed {i + batch_size}/{len(vod_streams)} VOD streams...")
|
||||
|
||||
all_streams.extend(vod_streams)
|
||||
logger.info(f"✅ Added {len(vod_streams)} VOD streams")
|
||||
|
||||
# Process series (this can also be huge!)
|
||||
series = results.get("series")
|
||||
if isinstance(series, list) and series:
|
||||
logger.info(f"🔥 Processing {len(series)} series (this is also slow)...")
|
||||
|
||||
# Batch process for better performance
|
||||
batch_size = 5000
|
||||
for i in range(0, len(series), batch_size):
|
||||
batch = series[i:i + batch_size]
|
||||
for show in batch:
|
||||
show["content_type"] = "series"
|
||||
if i + batch_size < len(series):
|
||||
logger.info(f" Processed {i + batch_size}/{len(series)} series...")
|
||||
|
||||
all_streams.extend(series)
|
||||
logger.info(f"✅ Added {len(series)} series")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Critical error fetching API data: {e}")
|
||||
return (
|
||||
None,
|
||||
None,
|
||||
json.dumps(
|
||||
{
|
||||
"error": "API Fetch Error",
|
||||
"details": f"Failed to fetch data from IPTV service: {str(e)}",
|
||||
}
|
||||
),
|
||||
500,
|
||||
)
|
||||
|
||||
logger.info(f"🚀 CONCURRENT FETCH COMPLETE: {len(all_categories)} total categories and {len(all_streams)} total streams")
|
||||
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
|
||||
url, username, password, proxy_url, error, status_code = get_required_params()
|
||||
if error:
|
||||
return error, status_code
|
||||
|
||||
# Check for VOD parameter - default to false to avoid timeouts (VOD is massive and slow!)
|
||||
include_vod = request.args.get("include_vod", "false").lower() == "true"
|
||||
logger.info(f"VOD content requested: {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"}
|
||||
|
||||
# Fetch categories
|
||||
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 categories as JSON
|
||||
return json.dumps(categories), 200, {"Content-Type": "application/json"}
|
||||
|
||||
|
||||
@app.route("/xmltv", methods=["GET"])
|
||||
def generate_xmltv():
|
||||
"""Generate a filtered XMLTV file from the Xtream API"""
|
||||
# Get and validate parameters
|
||||
url, username, password, proxy_url, error, status_code = get_required_params()
|
||||
if error:
|
||||
return error, status_code
|
||||
|
||||
# No filtering supported for XMLTV endpoint
|
||||
|
||||
# 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"}
|
||||
|
||||
# Fetch XMLTV data
|
||||
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"}
|
||||
|
||||
# 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"}
|
||||
)
|
||||
|
||||
# Replace image URLs in the XMLTV content with proxy URLs
|
||||
def replace_icon_url(match):
|
||||
original_url = match.group(1)
|
||||
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)
|
||||
|
||||
# Return the XMLTV data
|
||||
return Response(
|
||||
xmltv_data, mimetype="application/xml", headers={"Content-Disposition": "attachment; filename=guide.xml"}
|
||||
)
|
||||
|
||||
|
||||
@app.route("/m3u", methods=["GET", "POST"])
|
||||
def generate_m3u():
|
||||
"""Generate a filtered M3U playlist from the Xtream API"""
|
||||
# Get and validate parameters
|
||||
url, username, password, proxy_url, error, status_code = get_required_params()
|
||||
if error:
|
||||
return error, status_code
|
||||
|
||||
# Parse filter parameters (support both GET and POST for large filter lists)
|
||||
if request.method == "POST":
|
||||
data = request.get_json() or {}
|
||||
unwanted_groups = parse_group_list(data.get("unwanted_groups", ""))
|
||||
wanted_groups = parse_group_list(data.get("wanted_groups", ""))
|
||||
no_stream_proxy = str(data.get("nostreamproxy", "")).lower() == "true"
|
||||
include_vod = str(data.get("include_vod", "false")).lower() == "true"
|
||||
include_channel_id = str(data.get("include_channel_id", "false")).lower() == "true"
|
||||
channel_id_tag = str(data.get("channel_id_tag", "channel-id"))
|
||||
logger.info("🔄 Processing POST request for M3U generation")
|
||||
else:
|
||||
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", "false").lower() == "true"
|
||||
include_channel_id = request.args.get("include_channel_id", "false") == "true"
|
||||
channel_id_tag = request.args.get("channel_id_tag", "channel-id")
|
||||
logger.info("🔄 Processing GET request for M3U generation")
|
||||
|
||||
# For M3U generation, warn about VOD performance impact
|
||||
if include_vod:
|
||||
logger.warning("⚠️ M3U generation with VOD enabled - expect 2-5 minute generation time!")
|
||||
else:
|
||||
logger.info("⚡ M3U generation for live content only - should be fast!")
|
||||
|
||||
# Log filter parameters (truncate if too long for readability)
|
||||
wanted_display = f"{len(wanted_groups)} groups" if len(wanted_groups) > 10 else str(wanted_groups)
|
||||
unwanted_display = f"{len(unwanted_groups)} groups" if len(unwanted_groups) > 10 else str(unwanted_groups)
|
||||
logger.info(f"Filter parameters - wanted_groups: {wanted_display}, unwanted_groups: {unwanted_display}, include_vod: {include_vod}")
|
||||
|
||||
# Warn about massive filter lists
|
||||
total_filters = len(wanted_groups) + len(unwanted_groups)
|
||||
if total_filters > 20:
|
||||
logger.warning(f"⚠️ Large filter list detected ({total_filters} categories) - this will be slower!")
|
||||
if total_filters > 50:
|
||||
logger.warning(f"🐌 MASSIVE filter list ({total_filters} categories) - expect 3-5 minute processing time!")
|
||||
|
||||
# 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"}
|
||||
|
||||
# Fetch categories and channels
|
||||
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"}
|
||||
|
||||
# Extract user info and server URL
|
||||
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']}"
|
||||
|
||||
# Create category name lookup
|
||||
category_names = {cat["category_id"]: cat["category_name"] for cat in categories}
|
||||
|
||||
# Log all available groups
|
||||
all_groups = set(category_names.values())
|
||||
logger.info(f"All available groups: {sorted(all_groups)}")
|
||||
|
||||
# Generate M3U playlist
|
||||
m3u_playlist = "#EXTM3U\n"
|
||||
|
||||
# Track included groups
|
||||
included_groups = set()
|
||||
processed_streams = 0
|
||||
total_streams = len(streams)
|
||||
|
||||
# Pre-compile filter patterns for massive filter lists (performance optimization)
|
||||
wanted_patterns = [pattern.lower() for pattern in wanted_groups] if wanted_groups else []
|
||||
unwanted_patterns = [pattern.lower() for pattern in unwanted_groups] if unwanted_groups else []
|
||||
|
||||
logger.info(f"🔍 Starting to filter {total_streams} streams...")
|
||||
batch_size = 10000 # Process streams in batches for better performance
|
||||
|
||||
for stream in streams:
|
||||
content_type = stream.get("content_type", "live")
|
||||
|
||||
# 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")
|
||||
|
||||
# Add content type prefix for VOD
|
||||
if content_type == "vod":
|
||||
group_title = f"VOD - {group_title}"
|
||||
|
||||
# Optimized filtering logic using pre-compiled patterns
|
||||
include_stream = True
|
||||
group_title_lower = group_title.lower()
|
||||
|
||||
if wanted_patterns:
|
||||
# Only include streams from specified groups (optimized matching)
|
||||
include_stream = any(
|
||||
group_matches(group_title, wanted_group) for wanted_group in wanted_groups
|
||||
)
|
||||
elif unwanted_patterns:
|
||||
# Exclude streams from unwanted groups (optimized matching)
|
||||
include_stream = not any(
|
||||
group_matches(group_title, unwanted_group) for unwanted_group in unwanted_groups
|
||||
)
|
||||
|
||||
processed_streams += 1
|
||||
|
||||
# Progress logging for large datasets
|
||||
if processed_streams % batch_size == 0:
|
||||
logger.info(f" 📊 Processed {processed_streams}/{total_streams} streams ({(processed_streams/total_streams)*100:.1f}%)")
|
||||
|
||||
if include_stream:
|
||||
included_groups.add(group_title)
|
||||
|
||||
tags = [
|
||||
f'tvg-name="{stream_name}"',
|
||||
f'group-title="{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
|
||||
tags.append(f'tvg-logo="{logo_url}"')
|
||||
|
||||
# Handle channel id if enabled
|
||||
if include_channel_id:
|
||||
channel_id = stream.get("epg_channel_id")
|
||||
if channel_id:
|
||||
tags.append(f'{channel_id_tag}="{channel_id}"')
|
||||
|
||||
# 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:
|
||||
# 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"
|
||||
|
||||
# Apply stream proxying if enabled
|
||||
if not no_stream_proxy:
|
||||
stream_url = f"{proxy_url}/stream-proxy/{encode_url(stream_url)}"
|
||||
|
||||
# Add stream to playlist
|
||||
m3u_playlist += (
|
||||
f'#EXTINF:0 {" ".join(tags)},{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"
|
||||
|
||||
logger.info(f"✅ M3U generation complete! Generated playlist with {len(included_groups)} groups")
|
||||
|
||||
# Return the M3U playlist with proper CORS headers for frontend
|
||||
headers = {
|
||||
"Content-Disposition": f"attachment; filename={filename}",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type"
|
||||
}
|
||||
|
||||
return Response(m3u_playlist, mimetype="audio/x-scpls", headers=headers)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="Run the Flask app.")
|
||||
def main():
|
||||
"""Main entry point for the application"""
|
||||
# Parse command line arguments
|
||||
parser = argparse.ArgumentParser(description="Run the Xtream2M3U Flask app.")
|
||||
parser.add_argument(
|
||||
"--port", type=int, default=5000, help="Port number to run the app on"
|
||||
"--port", type=int, default=5000, help="Port number to run the app on (default: 5000)"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
# Initialize custom DNS resolver
|
||||
setup_custom_dns()
|
||||
|
||||
# Create the Flask app
|
||||
app = create_app()
|
||||
|
||||
# Run the app
|
||||
logger.info(f"Starting Xtream2M3U server on port {args.port}")
|
||||
app.run(debug=True, host="0.0.0.0", port=args.port)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
Reference in New Issue
Block a user