Rewrite to support M3U playlists instead of M3U8 HLS streams
This commit is contained in:
@@ -1,13 +1,14 @@
|
|||||||
# M3U8 to RTMP Streamer
|
# M3U to RTMP Streamer
|
||||||
|
|
||||||
Python script to stream M3U8 (HLS) live streams to RTMP servers using ffmpeg without re-encoding.
|
Python script to stream M3U playlist files to RTMP servers using ffmpeg without re-encoding.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Direct stream passthrough (no re-encoding)
|
- Direct stream passthrough (no re-encoding)
|
||||||
- Supports M3U8/HLS playlists
|
- Supports M3U playlist files
|
||||||
- Streams to RTMP servers
|
- Streams to RTMP servers
|
||||||
- Real-time output monitoring
|
- Real-time output monitoring
|
||||||
|
- Playlist looping support
|
||||||
- Graceful shutdown on Ctrl+C
|
- Graceful shutdown on Ctrl+C
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
@@ -29,34 +30,57 @@ Python script to stream M3U8 (HLS) live streams to RTMP servers using ffmpeg wit
|
|||||||
### Basic Usage
|
### Basic Usage
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python stream_m3u_to_rtmp.py <m3u_url> <rtmp_url>
|
python stream_m3u_to_rtmp.py <m3u_file> <rtmp_url>
|
||||||
```
|
```
|
||||||
|
|
||||||
### Examples
|
### Examples
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Stream from M3U8 URL to RTMP server
|
# Stream M3U playlist to RTMP server
|
||||||
python stream_m3u_to_rtmp.py \
|
python stream_m3u_to_rtmp.py \
|
||||||
"https://example.com/live/stream.m3u8" \
|
"playlist.m3u" \
|
||||||
"rtmp://live.twitch.tv/app/your_stream_key"
|
"rtmp://live.twitch.tv/app/your_stream_key"
|
||||||
|
|
||||||
# Stream from local M3U8 file (relative or absolute path)
|
# Stream with absolute path
|
||||||
python stream_m3u_to_rtmp.py \
|
python stream_m3u_to_rtmp.py \
|
||||||
"playlist.m3u8" \
|
"C:/path/to/playlist.m3u" \
|
||||||
"rtmp://server.com/live/stream_key"
|
"rtmp://server.com/live/stream_key"
|
||||||
|
|
||||||
# Stream from local M3U8 file with absolute path
|
# Loop through playlist when finished
|
||||||
python stream_m3u_to_rtmp.py \
|
python stream_m3u_to_rtmp.py \
|
||||||
"C:/path/to/playlist.m3u8" \
|
"playlist.m3u" \
|
||||||
"rtmp://server.com/live/stream_key"
|
"rtmp://server.com/live/key" \
|
||||||
|
--loop-playlist
|
||||||
|
|
||||||
|
# Loop each individual video
|
||||||
|
python stream_m3u_to_rtmp.py \
|
||||||
|
"playlist.m3u" \
|
||||||
|
"rtmp://server.com/live/key" \
|
||||||
|
--loop-video
|
||||||
|
|
||||||
# Specify custom ffmpeg path
|
# Specify custom ffmpeg path
|
||||||
python stream_m3u_to_rtmp.py \
|
python stream_m3u_to_rtmp.py \
|
||||||
"https://example.com/stream.m3u8" \
|
"playlist.m3u" \
|
||||||
"rtmp://server.com/live/key" \
|
"rtmp://server.com/live/key" \
|
||||||
--ffmpeg "C:/ffmpeg/bin/ffmpeg.exe"
|
--ffmpeg "C:/ffmpeg/bin/ffmpeg.exe"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## M3U Playlist Format
|
||||||
|
|
||||||
|
The script supports standard M3U playlist format:
|
||||||
|
|
||||||
|
```
|
||||||
|
#EXTM3U
|
||||||
|
#EXTINF:-1,Video Name 1
|
||||||
|
/path/to/video1.mp4
|
||||||
|
#EXTINF:-1,Video Name 2
|
||||||
|
/path/to/video2.mp4
|
||||||
|
```
|
||||||
|
|
||||||
|
- `#EXTM3U` - Playlist header (optional)
|
||||||
|
- `#EXTINF:-1,Name` - Video metadata (optional, -1 means unknown duration)
|
||||||
|
- File paths can be absolute or relative to the M3U file location
|
||||||
|
|
||||||
## RTMP URL Format
|
## RTMP URL Format
|
||||||
|
|
||||||
RTMP URLs typically follow this format:
|
RTMP URLs typically follow this format:
|
||||||
@@ -69,19 +93,26 @@ Examples:
|
|||||||
- YouTube: `rtmp://a.rtmp.youtube.com/live2/YOUR_STREAM_KEY`
|
- YouTube: `rtmp://a.rtmp.youtube.com/live2/YOUR_STREAM_KEY`
|
||||||
- Custom server: `rtmp://your-server.com:1935/live/stream_key`
|
- Custom server: `rtmp://your-server.com:1935/live/stream_key`
|
||||||
|
|
||||||
|
## Options
|
||||||
|
|
||||||
|
- `--loop-playlist`: Loop through the entire playlist when finished
|
||||||
|
- `--loop-video`: Loop each individual video file indefinitely
|
||||||
|
- `--ffmpeg`: Specify custom path to ffmpeg executable
|
||||||
|
|
||||||
## How It Works
|
## How It Works
|
||||||
|
|
||||||
The script uses ffmpeg with:
|
The script:
|
||||||
- `-c copy`: Copies all codecs without re-encoding (passthrough)
|
1. Parses the M3U playlist file to extract video file paths
|
||||||
- `-f flv`: Outputs in FLV format (required for RTMP)
|
2. Streams each video sequentially to the RTMP server
|
||||||
- `-re`: Reads input at native frame rate
|
3. Uses ffmpeg with `-c copy` to passthrough without re-encoding
|
||||||
- Direct stream from M3U8 to RTMP
|
4. Outputs in FLV format (required for RTMP)
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- The stream will continue until stopped (Ctrl+C) or the source ends
|
- The script streams videos in the order they appear in the playlist
|
||||||
|
- Relative paths in M3U files are resolved relative to the M3U file location
|
||||||
- No re-encoding means very low CPU usage
|
- No re-encoding means very low CPU usage
|
||||||
- Requires the source stream codecs to be compatible with RTMP/FLV
|
- Requires the source video codecs to be compatible with RTMP/FLV
|
||||||
- If codec compatibility issues occur, you may need to re-encode (remove `-c copy`)
|
- If codec compatibility issues occur, you may need to re-encode (remove `-c copy`)
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
@@ -89,5 +120,5 @@ The script uses ffmpeg with:
|
|||||||
- **FFmpeg not found**: Install FFmpeg or use `--ffmpeg` to specify path
|
- **FFmpeg not found**: Install FFmpeg or use `--ffmpeg` to specify path
|
||||||
- **Connection errors**: Check RTMP URL and network connectivity
|
- **Connection errors**: Check RTMP URL and network connectivity
|
||||||
- **Codec errors**: Source codecs may not be RTMP-compatible, may need re-encoding
|
- **Codec errors**: Source codecs may not be RTMP-compatible, may need re-encoding
|
||||||
- **Stream stops**: Check source M3U8 URL is accessible and valid
|
- **File not found**: Check video file paths in the M3U playlist are correct
|
||||||
|
- **Playlist empty**: Ensure M3U file contains valid video file paths
|
||||||
|
|||||||
+146
-48
@@ -1,63 +1,93 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
Stream M3U8 (HLS) live stream to RTMP server using ffmpeg without re-encoding.
|
Stream M3U playlist files to RTMP server using ffmpeg without re-encoding.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import signal
|
|
||||||
import os
|
import os
|
||||||
import argparse
|
import argparse
|
||||||
|
|
||||||
def stream_m3u_to_rtmp(m3u_url, rtmp_url, ffmpeg_path='ffmpeg'):
|
def parse_m3u_playlist(m3u_path):
|
||||||
"""
|
"""
|
||||||
Stream M3U8 playlist to RTMP server without re-encoding.
|
Parse M3U playlist file and extract video file paths.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
m3u_url: URL or path to M3U8 playlist file (supports both local files and URLs)
|
m3u_path: Path to M3U playlist file
|
||||||
rtmp_url: RTMP server URL (e.g., rtmp://server/live/stream_key)
|
|
||||||
ffmpeg_path: Path to ffmpeg executable (default: 'ffmpeg')
|
Returns:
|
||||||
|
List of video file paths
|
||||||
"""
|
"""
|
||||||
|
videos = []
|
||||||
|
try:
|
||||||
|
with open(m3u_path, 'r', encoding='utf-8') as f:
|
||||||
|
lines = f.readlines()
|
||||||
|
|
||||||
|
current_name = None
|
||||||
|
for line in lines:
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith('#EXTM3U'):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if line.startswith('#EXTINF'):
|
||||||
|
# Extract name from #EXTINF line: #EXTINF:-1,Video Name
|
||||||
|
parts = line.split(',', 1)
|
||||||
|
if len(parts) > 1:
|
||||||
|
current_name = parts[1].strip()
|
||||||
|
else:
|
||||||
|
current_name = None
|
||||||
|
elif line and not line.startswith('#'):
|
||||||
|
# This is a file path
|
||||||
|
video_path = line.strip()
|
||||||
|
# Resolve relative paths relative to M3U file location
|
||||||
|
if not os.path.isabs(video_path):
|
||||||
|
m3u_dir = os.path.dirname(os.path.abspath(m3u_path))
|
||||||
|
video_path = os.path.join(m3u_dir, video_path)
|
||||||
|
video_path = os.path.normpath(video_path)
|
||||||
|
|
||||||
|
videos.append({
|
||||||
|
'name': current_name or os.path.basename(video_path),
|
||||||
|
'path': video_path
|
||||||
|
})
|
||||||
|
current_name = None
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error parsing M3U file: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
# Check if it's a local file
|
return videos
|
||||||
is_local_file = not (m3u_url.startswith('http://') or m3u_url.startswith('https://') or m3u_url.startswith('rtmp://'))
|
|
||||||
|
def stream_video_to_rtmp(video_path, rtmp_url, ffmpeg_path='ffmpeg', loop=False):
|
||||||
|
"""
|
||||||
|
Stream a single video file to RTMP server without re-encoding.
|
||||||
|
|
||||||
if is_local_file:
|
Args:
|
||||||
# Convert to absolute path
|
video_path: Path to video file
|
||||||
m3u_path = os.path.abspath(m3u_url)
|
rtmp_url: RTMP server URL
|
||||||
if not os.path.exists(m3u_path):
|
ffmpeg_path: Path to ffmpeg executable
|
||||||
print(f"Error: Local M3U8 file not found: {m3u_path}")
|
loop: Whether to loop the video
|
||||||
return False
|
|
||||||
print(f"Using local M3U8 file: {m3u_path}")
|
Returns:
|
||||||
m3u_input = m3u_path
|
True if successful, False otherwise
|
||||||
else:
|
"""
|
||||||
m3u_input = m3u_url
|
if not os.path.exists(video_path):
|
||||||
|
print(f"Error: Video file not found: {video_path}")
|
||||||
# FFmpeg command for direct stream (no re-encoding)
|
return False
|
||||||
# -i: input M3U8 URL
|
|
||||||
# -c copy: copy codecs without re-encoding
|
|
||||||
# -f flv: output format FLV (required for RTMP)
|
|
||||||
# -flvflags no_duration_filesize: avoid issues with FLV format
|
|
||||||
# -re: read input at native frame rate (important for live streams)
|
|
||||||
# -stream_loop -1: loop the stream indefinitely (for live streams, this might not be needed)
|
|
||||||
|
|
||||||
ffmpeg_cmd = [
|
ffmpeg_cmd = [
|
||||||
ffmpeg_path,
|
ffmpeg_path,
|
||||||
'-i', m3u_input,
|
'-re', # Read input at native frame rate
|
||||||
|
'-i', video_path,
|
||||||
'-c', 'copy', # Copy all streams without re-encoding
|
'-c', 'copy', # Copy all streams without re-encoding
|
||||||
'-f', 'flv', # RTMP requires FLV format
|
'-f', 'flv', # RTMP requires FLV format
|
||||||
'-flvflags', 'no_duration_filesize',
|
'-flvflags', 'no_duration_filesize',
|
||||||
'-re', # Read input at native frame rate
|
|
||||||
rtmp_url
|
rtmp_url
|
||||||
]
|
]
|
||||||
|
|
||||||
print(f"Starting stream from: {m3u_url}")
|
if loop:
|
||||||
print(f"Streaming to: {rtmp_url}")
|
ffmpeg_cmd.insert(2, '-stream_loop')
|
||||||
print(f"FFmpeg command: {' '.join(ffmpeg_cmd)}")
|
ffmpeg_cmd.insert(3, '-1')
|
||||||
print("\nPress Ctrl+C to stop streaming...\n")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Start ffmpeg process
|
|
||||||
process = subprocess.Popen(
|
process = subprocess.Popen(
|
||||||
ffmpeg_cmd,
|
ffmpeg_cmd,
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
@@ -72,7 +102,6 @@ def stream_m3u_to_rtmp(m3u_url, rtmp_url, ffmpeg_path='ffmpeg'):
|
|||||||
if line:
|
if line:
|
||||||
print(line.strip())
|
print(line.strip())
|
||||||
|
|
||||||
# Monitor both stdout and stderr
|
|
||||||
import threading
|
import threading
|
||||||
stdout_thread = threading.Thread(target=print_output, args=(process.stdout,))
|
stdout_thread = threading.Thread(target=print_output, args=(process.stdout,))
|
||||||
stderr_thread = threading.Thread(target=print_output, args=(process.stderr,))
|
stderr_thread = threading.Thread(target=print_output, args=(process.stderr,))
|
||||||
@@ -82,16 +111,13 @@ def stream_m3u_to_rtmp(m3u_url, rtmp_url, ffmpeg_path='ffmpeg'):
|
|||||||
stdout_thread.start()
|
stdout_thread.start()
|
||||||
stderr_thread.start()
|
stderr_thread.start()
|
||||||
|
|
||||||
# Wait for process to complete
|
|
||||||
process.wait()
|
process.wait()
|
||||||
|
|
||||||
if process.returncode != 0:
|
if process.returncode != 0:
|
||||||
print(f"\nError: FFmpeg exited with code {process.returncode}")
|
print(f"\nError: FFmpeg exited with code {process.returncode}")
|
||||||
return False
|
return False
|
||||||
else:
|
return True
|
||||||
print("\nStream ended successfully")
|
|
||||||
return True
|
|
||||||
|
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
print("\n\nStopping stream...")
|
print("\n\nStopping stream...")
|
||||||
if process:
|
if process:
|
||||||
@@ -102,9 +128,66 @@ def stream_m3u_to_rtmp(m3u_url, rtmp_url, ffmpeg_path='ffmpeg'):
|
|||||||
process.kill()
|
process.kill()
|
||||||
print("Stream stopped")
|
print("Stream stopped")
|
||||||
return False
|
return False
|
||||||
except FileNotFoundError:
|
except Exception as e:
|
||||||
print(f"Error: FFmpeg not found at '{ffmpeg_path}'")
|
print(f"Error: {e}")
|
||||||
print("Please install FFmpeg or provide the correct path")
|
return False
|
||||||
|
|
||||||
|
def stream_m3u_to_rtmp(m3u_path, rtmp_url, ffmpeg_path='ffmpeg', loop_playlist=False, loop_video=False):
|
||||||
|
"""
|
||||||
|
Stream M3U playlist to RTMP server without re-encoding.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
m3u_path: Path to M3U playlist file
|
||||||
|
rtmp_url: RTMP server URL (e.g., rtmp://server/live/stream_key)
|
||||||
|
ffmpeg_path: Path to ffmpeg executable (default: 'ffmpeg')
|
||||||
|
loop_playlist: Loop through the entire playlist when finished
|
||||||
|
loop_video: Loop each individual video
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Convert to absolute path
|
||||||
|
m3u_path = os.path.abspath(m3u_path)
|
||||||
|
if not os.path.exists(m3u_path):
|
||||||
|
print(f"Error: M3U playlist file not found: {m3u_path}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
print(f"Parsing M3U playlist: {m3u_path}")
|
||||||
|
videos = parse_m3u_playlist(m3u_path)
|
||||||
|
|
||||||
|
if not videos:
|
||||||
|
print("Error: No videos found in M3U playlist")
|
||||||
|
return False
|
||||||
|
|
||||||
|
print(f"Found {len(videos)} video(s) in playlist:")
|
||||||
|
for i, video in enumerate(videos, 1):
|
||||||
|
print(f" {i}. {video['name']} ({video['path']})")
|
||||||
|
|
||||||
|
print(f"\nStreaming to: {rtmp_url}")
|
||||||
|
print("Press Ctrl+C to stop streaming...\n")
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
for i, video in enumerate(videos, 1):
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f"Streaming video {i}/{len(videos)}: {video['name']}")
|
||||||
|
print(f"File: {video['path']}")
|
||||||
|
print(f"{'='*60}\n")
|
||||||
|
|
||||||
|
success = stream_video_to_rtmp(video['path'], rtmp_url, ffmpeg_path, loop_video)
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
print(f"Failed to stream: {video['name']}")
|
||||||
|
if not loop_playlist:
|
||||||
|
return False
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not loop_playlist:
|
||||||
|
print("\nPlaylist finished streaming")
|
||||||
|
return True
|
||||||
|
|
||||||
|
print("\nRestarting playlist...")
|
||||||
|
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\n\nStreaming stopped by user")
|
||||||
return False
|
return False
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error: {e}")
|
print(f"Error: {e}")
|
||||||
@@ -112,11 +195,11 @@ def stream_m3u_to_rtmp(m3u_url, rtmp_url, ffmpeg_path='ffmpeg'):
|
|||||||
|
|
||||||
def main():
|
def main():
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description='Stream M3U8 (HLS) live stream to RTMP server without re-encoding'
|
description='Stream M3U playlist files to RTMP server without re-encoding'
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
'm3u_url',
|
'm3u_file',
|
||||||
help='URL or path to M3U8 playlist file (supports both local files and HTTP/HTTPS URLs)'
|
help='Path to M3U playlist file'
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
'rtmp_url',
|
'rtmp_url',
|
||||||
@@ -127,12 +210,27 @@ def main():
|
|||||||
default='ffmpeg',
|
default='ffmpeg',
|
||||||
help='Path to ffmpeg executable (default: ffmpeg)'
|
help='Path to ffmpeg executable (default: ffmpeg)'
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--loop-playlist',
|
||||||
|
action='store_true',
|
||||||
|
help='Loop through the entire playlist when finished'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--loop-video',
|
||||||
|
action='store_true',
|
||||||
|
help='Loop each individual video file'
|
||||||
|
)
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
success = stream_m3u_to_rtmp(args.m3u_url, args.rtmp_url, args.ffmpeg)
|
success = stream_m3u_to_rtmp(
|
||||||
|
args.m3u_file,
|
||||||
|
args.rtmp_url,
|
||||||
|
args.ffmpeg,
|
||||||
|
args.loop_playlist,
|
||||||
|
args.loop_video
|
||||||
|
)
|
||||||
sys.exit(0 if success else 1)
|
sys.exit(0 if success else 1)
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
main()
|
main()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user