first commit

This commit is contained in:
2025-02-23 20:31:57 -06:00
commit 34b4252adf
9 changed files with 366 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
.venv/
__pycache__/
*.pyc
.env

35
Dockerfile Normal file
View File

@@ -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"]

86
README.md Normal file
View File

@@ -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

45
data/channels.json Normal file
View File

@@ -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"
}
]
}

21
docker-compose.yml Normal file
View File

@@ -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

2
env Normal file
View File

@@ -0,0 +1,2 @@
AUTH_USERNAME=65128929
AUTH_PASSWORD=34243636

43
src/encodeurls.py Normal file
View File

@@ -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)

6
src/requirements.txt Normal file
View File

@@ -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

124
src/stream_link_server.py Normal file
View File

@@ -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
)