package downloader import ( "bufio" "bytes" "context" "errors" "fmt" "io" "net/http" "net/url" "os" "os/exec" "path" "path/filepath" "regexp" "sort" "strings" "time" "github.com/PuerkitoBio/goquery" "github.com/grafov/m3u8" ) const ( defaultClientTimeout = 30 * time.Second vodSegmentTimeout = 5 * time.Minute // Longer timeout for VOD segments which can be large maxRedirects = 5 playlistPollInterval = 2 * time.Second // Poll interval for livestreams ) var ( errInvalidPlaylist = errors.New("unsupported playlist type. expected media playlist") errUnsupportedMaster = errors.New("master playlist contains no playable variants") errFFmpegMissing = errors.New("ffmpeg is required on PATH to transmux segments") errNoStreamsFound = errors.New("no HLS playlists found on page") ) type segmentInfo struct { Sequence uint64 URI string } // Download downloads the video stream(s) referenced by the given URL into files derived from outputName. // If outputName is empty, a base name is inferred from the source URL. func Download(ctx context.Context, sourceURL, outputName string) error { if sourceURL == "" { return errors.New("stream URL must not be empty") } parsed, err := url.Parse(sourceURL) if err != nil { return fmt.Errorf("invalid URL: %w", err) } client := &http.Client{Timeout: defaultClientTimeout} contentType, err := peekContentType(ctx, client, parsed) if err != nil { return fmt.Errorf("detect content type: %w", err) } if strings.Contains(contentType, "text/html") { streams, err := extractPlaylistsFromHTML(ctx, client, parsed) if err != nil { return err } if len(streams) == 0 { return errNoStreamsFound } for idx, stream := range streams { name := outputName if name == "" { name = inferOutputNameMust(stream) } if len(streams) > 1 { ext := filepath.Ext(name) base := strings.TrimSuffix(name, ext) if base == "" { base = fmt.Sprintf("video-%d", idx+1) } name = base + fmt.Sprintf("-%02d", idx+1) + ext } if err := Download(ctx, stream, name); err != nil { return err } } return nil } // Use the original context for VOD downloads // For livestreams, a cancellable context will be created in downloadPlaylist return downloadPlaylist(ctx, client, parsed, outputName) } // watchForQuitKey watches for "q" keypress and cancels the context func watchForQuitKey(cancel context.CancelFunc) { // Check if stdin is a terminal if !isTerminal(os.Stdin) { return // Not a terminal, skip keyboard input } // Print instruction once fmt.Fprintf(os.Stderr, "Press 'q' and Enter to stop downloading and convert to MP4\n") reader := bufio.NewReader(os.Stdin) for { line, err := reader.ReadString('\n') if err != nil { // EOF or error, stop watching return } // Check if line starts with 'q' or 'Q' (allows "q" or "q\n" or "quit\n", etc.) line = strings.TrimSpace(line) if len(line) > 0 && (line[0] == 'q' || line[0] == 'Q') { fmt.Fprintf(os.Stderr, "\nStopping download and converting to MP4...\n") cancel() return } } } // isTerminal checks if the file descriptor is a terminal func isTerminal(f *os.File) bool { stat, err := f.Stat() if err != nil { return false } return (stat.Mode() & os.ModeCharDevice) != 0 } func downloadPlaylist(ctx context.Context, client *http.Client, parsed *url.URL, outputName string) error { body, finalURL, err := fetchWithRedirects(ctx, client, parsed, maxRedirects) if err != nil { return fmt.Errorf("fetch playlist: %w", err) } defer body.Close() playlist, listType, err := m3u8.DecodeFrom(bufio.NewReader(body), true) if err != nil { return fmt.Errorf("parse playlist: %w", err) } var mediaPlaylist *m3u8.MediaPlaylist switch listType { case m3u8.MEDIA: mp, ok := playlist.(*m3u8.MediaPlaylist) if !ok { return errInvalidPlaylist } mediaPlaylist = mp case m3u8.MASTER: masterPlaylist, ok := playlist.(*m3u8.MasterPlaylist) if !ok { return errInvalidPlaylist } variantURL, err := selectVariant(masterPlaylist, finalURL) if err != nil { return err } return Download(ctx, variantURL, outputName) default: return errInvalidPlaylist } if len(mediaPlaylist.Segments) == 0 { return errors.New("playlist contains no segments") } name := outputName if name == "" { name = inferOutputName(finalURL) } // Check if this is a livestream (playlist is not closed) isLiveStream := !mediaPlaylist.Closed if isLiveStream { // For livestreams, create a cancellable context for keyboard input liveCtx, cancel := context.WithCancel(ctx) defer cancel() // Start keyboard input handler in a goroutine for livestreams only go watchForQuitKey(cancel) // For livestreams, write to a permanent .ts file tsName := ensureTSExtension(name) tsFile, err := os.Create(tsName) if err != nil { return fmt.Errorf("create output file: %w", err) } // downloadLiveStream handles file closing return downloadLiveStream(liveCtx, client, finalURL, mediaPlaylist, tsFile, name) } // For VOD (Video on Demand), download segments and use ffmpeg concat demuxer // Create a context without deadline for VOD downloads to avoid premature cancellation // Preserve cancellation from parent context but remove any deadline vodCtx, vodCancel := context.WithCancel(context.Background()) defer vodCancel() // If parent context is cancelled, cancel VOD context too go func() { <-ctx.Done() vodCancel() }() // Create a client with longer timeout for VOD segments vodClient := &http.Client{Timeout: vodSegmentTimeout} // Create temp directory for segments tempDir, err := os.MkdirTemp("", "sdl-segments-*") if err != nil { return fmt.Errorf("create temp directory: %w", err) } defer os.RemoveAll(tempDir) segments := collectSegments(mediaPlaylist) totalSegments := len(segments) fmt.Fprintf(os.Stderr, "Downloading %d segments...\n", totalSegments) // Download each segment to a separate file segmentFiles := make([]string, 0, totalSegments) for i, segment := range segments { segmentFile := filepath.Join(tempDir, fmt.Sprintf("segment-%05d.ts", i)) file, err := os.Create(segmentFile) if err != nil { return fmt.Errorf("create segment file %d: %w", i, err) } if err := downloadSegment(vodCtx, vodClient, finalURL, segment, file); err != nil { file.Close() return fmt.Errorf("download segment %d: %w", i, err) } if err := file.Sync(); err != nil { file.Close() return fmt.Errorf("sync segment file %d: %w", i, err) } if err := file.Close(); err != nil { return fmt.Errorf("close segment file %d: %w", i, err) } segmentFiles = append(segmentFiles, segmentFile) // Show progress: segment number, total, and percentage progress := float64(i+1) / float64(totalSegments) * 100 fmt.Fprintf(os.Stderr, "\rProgress: %d/%d segments (%.1f%%)", i+1, totalSegments, progress) os.Stderr.Sync() // Flush progress output } fmt.Fprintf(os.Stderr, "\n") // Create concat file for ffmpeg concat protocol (not demuxer) // The concat protocol format is different: just file paths, one per line concatFile := filepath.Join(tempDir, "concat.txt") concatF, err := os.Create(concatFile) if err != nil { return fmt.Errorf("create concat file: %w", err) } for _, segFile := range segmentFiles { // Use absolute path for concat protocol absPath, err := filepath.Abs(segFile) if err != nil { concatF.Close() return fmt.Errorf("get absolute path: %w", err) } // Concat protocol format: just the path, one per line fmt.Fprintf(concatF, "%s\n", absPath) } if err := concatF.Close(); err != nil { return fmt.Errorf("close concat file: %w", err) } mp4Name := ensureMP4Extension(name) fmt.Fprintf(os.Stderr, "Converting to MP4...\n") if err := transmuxToMP4FromConcatProtocol(vodCtx, concatFile, mp4Name); err != nil { return err } fmt.Fprintf(os.Stderr, "Complete: %s\n", mp4Name) return nil } func downloadLiveStream(ctx context.Context, client *http.Client, playlistURL *url.URL, initialPlaylist *m3u8.MediaPlaylist, output *os.File, outputName string) error { // Track downloaded segments by sequence number downloadedSeqs := make(map[uint64]bool) tsPath := output.Name() // Download initial segments initialSegments := collectSegments(initialPlaylist) for _, segment := range initialSegments { if err := downloadSegment(ctx, client, playlistURL, segment, output); err != nil { return fmt.Errorf("download initial segment %d: %w", segment.Sequence, err) } // Sync after each segment to ensure data is written to disk if err := output.Sync(); err != nil { return fmt.Errorf("sync file after segment %d: %w", segment.Sequence, err) } downloadedSeqs[segment.Sequence] = true } // Continuously poll for new segments ticker := time.NewTicker(playlistPollInterval) defer ticker.Stop() for { select { case <-ctx.Done(): // Context cancelled (Ctrl+C or "q" key) - finalize and convert return finalizeAndConvert(output, tsPath, outputName) case <-ticker.C: // Fetch updated playlist body, _, err := fetchWithRedirects(ctx, client, playlistURL, maxRedirects) if err != nil { // If we can't fetch the playlist, continue trying continue } playlist, listType, err := m3u8.DecodeFrom(bufio.NewReader(body), true) body.Close() if err != nil { continue } if listType != m3u8.MEDIA { continue } mediaPlaylist, ok := playlist.(*m3u8.MediaPlaylist) if !ok { continue } // Check if stream has ended if mediaPlaylist.Closed { // Download any remaining segments segments := collectSegments(mediaPlaylist) for _, segment := range segments { if !downloadedSeqs[segment.Sequence] { if err := downloadSegment(ctx, client, playlistURL, segment, output); err != nil { // Log error but continue with finalization continue } // Sync after each segment if err := output.Sync(); err != nil { continue } downloadedSeqs[segment.Sequence] = true } } // Stream ended - finalize and convert return finalizeAndConvert(output, tsPath, outputName) } // Download new segments segments := collectSegments(mediaPlaylist) for _, segment := range segments { if !downloadedSeqs[segment.Sequence] { if err := downloadSegment(ctx, client, playlistURL, segment, output); err != nil { // Log error but continue polling continue } // Sync after each segment to ensure data is written to disk if err := output.Sync(); err != nil { // Log error but continue continue } downloadedSeqs[segment.Sequence] = true } } } } } func finalizeOutput(ctx context.Context, tempPath, outputName string) error { mp4Name := ensureMP4Extension(outputName) if err := transmuxToMP4(ctx, tempPath, mp4Name); err != nil { return err } // Clean up temp file after successful transmux os.Remove(tempPath) return nil } func finalizeAndConvert(output *os.File, tsPath, outputName string) error { // Sync and close the file before conversion output.Sync() // Flush any pending writes output.Close() // Convert to MP4 fmt.Fprintf(os.Stderr, "Converting to MP4...\n") if err := convertTSToMP4(context.Background(), tsPath, outputName); err != nil { // Don't fail if conversion fails - the .ts file is still valid fmt.Fprintf(os.Stderr, "Warning: MP4 conversion failed, but .ts file is saved: %v\n", err) return nil } mp4Name := ensureMP4Extension(outputName) fmt.Fprintf(os.Stderr, "Conversion complete: %s\n", mp4Name) return nil } func convertTSToMP4(ctx context.Context, tsPath, outputName string) error { // Only convert if ffmpeg is available, but don't fail if it's not if _, err := exec.LookPath("ffmpeg"); err != nil { return fmt.Errorf("ffmpeg not found in PATH") } mp4Name := ensureMP4Extension(outputName) // Use background context for conversion to ensure it completes even if main context is cancelled if err := transmuxToMP4(ctx, tsPath, mp4Name); err != nil { return err } return nil } func peekContentType(ctx context.Context, client *http.Client, target *url.URL) (string, error) { req, err := http.NewRequestWithContext(ctx, http.MethodHead, target.String(), nil) if err != nil { return "", fmt.Errorf("create HEAD request: %w", err) } resp, err := client.Do(req) if err != nil { return "", fmt.Errorf("head request: %w", err) } defer resp.Body.Close() if resp.StatusCode >= 400 { return "", fmt.Errorf("head status %s", resp.Status) } return resp.Header.Get("Content-Type"), nil } func extractPlaylistsFromHTML(ctx context.Context, client *http.Client, target *url.URL) ([]string, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, target.String(), nil) if err != nil { return nil, fmt.Errorf("create request: %w", err) } resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("fetch page: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("unexpected status %s", resp.Status) } doc, err := goquery.NewDocumentFromReader(resp.Body) if err != nil { return nil, fmt.Errorf("parse html: %w", err) } results := make([]string, 0) doc.Find("video,source").Each(func(_ int, sel *goquery.Selection) { if src, ok := sel.Attr("src"); ok { if resolved := resolveURL(target, src); resolved != "" { results = append(results, resolved) } } }) attrPatterns := []string{"data-src", "data-hls", "data-hls-src"} doc.Find("*[data-src], *[data-hls], *[data-hls-src]").Each(func(_ int, sel *goquery.Selection) { for _, attr := range attrPatterns { if val, ok := sel.Attr(attr); ok { if resolved := resolveURL(target, val); resolved != "" { results = append(results, resolved) } } } }) hrefPattern := regexp.MustCompile(`(?i)\.m3u8(\?.*)?$`) doc.Find("a[href], link[href]").Each(func(_ int, sel *goquery.Selection) { if href, ok := sel.Attr("href"); ok && hrefPattern.MatchString(href) { if resolved := resolveURL(target, href); resolved != "" { results = append(results, resolved) } } }) results = append(results, findPlaylistInScripts(target, doc)...) uniq := uniqueStrings(results) return uniq, nil } func resolveURL(baseURL *url.URL, ref string) string { if ref == "" { return "" } if strings.HasPrefix(ref, "data:") { return "" } parsed, err := baseURL.Parse(ref) if err != nil { return "" } return parsed.String() } func findPlaylistInScripts(baseURL *url.URL, doc *goquery.Document) []string { var results []string pattern := regexp.MustCompile(`https?://[^"'\\\s]+\.m3u8[^"'\\\s]*`) doc.Find("script").Each(func(_ int, sel *goquery.Selection) { text := sel.Text() for _, match := range pattern.FindAllString(text, -1) { if resolved := resolveURL(baseURL, match); resolved != "" { results = append(results, resolved) } } }) return results } func uniqueStrings(in []string) []string { seen := make(map[string]struct{}, len(in)) out := make([]string, 0, len(in)) for _, item := range in { if item == "" { continue } if _, exists := seen[item]; exists { continue } seen[item] = struct{}{} out = append(out, item) } return out } func collectSegments(playlist *m3u8.MediaPlaylist) []segmentInfo { segments := make([]segmentInfo, 0, len(playlist.Segments)) for _, segment := range playlist.Segments { if segment == nil || segment.URI == "" { continue } segments = append(segments, segmentInfo{Sequence: segment.SeqId, URI: segment.URI}) } sort.Slice(segments, func(i, j int) bool { return segments[i].Sequence < segments[j].Sequence }) return segments } func selectVariant(master *m3u8.MasterPlaylist, base *url.URL) (string, error) { var chosen *m3u8.Variant for _, variant := range master.Variants { if variant == nil || variant.URI == "" { continue } if chosen == nil || variant.Bandwidth > chosen.Bandwidth { chosen = variant } } if chosen == nil { return "", errUnsupportedMaster } resolved, err := base.Parse(chosen.URI) if err != nil { return "", fmt.Errorf("parse variant URL: %w", err) } return resolved.String(), nil } func downloadSegment(ctx context.Context, client *http.Client, base *url.URL, segment segmentInfo, output io.Writer) error { segmentURL, err := base.Parse(segment.URI) if err != nil { return fmt.Errorf("parse segment URL: %w", err) } req, err := http.NewRequestWithContext(ctx, http.MethodGet, segmentURL.String(), nil) if err != nil { return fmt.Errorf("create request: %w", err) } resp, err := client.Do(req) if err != nil { return fmt.Errorf("fetch segment: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("unexpected status %s", resp.Status) } if _, err := io.Copy(output, resp.Body); err != nil { return fmt.Errorf("write segment: %w", err) } return nil } func fetchWithRedirects(ctx context.Context, client *http.Client, streamURL *url.URL, redirects int) (io.ReadCloser, *url.URL, error) { currentURL := streamURL for count := 0; count <= redirects; count++ { req, err := http.NewRequestWithContext(ctx, http.MethodGet, currentURL.String(), nil) if err != nil { return nil, nil, fmt.Errorf("create request: %w", err) } resp, err := client.Do(req) if err != nil { return nil, nil, fmt.Errorf("http get: %w", err) } switch { case resp.StatusCode >= 300 && resp.StatusCode < 400: location := resp.Header.Get("Location") resp.Body.Close() if location == "" { return nil, nil, errors.New("redirect without location header") } nextURL, err := currentURL.Parse(location) if err != nil { return nil, nil, fmt.Errorf("parse redirect URL: %w", err) } currentURL = nextURL continue case resp.StatusCode == http.StatusOK: return resp.Body, currentURL, nil default: resp.Body.Close() return nil, nil, fmt.Errorf("unexpected status %s", resp.Status) } } return nil, nil, errors.New("too many redirects") } func inferOutputName(streamURL *url.URL) string { base := path.Base(streamURL.Path) if base == "." || base == "/" || base == "" { base = "download" } trimmed := strings.TrimSuffix(base, path.Ext(base)) if trimmed == "" { trimmed = "download" } return trimmed + ".mp4" } func ensureMP4Extension(name string) string { lower := strings.ToLower(name) if strings.HasSuffix(lower, ".mp4") { return name } ext := filepath.Ext(name) trimmed := strings.TrimSuffix(name, ext) if trimmed == "" { trimmed = "output" } return trimmed + ".mp4" } func ensureTSExtension(name string) string { lower := strings.ToLower(name) if strings.HasSuffix(lower, ".ts") { return name } ext := filepath.Ext(name) trimmed := strings.TrimSuffix(name, ext) if trimmed == "" { trimmed = "output" } return trimmed + ".ts" } func inferOutputNameMust(rawURL string) string { parsed, err := url.Parse(rawURL) if err != nil { return ensureMP4Extension("download") } return inferOutputName(parsed) } func transmuxToMP4FromConcatProtocol(ctx context.Context, concatFile, mp4Path string) error { if _, err := exec.LookPath("ffmpeg"); err != nil { return errFFmpegMissing } if err := os.MkdirAll(filepath.Dir(mp4Path), 0o755); err != nil { return fmt.Errorf("ensure output directory: %w", err) } // Use ffmpeg concat protocol (not demuxer) - this is designed for TS files // Format: concat:file1.ts|file2.ts|file3.ts // Or we can use the file list with concat protocol absConcatFile, err := filepath.Abs(concatFile) if err != nil { return fmt.Errorf("get absolute path for concat file: %w", err) } // Read the concat file to build the concat protocol string concatContent, err := os.ReadFile(absConcatFile) if err != nil { return fmt.Errorf("read concat file: %w", err) } lines := strings.Split(string(concatContent), "\n") var segmentPaths []string for _, line := range lines { line = strings.TrimSpace(line) if line != "" { segmentPaths = append(segmentPaths, line) } } if len(segmentPaths) == 0 { return fmt.Errorf("no segments found in concat file") } // Build concat protocol string: concat:file1.ts|file2.ts|... concatInput := "concat:" + strings.Join(segmentPaths, "|") // Use concat protocol with better flags for TS files cmd := exec.CommandContext(ctx, "ffmpeg", "-y", "-i", concatInput, "-c", "copy", "-movflags", "+faststart", mp4Path) cmd.Stdout = io.Discard var stderr bytes.Buffer cmd.Stderr = &stderr if err := cmd.Run(); err != nil { // If copy fails, try with re-encoding msg := strings.TrimSpace(stderr.String()) needsReencode := strings.Contains(msg, "could not find corresponding") || strings.Contains(msg, "error reading header") || strings.Contains(msg, "Invalid data") || strings.Contains(msg, "Invalid argument") if needsReencode { fmt.Fprintf(os.Stderr, "Copy mode failed, trying re-encode...\n") cmd2 := exec.CommandContext(ctx, "ffmpeg", "-y", "-i", concatInput, "-c:v", "libx264", "-preset", "medium", "-crf", "23", "-c:a", "aac", "-b:a", "128k", "-movflags", "+faststart", mp4Path) cmd2.Stdout = io.Discard var stderr2 bytes.Buffer cmd2.Stderr = &stderr2 if err2 := cmd2.Run(); err2 != nil { os.Remove(mp4Path) msg2 := strings.TrimSpace(stderr2.String()) if msg2 != "" { return fmt.Errorf("ffmpeg (re-encode): %s", msg2) } return fmt.Errorf("ffmpeg (re-encode): %w", err2) } return nil } os.Remove(mp4Path) if msg != "" { return fmt.Errorf("ffmpeg: %s", msg) } return fmt.Errorf("ffmpeg: %w", err) } return nil } func transmuxToMP4FromConcat(ctx context.Context, concatFile, mp4Path string) error { if _, err := exec.LookPath("ffmpeg"); err != nil { return errFFmpegMissing } if err := os.MkdirAll(filepath.Dir(mp4Path), 0o755); err != nil { return fmt.Errorf("ensure output directory: %w", err) } // Verify concat file exists and is readable if _, err := os.Stat(concatFile); err != nil { return fmt.Errorf("concat file not found: %w", err) } // Read concat file to verify format concatContent, err := os.ReadFile(concatFile) if err != nil { return fmt.Errorf("read concat file: %w", err) } // Debug: show first few lines of concat file if it's small if len(concatContent) < 500 { fmt.Fprintf(os.Stderr, "Concat file content:\n%s\n", string(concatContent)) } // Use ffmpeg concat demuxer to properly combine segments // This handles TS metadata correctly // Add -avoid_negative_ts make_zero to handle timestamp issues // Use absolute path for concat file absConcatFile, err := filepath.Abs(concatFile) if err != nil { return fmt.Errorf("get absolute path for concat file: %w", err) } cmd := exec.CommandContext(ctx, "ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", absConcatFile, "-avoid_negative_ts", "make_zero", "-c", "copy", "-movflags", "+faststart", mp4Path) cmd.Stdout = io.Discard var stderr bytes.Buffer cmd.Stderr = &stderr if err := cmd.Run(); err != nil { // If copy fails, try with re-encoding as fallback msg := strings.TrimSpace(stderr.String()) // Check for various error patterns needsReencode := strings.Contains(msg, "could not find corresponding") || strings.Contains(msg, "error reading header") || strings.Contains(msg, "Invalid data") || strings.Contains(msg, "Invalid argument") || strings.Contains(msg, "No such file") if needsReencode { // Try re-encoding instead of copy fmt.Fprintf(os.Stderr, "Copy mode failed, trying re-encode...\n") cmd2 := exec.CommandContext(ctx, "ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", absConcatFile, "-avoid_negative_ts", "make_zero", "-fflags", "+genpts", "-c:v", "libx264", "-preset", "medium", "-crf", "23", "-c:a", "aac", "-b:a", "128k", "-movflags", "+faststart", mp4Path) cmd2.Stdout = io.Discard var stderr2 bytes.Buffer cmd2.Stderr = &stderr2 if err2 := cmd2.Run(); err2 != nil { // If concat demuxer completely fails, try using individual segments as input fmt.Fprintf(os.Stderr, "Concat demuxer failed, trying direct segment input...\n") // Get segment files from concat file lines := strings.Split(string(concatContent), "\n") var segmentPaths []string for _, line := range lines { line = strings.TrimSpace(line) if strings.HasPrefix(line, "file '") && strings.HasSuffix(line, "'") { path := strings.TrimPrefix(strings.TrimSuffix(line, "'"), "file '") path = strings.ReplaceAll(path, "''", "'") // Unescape segmentPaths = append(segmentPaths, path) } } if len(segmentPaths) > 0 { // Try with first segment to test cmd3 := exec.CommandContext(ctx, "ffmpeg", "-y", "-i", segmentPaths[0], "-avoid_negative_ts", "make_zero", "-fflags", "+genpts", "-c:v", "libx264", "-preset", "medium", "-crf", "23", "-c:a", "aac", "-b:a", "128k", "-movflags", "+faststart", mp4Path) cmd3.Stdout = io.Discard var stderr3 bytes.Buffer cmd3.Stderr = &stderr3 if err3 := cmd3.Run(); err3 != nil { os.Remove(mp4Path) return fmt.Errorf("ffmpeg failed with all methods. Last error: %s", strings.TrimSpace(stderr3.String())) } return nil } os.Remove(mp4Path) msg2 := strings.TrimSpace(stderr2.String()) if msg2 != "" { return fmt.Errorf("ffmpeg (re-encode): %s", msg2) } return fmt.Errorf("ffmpeg (re-encode): %w", err2) } return nil } os.Remove(mp4Path) if msg != "" { return fmt.Errorf("ffmpeg: %s", msg) } return fmt.Errorf("ffmpeg: %w", err) } return nil } func transmuxToMP4(ctx context.Context, tsPath, mp4Path string) error { if _, err := exec.LookPath("ffmpeg"); err != nil { return errFFmpegMissing } if err := os.MkdirAll(filepath.Dir(mp4Path), 0o755); err != nil { return fmt.Errorf("ensure output directory: %w", err) } // Try with copy first (fastest), fall back to re-encoding if needed // Use -fflags +genpts to generate presentation timestamps if missing // Use -err_detect ignore_err to be more tolerant of minor errors // Use -avoid_negative_ts make_zero to handle timestamp issues cmd := exec.CommandContext(ctx, "ffmpeg", "-y", "-fflags", "+genpts+igndts", "-err_detect", "ignore_err", "-i", tsPath, "-avoid_negative_ts", "make_zero", "-c", "copy", "-movflags", "+faststart", mp4Path) cmd.Stdout = io.Discard var stderr bytes.Buffer cmd.Stderr = &stderr if err := cmd.Run(); err != nil { // If copy fails, try with re-encoding as fallback msg := strings.TrimSpace(stderr.String()) needsReencode := strings.Contains(msg, "could not find corresponding") || strings.Contains(msg, "error reading header") || strings.Contains(msg, "Invalid data") || strings.Contains(msg, "Invalid argument") if needsReencode { // Try re-encoding instead of copy fmt.Fprintf(os.Stderr, "Copy mode failed, trying re-encode...\n") cmd2 := exec.CommandContext(ctx, "ffmpeg", "-y", "-fflags", "+genpts+igndts", "-err_detect", "ignore_err", "-i", tsPath, "-avoid_negative_ts", "make_zero", "-c:v", "libx264", "-preset", "medium", "-crf", "23", "-c:a", "aac", "-b:a", "128k", "-movflags", "+faststart", mp4Path) cmd2.Stdout = io.Discard var stderr2 bytes.Buffer cmd2.Stderr = &stderr2 if err2 := cmd2.Run(); err2 != nil { os.Remove(mp4Path) msg2 := strings.TrimSpace(stderr2.String()) if msg2 != "" { return fmt.Errorf("ffmpeg (re-encode): %s", msg2) } return fmt.Errorf("ffmpeg (re-encode): %w", err2) } return nil } os.Remove(mp4Path) if msg != "" { return fmt.Errorf("ffmpeg: %s", msg) } return fmt.Errorf("ffmpeg: %w", err) } return nil }