Rewrite to support M3U playlists instead of M3U8 HLS streams

This commit is contained in:
Mathieu Moran
2026-01-11 23:37:00 -05:00
parent 5a2f560f82
commit efc93f1520
2 changed files with 198 additions and 69 deletions
+52 -21
View File
@@ -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
+144 -46
View File
@@ -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()
# Check if it's a local file current_name = None
is_local_file = not (m3u_url.startswith('http://') or m3u_url.startswith('https://') or m3u_url.startswith('rtmp://')) for line in lines:
line = line.strip()
if not line or line.startswith('#EXTM3U'):
continue
if is_local_file: if line.startswith('#EXTINF'):
# Convert to absolute path # Extract name from #EXTINF line: #EXTINF:-1,Video Name
m3u_path = os.path.abspath(m3u_url) parts = line.split(',', 1)
if not os.path.exists(m3u_path): if len(parts) > 1:
print(f"Error: Local M3U8 file not found: {m3u_path}") current_name = parts[1].strip()
return False else:
print(f"Using local M3U8 file: {m3u_path}") current_name = None
m3u_input = m3u_path elif line and not line.startswith('#'):
else: # This is a file path
m3u_input = m3u_url 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)
# FFmpeg command for direct stream (no re-encoding) videos.append({
# -i: input M3U8 URL 'name': current_name or os.path.basename(video_path),
# -c copy: copy codecs without re-encoding 'path': video_path
# -f flv: output format FLV (required for RTMP) })
# -flvflags no_duration_filesize: avoid issues with FLV format current_name = None
# -re: read input at native frame rate (important for live streams) except Exception as e:
# -stream_loop -1: loop the stream indefinitely (for live streams, this might not be needed) print(f"Error parsing M3U file: {e}")
return []
return videos
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.
Args:
video_path: Path to video file
rtmp_url: RTMP server URL
ffmpeg_path: Path to ffmpeg executable
loop: Whether to loop the video
Returns:
True if successful, False otherwise
"""
if not os.path.exists(video_path):
print(f"Error: Video file not found: {video_path}")
return False
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,15 +111,12 @@ 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...")
@@ -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()