first commit
This commit is contained in:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
35
Dockerfile
Normal file
35
Dockerfile
Normal 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
86
README.md
Normal 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
45
data/channels.json
Normal 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
21
docker-compose.yml
Normal 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
|
||||||
43
src/encodeurls.py
Normal file
43
src/encodeurls.py
Normal 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
6
src/requirements.txt
Normal 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
124
src/stream_link_server.py
Normal 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
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user