Compare commits

5 Commits

Author SHA1 Message Date
2ce88069a9 fix: stuff 2025-11-18 13:36:38 +01:00
ff88fbeaaf fix: stuff 2025-11-18 13:27:15 +01:00
37ff8cdcf1 fix: ctx cancellation 2025-11-18 13:14:19 +01:00
308dd6ad6e feat: dl progress 2025-11-18 12:53:59 +01:00
2ffcc889af fix: VOD download 2025-11-18 12:44:05 +01:00

View File

@@ -24,6 +24,7 @@ import (
const ( const (
defaultClientTimeout = 30 * time.Second defaultClientTimeout = 30 * time.Second
vodSegmentTimeout = 5 * time.Minute // Longer timeout for VOD segments which can be large
maxRedirects = 5 maxRedirects = 5
playlistPollInterval = 2 * time.Second // Poll interval for livestreams playlistPollInterval = 2 * time.Second // Poll interval for livestreams
) )
@@ -87,16 +88,9 @@ func Download(ctx context.Context, sourceURL, outputName string) error {
return nil return nil
} }
// For livestreams, set up keyboard input handler for "q" key // Use the original context for VOD downloads
// Check if this might be a livestream by creating a cancellable context // For livestreams, a cancellable context will be created in downloadPlaylist
playlistCtx, cancel := context.WithCancel(ctx) return downloadPlaylist(ctx, client, parsed, outputName)
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)
} }
// watchForQuitKey watches for "q" keypress and cancels the context // 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 isLiveStream := !mediaPlaylist.Closed
if isLiveStream { 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 // For livestreams, write to a permanent .ts file
tsName := ensureTSExtension(name) tsName := ensureTSExtension(name)
tsFile, err := os.Create(tsName) 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) return fmt.Errorf("create output file: %w", err)
} }
// downloadLiveStream handles file closing // downloadLiveStream handles file closing
// Note: keyboard input handler is already set up in Download() return downloadLiveStream(liveCtx, client, finalURL, mediaPlaylist, tsFile, name)
return downloadLiveStream(ctx, 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
tempTS, err := os.CreateTemp("", "sdl-*.ts") // Create a context without deadline for VOD downloads to avoid premature cancellation
if err != nil { // Preserve cancellation from parent context but remove any deadline
return fmt.Errorf("create temp file: %w", err) vodCtx, vodCancel := context.WithCancel(context.Background())
} defer vodCancel()
tempPath := tempTS.Name()
defer func() { // If parent context is cancelled, cancel VOD context too
tempTS.Close() go func() {
os.Remove(tempPath) <-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) 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 { for i, segment := range segments {
if err := downloadSegment(ctx, client, finalURL, segment, tempTS); err != nil { 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) 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)
} }
if _, err := tempTS.Seek(0, io.SeekStart); err != nil { for _, segFile := range segmentFiles {
return fmt.Errorf("rewind temp file: %w", err) // 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) 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 return err
} }
fmt.Fprintf(os.Stderr, "Complete: %s\n", mp4Name)
return nil return nil
} }
@@ -625,6 +685,231 @@ func inferOutputNameMust(rawURL string) string {
return inferOutputName(parsed) 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 { func transmuxToMP4(ctx context.Context, tsPath, mp4Path string) error {
if _, err := exec.LookPath("ffmpeg"); err != nil { if _, err := exec.LookPath("ffmpeg"); err != nil {
return errFFmpegMissing return errFFmpegMissing
@@ -634,14 +919,61 @@ func transmuxToMP4(ctx context.Context, tsPath, mp4Path string) error {
return fmt.Errorf("ensure output directory: %w", err) 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 cmd.Stdout = io.Discard
var stderr bytes.Buffer var stderr bytes.Buffer
cmd.Stderr = &stderr cmd.Stderr = &stderr
if err := cmd.Run(); err != nil { if err := cmd.Run(); err != nil {
os.Remove(mp4Path) // If copy fails, try with re-encoding as fallback
msg := strings.TrimSpace(stderr.String()) 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 != "" { if msg != "" {
return fmt.Errorf("ffmpeg: %s", msg) return fmt.Errorf("ffmpeg: %s", msg)
} }