diff --git a/internal/downloader/downloader.go b/internal/downloader/downloader.go index 0449fac..9636e1a 100644 --- a/internal/downloader/downloader.go +++ b/internal/downloader/downloader.go @@ -24,6 +24,7 @@ import ( 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 ) @@ -193,37 +194,66 @@ func downloadPlaylist(ctx context.Context, client *http.Client, parsed *url.URL, } // For VOD (Video on Demand), use temp file and convert to MP4 + // 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} + tempTS, err := os.CreateTemp("", "sdl-*.ts") if err != nil { return fmt.Errorf("create temp file: %w", err) } tempPath := tempTS.Name() - defer func() { - tempTS.Close() - os.Remove(tempPath) - }() + defer os.Remove(tempPath) segments := collectSegments(mediaPlaylist) totalSegments := len(segments) fmt.Fprintf(os.Stderr, "Downloading %d segments...\n", totalSegments) for i, segment := range segments { - if err := downloadSegment(ctx, client, finalURL, segment, tempTS); err != nil { + if err := downloadSegment(vodCtx, vodClient, finalURL, segment, tempTS); err != nil { + tempTS.Close() return fmt.Errorf("download 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) + os.Stderr.Sync() // Flush progress output } fmt.Fprintf(os.Stderr, "\n") - if _, err := tempTS.Seek(0, io.SeekStart); err != nil { - return fmt.Errorf("rewind temp file: %w", err) + // 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) + } + + // Verify the file exists and has content + stat, err := os.Stat(tempPath) + if err != nil { + return fmt.Errorf("stat temp file: %w", err) + } + if stat.Size() == 0 { + return fmt.Errorf("downloaded file is empty") } mp4Name := ensureMP4Extension(name) - fmt.Fprintf(os.Stderr, "Converting to MP4...\n") - if err := transmuxToMP4(ctx, tempPath, mp4Name); err != nil { + fmt.Fprintf(os.Stderr, "Converting to MP4 (%.2f MB)...\n", float64(stat.Size())/1024/1024) + if err := transmuxToMP4(vodCtx, tempPath, mp4Name); err != nil { return err } fmt.Fprintf(os.Stderr, "Complete: %s\n", mp4Name) @@ -642,14 +672,50 @@ func transmuxToMP4(ctx context.Context, tsPath, mp4Path string) error { return fmt.Errorf("ensure output directory: %w", err) } - cmd := exec.CommandContext(ctx, "ffmpeg", "-y", "-i", tsPath, "-c", "copy", "-movflags", "+faststart", mp4Path) + // 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 + cmd := exec.CommandContext(ctx, "ffmpeg", + "-y", + "-fflags", "+genpts", + "-err_detect", "ignore_err", + "-i", tsPath, + "-c", "copy", + "-movflags", "+faststart", + mp4Path) cmd.Stdout = io.Discard var stderr bytes.Buffer cmd.Stderr = &stderr if err := cmd.Run(); err != nil { - os.Remove(mp4Path) + // 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") { + // 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", + "-err_detect", "ignore_err", + "-i", tsPath, + "-c:v", "libx264", + "-c:a", "aac", + "-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) }