From 34b4252adffd302a40829640eedc41c1672fdeea Mon Sep 17 00:00:00 2001 From: Stefano Fiorini Date: Sun, 23 Feb 2025 20:31:57 -0600 Subject: [PATCH] first commit --- .gitignore | 4 ++ Dockerfile | 35 +++++++++++ README.md | 86 ++++++++++++++++++++++++++ data/channels.json | 45 ++++++++++++++ docker-compose.yml | 21 +++++++ env | 2 + src/encodeurls.py | 43 +++++++++++++ src/requirements.txt | 6 ++ src/stream_link_server.py | 124 ++++++++++++++++++++++++++++++++++++++ 9 files changed, 366 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 data/channels.json create mode 100644 docker-compose.yml create mode 100644 env create mode 100644 src/encodeurls.py create mode 100644 src/requirements.txt create mode 100644 src/stream_link_server.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8f0d76c --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.venv/ +__pycache__/ +*.pyc +.env \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ddd7398 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,35 @@ +FROM python:3.11-slim + +# Install system dependencies including FFmpeg +RUN apt-get update && apt-get install -y \ + ffmpeg \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# Install Streamlink +RUN pip install --no-cache-dir streamlink + +# Set working directory +WORKDIR /app + +# Copy application code +COPY src/ ./src/ +COPY env ./src/.env + +# Install Python dependencies +RUN pip install --no-cache-dir -r src/requirements.txt + +# Create data directory +RUN mkdir /data + +# Create a symlink from /app/data to /data +RUN ln -s /data /app/data + +# Volume configuration +VOLUME ["/data"] + +# Expose the port +EXPOSE 6090 + +# Command to run the application +CMD ["python", "src/stream_link_server.py"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..a19d09c --- /dev/null +++ b/README.md @@ -0,0 +1,86 @@ +# streamlink-server + +Run your m3u and mpd streams through streamlink with authentication + +## Purpose + +Convert unstable or incompatible video streams into a format that media players can reliably play and record by processing them through streamlink. + +## Requirements + +- Python 3.11 or newer +- FFmpeg +- streamlink +- Python packages (install via `pip`): + - FastAPI + - uvicorn + - python-dotenv + - python-multipart + +## Installation + +```bash +git clone https://github.com/purplescorpion1/streamlink-server.git +cd streamlink-server +pip install -r src/requirements.txt +``` + +## Configuration + +1. Create a `.env` file in the project root: + +```plaintext +AUTH_USERNAME=your_username +AUTH_PASSWORD=your_password +``` + +2. Configure your channels in `data/channels.json`: + +```json +{ + "channels": [ + { + "id": "1", + "name": "Example Channel", + "url": "https://example.com/stream.m3u8" + } + ] +} +``` + +## Usage + +1. Start the server: + +```bash +python src/stream_link_server.py +``` + +The server will start on port 6090 by default. + +2. Access your streams: + +- List all channels: + `http://localhost:6090/channels?username=xxx&password=xxx` +- Stream specific channel: + `http://localhost:6090/{channel_id}?username=xxx&password=xxx` + +## Docker Support + +Run with Docker Compose: + +```bash +docker-compose up -d +``` + +## Environment Variables + +- `AUTH_USERNAME`: Username for authentication +- `AUTH_PASSWORD`: Password for authentication +- `PORT`: Server port (default: 6090) + +## Notes + +- Make sure streamlink is properly installed and available in your system PATH +- All streams require authentication with username/password +- The server provides a clean HTTP interface for your media players \ No newline at end of file diff --git a/data/channels.json b/data/channels.json new file mode 100644 index 0000000..444b181 --- /dev/null +++ b/data/channels.json @@ -0,0 +1,45 @@ +{ + "channels": [ + { + "id": "1", + "name": "RAI 1", + "url": "https://cachehsi1a.netplus.ch/live/eds/rai1/browser-dash/rai1.mpd" + }, + { + "id": "2", + "name": "RAI 2", + "url": "https://cachehsi1a.netplus.ch/live/eds/rai2/browser-dash/rai2.mpd" + }, + { + "id": "3", + "name": "RAI 3", + "url": "https://cachehsi1a.netplus.ch/live/eds/rai3/browser-dash/rai3.mpd" + }, + { + "id": "4", + "name": "Rete 4", + "url": "https://cachehsi1a.netplus.ch/live/eds/rete4/browser-dash/rete4.mpd" + }, + { + "id": "5", + "name": "Canale 5", + "url": "https://cachehsi1a.netplus.ch/live/eds/canale5/browser-dash/canale5.mpd" + }, + { + "id": "6", + "name": "Italia 1", + "url": "https://cachehsi1a.netplus.ch/live/eds/italia1/browser-dash/italia1.mpd" + }, + { + "id": "7", + "name": "Rai Storia", + "url": "https://cachehsi1a.netplus.ch/live/eds/raistoria/browser-dash/raistoria.mpd" + }, + { + "id": "8", + "name": "Rai Scuola", + "url": "https://cachehsi1a.netplus.ch/live/eds/raiscuola/browser-dash/raiscuola.mpd" + } + + ] +} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..52875fc --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,21 @@ +version: '3.8' + +services: + streamlink-server: + build: + context: . + dockerfile: Dockerfile + container_name: streamlink-server + ports: + - "6090:6090" + volumes: + - ./data:/data + restart: unless-stopped + environment: + - TZ=UTC + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:6090/channels?username=65128929&password=34243636"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s \ No newline at end of file diff --git a/env b/env new file mode 100644 index 0000000..8bdd2cf --- /dev/null +++ b/env @@ -0,0 +1,2 @@ +AUTH_USERNAME=65128929 +AUTH_PASSWORD=34243636 \ No newline at end of file diff --git a/src/encodeurls.py b/src/encodeurls.py new file mode 100644 index 0000000..080fa8e --- /dev/null +++ b/src/encodeurls.py @@ -0,0 +1,43 @@ +import urllib.parse + +def encode_url_part(url): + """Encodes the URL part, preserving URL special characters.""" + return urllib.parse.quote(url, safe='~()*!.\'') # Extended safe characters to handle more URL characters + +def process_m3u_file(input_file, output_file): + """Reads the M3U file, encodes the stream URLs, and writes to a new file.""" + base_url = 'http://192.168.1.123:6090/stream?url=' + + with open(input_file, 'r') as file: + lines = file.readlines() + + with open(output_file, 'w') as file: + for line in lines: + line = line.strip() + if line.startswith('#EXTINF'): + # Write the #EXTINF line unchanged + file.write(line + '\n') + elif line.startswith(base_url): + # Extract and encode only the part after 'url=' + url_part = line[len(base_url):] + print(f"Original URL part: {url_part}") # Debugging statement + + # URL decode the part before encoding + url_part = urllib.parse.unquote(url_part) + print(f"Decoded URL part: {url_part}") # Debugging statement + + encoded_url_part = encode_url_part(url_part) + print(f"Encoded URL part: {encoded_url_part}") # Debugging statement + + encoded_line = f"{base_url}{encoded_url_part}" + file.write(encoded_line + '\n') + else: + # Write other lines unchanged + file.write(line + '\n') + +# Define input and output file paths +input_file = 'input.m3u' +output_file = 'output_encoded.m3u' + +# Process the M3U file +process_m3u_file(input_file, output_file) diff --git a/src/requirements.txt b/src/requirements.txt new file mode 100644 index 0000000..cf30cfa --- /dev/null +++ b/src/requirements.txt @@ -0,0 +1,6 @@ +fastapi>=0.109.0 +uvicorn>=0.27.0 +streamlink>=6.5.0 +python-multipart>=0.0.9 +typing-extensions>=4.9.0 +python-dotenv==1.0.0 \ No newline at end of file diff --git a/src/stream_link_server.py b/src/stream_link_server.py new file mode 100644 index 0000000..9565a52 --- /dev/null +++ b/src/stream_link_server.py @@ -0,0 +1,124 @@ +import asyncio +from asyncio import create_subprocess_exec, subprocess +from asyncio.subprocess import Process +from fastapi import Depends, FastAPI, HTTPException +from fastapi.responses import StreamingResponse, JSONResponse +from dotenv import load_dotenv +import uvicorn +import subprocess +import json +import os +from typing import Dict, List, Optional + +# Load environment variables +load_dotenv() + +app = FastAPI(title="Streamlink Server") + +AUTH_USERNAME = os.getenv("AUTH_USERNAME") +AUTH_PASSWORD = os.getenv("AUTH_PASSWORD") +DATA_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'data') + +def verify_credentials(username: Optional[str] = None, password: Optional[str] = None): + if not username or not password: + raise HTTPException(status_code=401, detail="Authentication required") + + if username != AUTH_USERNAME or password != AUTH_PASSWORD: + raise HTTPException(status_code=403, detail="Invalid credentials") + + return True + +# Load channels configuration +def load_channels(): + """Load channels configuration from JSON file""" + channels_path = os.path.join(DATA_DIR, 'channels.json') + with open(channels_path, 'r') as f: + return {str(c['id']): c for c in json.load(f)['channels']} + +async def generate_streamlink_process(url) -> Process: + """ + Run Streamlink as an async subprocess and pipe its output to the response. + """ + process = await create_subprocess_exec( + 'streamlink', + '--ffmpeg-fout', 'mpegts', + '--hls-live-restart', + '--retry-streams', '3', + '--stream-timeout', '60', + '--hls-playlist-reload-attempts', '3', + '--stream-segment-threads', '3', + '--ringbuffer-size', '32M', + '--stdout', + url, + 'best', + stdout=subprocess.PIPE, + stderr=subprocess.PIPE + ) + return process + +async def stream_generator(process: Process, url: str): + """Generate streaming content asynchronously""" + CHUNK_SIZE = 32768 + try: + while True: + if process.returncode is not None: + # Process has terminated, restart it + process = await generate_streamlink_process(url) + continue + + try: + output = await process.stdout.read(CHUNK_SIZE) + if output: + yield output + else: + # No output but process still running, wait briefly + await asyncio.sleep(0.1) + except Exception as e: + print(f"Error reading stream: {e}") + try: + process.terminate() + except: + pass + process = await generate_streamlink_process(url) + finally: + try: + process.terminate() + except: + pass + +@app.get("/channels", response_model=List[Dict]) +async def list_channels(auth: bool = Depends(verify_credentials)): + """List all available channels""" + channels = load_channels() + return [{ + 'id': c['id'], + 'name': c['name'] + } for c in channels.values()] + +@app.get("/{channel_id}") +async def stream_channel(channel_id: str, auth: bool = Depends(verify_credentials)): + """Stream a channel by ID""" + channels = load_channels() + if channel_id not in channels: + raise HTTPException(status_code=404, detail="Channel not found") + + channel = channels[channel_id] + url = channel['url'] + + try: + process = await generate_streamlink_process(url) + return StreamingResponse( + stream_generator(process, url), + media_type='video/mp2t' + ) + except Exception as e: + raise HTTPException(status_code=500, detail=f"Error starting Streamlink: {str(e)}") + +if __name__ == "__main__": + uvicorn.run( + "stream_link_server:app", + host="0.0.0.0", + port=6090, + reload=True, + workers=2 + )