diff --git a/README.md b/README.md index 48581a6..6f6b881 100644 --- a/README.md +++ b/README.md @@ -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 - Direct stream passthrough (no re-encoding) -- Supports M3U8/HLS playlists +- Supports M3U playlist files - Streams to RTMP servers - Real-time output monitoring +- Playlist looping support - Graceful shutdown on Ctrl+C ## Requirements @@ -29,34 +30,57 @@ Python script to stream M3U8 (HLS) live streams to RTMP servers using ffmpeg wit ### Basic Usage ```bash -python stream_m3u_to_rtmp.py +python stream_m3u_to_rtmp.py ``` ### Examples ```bash -# Stream from M3U8 URL to RTMP server +# Stream M3U playlist to RTMP server python stream_m3u_to_rtmp.py \ - "https://example.com/live/stream.m3u8" \ + "playlist.m3u" \ "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 \ - "playlist.m3u8" \ + "C:/path/to/playlist.m3u" \ "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 \ - "C:/path/to/playlist.m3u8" \ - "rtmp://server.com/live/stream_key" + "playlist.m3u" \ + "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 python stream_m3u_to_rtmp.py \ - "https://example.com/stream.m3u8" \ + "playlist.m3u" \ "rtmp://server.com/live/key" \ --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 URLs typically follow this format: @@ -69,19 +93,26 @@ Examples: - YouTube: `rtmp://a.rtmp.youtube.com/live2/YOUR_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 -The script uses ffmpeg with: -- `-c copy`: Copies all codecs without re-encoding (passthrough) -- `-f flv`: Outputs in FLV format (required for RTMP) -- `-re`: Reads input at native frame rate -- Direct stream from M3U8 to RTMP +The script: +1. Parses the M3U playlist file to extract video file paths +2. Streams each video sequentially to the RTMP server +3. Uses ffmpeg with `-c copy` to passthrough without re-encoding +4. Outputs in FLV format (required for RTMP) ## 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 -- 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`) ## Troubleshooting @@ -89,5 +120,5 @@ The script uses ffmpeg with: - **FFmpeg not found**: Install FFmpeg or use `--ffmpeg` to specify path - **Connection errors**: Check RTMP URL and network connectivity - **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 diff --git a/stream_m3u_to_rtmp.py b/stream_m3u_to_rtmp.py index c2325d4..8d1ed46 100644 --- a/stream_m3u_to_rtmp.py +++ b/stream_m3u_to_rtmp.py @@ -1,63 +1,93 @@ #!/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 sys -import signal import os 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: - m3u_url: URL or path to M3U8 playlist file (supports both local files and URLs) - rtmp_url: RTMP server URL (e.g., rtmp://server/live/stream_key) - ffmpeg_path: Path to ffmpeg executable (default: 'ffmpeg') + m3u_path: Path to M3U playlist file + + 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 - is_local_file = not (m3u_url.startswith('http://') or m3u_url.startswith('https://') or m3u_url.startswith('rtmp://')) + 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. - if is_local_file: - # Convert to absolute path - m3u_path = os.path.abspath(m3u_url) - if not os.path.exists(m3u_path): - print(f"Error: Local M3U8 file not found: {m3u_path}") - return False - print(f"Using local M3U8 file: {m3u_path}") - m3u_input = m3u_path - else: - m3u_input = m3u_url - - # FFmpeg command for direct stream (no re-encoding) - # -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) + 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_path, - '-i', m3u_input, + '-re', # Read input at native frame rate + '-i', video_path, '-c', 'copy', # Copy all streams without re-encoding '-f', 'flv', # RTMP requires FLV format '-flvflags', 'no_duration_filesize', - '-re', # Read input at native frame rate rtmp_url ] - print(f"Starting stream from: {m3u_url}") - print(f"Streaming to: {rtmp_url}") - print(f"FFmpeg command: {' '.join(ffmpeg_cmd)}") - print("\nPress Ctrl+C to stop streaming...\n") + if loop: + ffmpeg_cmd.insert(2, '-stream_loop') + ffmpeg_cmd.insert(3, '-1') try: - # Start ffmpeg process process = subprocess.Popen( ffmpeg_cmd, stdout=subprocess.PIPE, @@ -72,7 +102,6 @@ def stream_m3u_to_rtmp(m3u_url, rtmp_url, ffmpeg_path='ffmpeg'): if line: print(line.strip()) - # Monitor both stdout and stderr import threading stdout_thread = threading.Thread(target=print_output, args=(process.stdout,)) 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() stderr_thread.start() - # Wait for process to complete process.wait() if process.returncode != 0: print(f"\nError: FFmpeg exited with code {process.returncode}") return False - else: - print("\nStream ended successfully") - return True - + return True + except KeyboardInterrupt: print("\n\nStopping stream...") if process: @@ -102,9 +128,66 @@ def stream_m3u_to_rtmp(m3u_url, rtmp_url, ffmpeg_path='ffmpeg'): process.kill() print("Stream stopped") return False - except FileNotFoundError: - print(f"Error: FFmpeg not found at '{ffmpeg_path}'") - print("Please install FFmpeg or provide the correct path") + except Exception as e: + print(f"Error: {e}") + 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 except Exception as e: print(f"Error: {e}") @@ -112,11 +195,11 @@ def stream_m3u_to_rtmp(m3u_url, rtmp_url, ffmpeg_path='ffmpeg'): def main(): 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( - 'm3u_url', - help='URL or path to M3U8 playlist file (supports both local files and HTTP/HTTPS URLs)' + 'm3u_file', + help='Path to M3U playlist file' ) parser.add_argument( 'rtmp_url', @@ -127,12 +210,27 @@ def main(): 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() - 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) if __name__ == '__main__': main() -