Downloading and Converting M3U8 Video Segments to MP4 Using Python

The following code demonstrates how to implement multi-threaded downloading of TS segments and merge them into an MP4 file using Python. The approach involves downloading TS segments via multiple threads based on the links, decrypting them using a key if necessary, and then merging them with FFmpeg into an MP4 file.1.Parse the M3U8 file to obtain video segment information, and decrypt if necessary.

 def parse_m3u8(self):        """Parse the M3U8 file to obtain video segment information"""        logger.info(f"Parsing M3U8 file: {self.m3u8_url}")        try:            response = requests.get(self.m3u8_url, headers=self.headers, timeout=30)            response.raise_for_status()            content = response.text        except Exception as e:            logger.error(f"Failed to download M3U8 file: {e}")            return False        # Check if the M3U8 is encrypted        is_encrypted = False        key_url = None        lines = content.strip().split('\n')        for i, line in enumerate(lines):            line = line.strip()            # Check for encryption information            if line.startswith('#EXT-X-KEY'):                is_encrypted = True                # Parse key information                match = re.search(r'METHOD=([^,]+)', line)                if match and match.group(1) != 'NONE':                    method = match.group(1)                    logger.info(f"Detected encryption method: {method}")                    # Get key URL                    key_match = re.search(r'URI="([^"]+)"', line)                    if key_match:                        key_url = key_match.group(1)                        if not key_url.startswith('http'):                            key_url = urljoin(self.m3u8_url, key_url)                        logger.info(f"Key URL: {key_url}")                    # Get IV                    iv_match = re.search(r'IV=([^,]+)', line)                    if iv_match:                        self.iv = iv_match.group(1).replace('0x', '')            # Get video segment URL            if line and not line.startswith('#'):                if not line.startswith('http'):                    line = urljoin(self.m3u8_url, line)                self.segments.append({                    'url': line,                    'index': len(self.segments),                    'encrypted': is_encrypted                })        # Download key        if is_encrypted and key_url and not self.key:            if not self.download_key(key_url):                return False        self.total_segments = len(self.segments)        logger.info(f"Parsing complete, total {self.total_segments} video segments")        return True

2. Extract the key from the link.

    def download_key(self, key_url):        """Download AES decryption key"""        logger.info(f"Downloading decryption key: {key_url}")        try:            response = requests.get(key_url, headers=self.headers, timeout=30)            response.raise_for_status()            self.key = response.content            logger.info(f"Key downloaded successfully, length: {len(self.key)} bytes")            return True        except Exception as e:            logger.error(f"Failed to download key: {e}")            return False

3. The main method for decryption.

    def decrypt_segment(self, data, segment_info):        """Decrypt video segment"""        if not self.key:            logger.error("No decryption key")            return None        try:            # Prepare IV            if self.iv:                iv = bytes.fromhex(self.iv)            else:                # If no IV is specified, use the segment index as IV                iv = segment_info['index'].to_bytes(16, byteorder='big')            # Create AES decryptor            cipher = AES.new(self.key, AES.MODE_CBC, iv)            # Decrypt data            decrypted_data = unpad(cipher.decrypt(data), AES.block_size)            return decrypted_data        except Exception as e:            logger.error(f"Decryption failed: {e}")            return None

4. Download all segments.

    def download_segment(self, segment_info):        """Download a single video segment"""        index = segment_info['index']        url = segment_info['url']        encrypted = segment_info['encrypted']        try:            # Download segment            response = requests.get(url, headers=self.headers, timeout=30)            response.raise_for_status()            data = response.content            # If decryption is needed            if encrypted and self.key:                data = self.decrypt_segment(data, segment_info)                if data is None:                    logger.error(f"Segment {index} decryption failed")                    return False            # Save segment            segment_path = os.path.join(self.output_dir, 'segments', f'segment_{index:06d}.ts')            with open(segment_path, 'wb') as f:                f.write(data)            self.downloaded_segments += 1            progress = (self.downloaded_segments / self.total_segments) * 100            logger.info(f"Download progress: {progress:.1f}% ({self.downloaded_segments}/{self.total_segments})")            return True        except Exception as e:            logger.error(f"Failed to download segment {index}: {e}")            return False    def download_all_segments(self):        """Download all video segments"""        logger.info(f"Starting to download video segments, concurrency: {self.max_workers}")        with ThreadPoolExecutor(max_workers=self.max_workers) as executor:            # Submit all download tasks            future_to_segment = {                executor.submit(self.download_segment, segment): segment                 for segment in self.segments            }            # Wait for all tasks to complete            failed_segments = []            for future in as_completed(future_to_segment):                segment = future_to_segment[future]                try:                    success = future.result()                    if not success:                        failed_segments.append(segment)                except Exception as e:                    logger.error(f"Download task exception: {e}")                    failed_segments.append(segment)        # Retry failed segments        if failed_segments:            logger.info(f"Retrying {len(failed_segments)} failed segments")            for segment in failed_segments:                self.download_segment(segment)        logger.info("All segments downloaded")

5. Useffmpeg to merge into MP4 and clean up segments.

    def merge_segments(self):        """Merge all segments into an MP4 file"""        logger.info("Starting to merge video segments")        output_path = os.path.join(self.output_dir, f"{self.output_name}.mp4")        segment_list_path = os.path.join(self.output_dir, 'segments', 'segment_list.txt')        # Create segment list file        with open(segment_list_path, 'w', encoding='utf-8') as f:            for i in range(self.total_segments):                segment_path = os.path.join(self.output_dir, 'segments', f'segment_{i:06d}.ts')                if os.path.exists(segment_path):                    f.write(f"file '{segment_path}'\n")        # Use ffmpeg to merge segments        try:            cmd = [                'ffmpeg',                '-f', 'concat',                '-safe', '0',                '-i', segment_list_path,                '-c', 'copy',                '-bsf:a', 'aac_adtstoasc',                '-y',                output_path            ]            result = subprocess.run(cmd, capture_output=True, text=True)            if result.returncode == 0:                logger.info(f"Video merged successfully: {output_path}")                return True            else:                logger.error(f"FFmpeg merge failed: {result.stderr}")                return False        except FileNotFoundError:            logger.error("FFmpeg not found, please install FFmpeg first")            logger.info("You can manually merge segments:")            logger.info(f"copy /b {os.path.join(self.output_dir, 'segments', '*.ts')} {output_path}")            return False    def clean_temp_files(self):        """Clean temporary files"""        logger.info("Cleaning temporary files")        segments_dir = os.path.join(self.output_dir, 'segments')        if os.path.exists(segments_dir):            for file in os.listdir(segments_dir):                file_path = os.path.join(segments_dir, file)                if os.path.isfile(file_path):                    os.remove(file_path)            os.rmdir(segments_dir)    def run(self, clean_temp=True):        """Run the complete download process"""        logger.info("="*50)        logger.info("M3U8 Video Downloader is starting")        logger.info("="*50)        start_time = time.time()        # Parse M3U8 file        if not self.parse_m3u8():            return False        # Download all segments        self.download_all_segments()        # Merge segments        if self.merge_segments():            # Clean temporary files            if clean_temp:                self.clean_temp_files()            end_time = time.time()            duration = end_time - start_time            logger.info("="*50)            logger.info(f"Download complete! Time taken: {duration:.1f} seconds")            logger.info(f"Output file: {os.path.join(self.output_dir, self.output_name)}.mp4")            logger.info("="*50)            return True        else:            logger.error("Video merge failed")            return False

Leave a Comment