diff --git a/internal/downloader/downloader.go b/internal/downloader/downloader.go index 9636e1a..1191377 100644 --- a/internal/downloader/downloader.go +++ b/internal/downloader/downloader.go @@ -193,7 +193,7 @@ func downloadPlaylist(ctx context.Context, client *http.Client, parsed *url.URL, return downloadLiveStream(liveCtx, client, finalURL, mediaPlaylist, tsFile, name) } - // For VOD (Video on Demand), use temp file and convert to MP4 + // 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()) @@ -208,6 +208,7 @@ func downloadPlaylist(ctx context.Context, client *http.Client, parsed *url.URL, // Create a client with longer timeout for VOD segments vodClient := &http.Client{Timeout: vodSegmentTimeout} + // Create temp file for concatenated segments tempTS, err := os.CreateTemp("", "sdl-*.ts") if err != nil { return fmt.Errorf("create temp file: %w", err) @@ -219,11 +220,17 @@ func downloadPlaylist(ctx context.Context, client *http.Client, parsed *url.URL, totalSegments := len(segments) fmt.Fprintf(os.Stderr, "Downloading %d segments...\n", totalSegments) + // Download and concatenate segments directly for i, segment := range segments { if err := downloadSegment(vodCtx, vodClient, finalURL, segment, tempTS); err != nil { tempTS.Close() return fmt.Errorf("download segment %d: %w", i, err) } + // Flush after each segment to ensure data is written + if err := tempTS.Sync(); err != nil { + tempTS.Close() + return fmt.Errorf("sync after segment %d: %w", i, err) + } // 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) @@ -231,12 +238,6 @@ func downloadPlaylist(ctx context.Context, client *http.Client, parsed *url.URL, } fmt.Fprintf(os.Stderr, "\n") - // Flush and sync the file to ensure all data is written to disk - if err := tempTS.Sync(); err != nil { - tempTS.Close() - return fmt.Errorf("sync temp file: %w", err) - } - // Close the file before conversion if err := tempTS.Close(); err != nil { return fmt.Errorf("close temp file: %w", err) @@ -663,6 +664,138 @@ func inferOutputNameMust(rawURL string) string { return inferOutputName(parsed) } +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 @@ -675,11 +808,13 @@ func transmuxToMP4(ctx context.Context, tsPath, mp4Path string) error { // 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", + "-fflags", "+genpts+igndts", "-err_detect", "ignore_err", "-i", tsPath, + "-avoid_negative_ts", "make_zero", "-c", "copy", "-movflags", "+faststart", mp4Path) @@ -690,16 +825,25 @@ func transmuxToMP4(ctx context.Context, tsPath, mp4Path string) error { if err := cmd.Run(); err != nil { // If copy fails, try with re-encoding as fallback msg := strings.TrimSpace(stderr.String()) - if strings.Contains(msg, "could not find corresponding") || strings.Contains(msg, "error reading header") { + 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", + "-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