Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2ce88069a9 | |||
| ff88fbeaaf | |||
| 37ff8cdcf1 | |||
| 308dd6ad6e | |||
| 2ffcc889af |
@@ -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
|
||||
)
|
||||
@@ -87,16 +88,9 @@ func Download(ctx context.Context, sourceURL, outputName string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// For livestreams, set up keyboard input handler for "q" key
|
||||
// Check if this might be a livestream by creating a cancellable context
|
||||
playlistCtx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
// Start keyboard input handler in a goroutine
|
||||
// This will work for both livestreams and VOD, but only matters for livestreams
|
||||
go watchForQuitKey(cancel)
|
||||
|
||||
return downloadPlaylist(playlistCtx, client, parsed, outputName)
|
||||
// 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
|
||||
@@ -182,6 +176,13 @@ func downloadPlaylist(ctx context.Context, client *http.Client, parsed *url.URL,
|
||||
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)
|
||||
@@ -189,36 +190,95 @@ func downloadPlaylist(ctx context.Context, client *http.Client, parsed *url.URL,
|
||||
return fmt.Errorf("create output file: %w", err)
|
||||
}
|
||||
// downloadLiveStream handles file closing
|
||||
// Note: keyboard input handler is already set up in Download()
|
||||
return downloadLiveStream(ctx, client, finalURL, mediaPlaylist, tsFile, name)
|
||||
return downloadLiveStream(liveCtx, client, finalURL, mediaPlaylist, tsFile, name)
|
||||
}
|
||||
|
||||
// For VOD (Video on Demand), use temp file and convert to MP4
|
||||
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)
|
||||
// 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()
|
||||
}()
|
||||
|
||||
segments := collectSegments(mediaPlaylist)
|
||||
for i, segment := range segments {
|
||||
if err := downloadSegment(ctx, client, finalURL, segment, tempTS); err != nil {
|
||||
return fmt.Errorf("download segment %d: %w", i, err)
|
||||
// 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 := tempTS.Seek(0, io.SeekStart); err != nil {
|
||||
return fmt.Errorf("rewind temp file: %w", 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)
|
||||
if err := transmuxToMP4(ctx, tempPath, mp4Name); err != nil {
|
||||
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
|
||||
}
|
||||
@@ -625,6 +685,231 @@ func inferOutputNameMust(rawURL string) string {
|
||||
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
|
||||
@@ -634,14 +919,61 @@ 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
|
||||
// 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 {
|
||||
os.Remove(mp4Path)
|
||||
// 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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user