package youtube import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "log" "os" "os/exec" "strconv" "sync" "time" "git.netflux.io/rob/clipper/media" youtubev2 "github.com/kkdai/youtube/v2" ) const SizeOfInt16 = 2 const ( rawAudioCodec = "pcm_s16le" rawAudioFormat = "s16le" rawAudioSampleRate = 48000 ) const ( thumbnailPrescaleWidth = -1 thumbnailPrescaleHeight = 120 thumbnailWidth = 177 // 16:9 thumbnailHeight = 100 // " ) // Downloader downloads a set of Youtube media for a given video ID, including // separate audio and video files and a JSON metadata file. Additionally, it // also renders the downloaded audio file as a raw audio file. type Downloader struct { youtubeClient YoutubeClient } // NewDownloader returns a new *Downloader. func NewDownloader(youtubeClient YoutubeClient) *Downloader { return &Downloader{youtubeClient: youtubeClient} } type audioResult struct { *media.Audio err error } type videoResult struct { *media.Video err error } // Download downloads the relevant audio and video files for the provided // Youtube video ID. If successful, a *media.MediaSet struct containing // metadata about the downloaded items is returned. func (d *Downloader) Download(ctx context.Context, videoID string) (*media.MediaSet, error) { var video *youtubev2.Video video, err := d.youtubeClient.GetVideoContext(ctx, videoID) if err != nil { return nil, fmt.Errorf("error fetching video: %v", err) } mediaSet := media.NewMediaSet(videoID) audioResultChan := make(chan audioResult, 1) videoResultChan := make(chan videoResult, 1) var wg sync.WaitGroup wg.Add(2) go func() { defer close(audioResultChan) audio, audioErr := d.downloadAudio(ctx, video, mediaSet.EncodedAudioPath(), mediaSet.RawAudioPath()) result := audioResult{audio, audioErr} audioResultChan <- result wg.Done() }() go func() { defer close(videoResultChan) video, videoErr := d.downloadVideo(ctx, video, mediaSet.VideoPath()) result := videoResult{video, videoErr} videoResultChan <- result wg.Done() }() wg.Wait() audioResult := <-audioResultChan videoResult := <-videoResultChan if err = audioResult.err; err != nil { return nil, fmt.Errorf("error downloading audio: %v", err) } if err = videoResult.err; err != nil { return nil, fmt.Errorf("error downloading video: %v", err) } mediaSet.Audio = *audioResult.Audio mediaSet.Video = *videoResult.Video metadataFile, err := os.Create(mediaSet.MetadataPath()) if err != nil { return nil, fmt.Errorf("error opening metadata file: %v", err) } if err = json.NewEncoder(metadataFile).Encode(mediaSet); err != nil { return nil, fmt.Errorf("error encoding metadata: %v", err) } if err = metadataFile.Close(); err != nil { return nil, fmt.Errorf("error writing metadata file: %v", err) } log.Println("finished downloading mediaset") return mediaSet, nil } func (d *Downloader) downloadAudio(ctx context.Context, video *youtubev2.Video, outPath, rawOutPath string) (*media.Audio, error) { if len(video.Formats) == 0 { return nil, errors.New("error selecting audio format: no format available") } format := SortAudio(video.Formats)[0] log.Printf("selected audio format: %s", FormatDebugString(&format, false)) stream, _, err := d.youtubeClient.GetStreamContext(ctx, video, &format) if err != nil { return nil, fmt.Errorf("error fetching audio stream: %v", err) } reader := progressReader{Reader: stream, label: "audio", exp: int(format.ContentLength)} rawAudioFile, err := os.Create(rawOutPath) if err != nil { return nil, fmt.Errorf("error creating raw audio file: %v", err) } encodedAudioFile, err := os.Create(outPath) if err != nil { return nil, fmt.Errorf("error creating encoded audio file: %v", err) } streamReader := io.TeeReader(&reader, encodedAudioFile) var errOut bytes.Buffer cmd := exec.CommandContext(ctx, "ffmpeg", "-i", "-", "-f", rawAudioFormat, "-ar", strconv.Itoa(rawAudioSampleRate), "-acodec", rawAudioCodec, "-") cmd.Stdin = streamReader cmd.Stdout = rawAudioFile cmd.Stderr = &errOut if err = cmd.Run(); err != nil { log.Println(errOut.String()) return nil, fmt.Errorf("error processing audio: %v", err) } if err = rawAudioFile.Close(); err != nil { return nil, fmt.Errorf("error writing raw audio file: %v", err) } rawAudioFile, err = os.Open(rawOutPath) if err != nil { return nil, fmt.Errorf("error opening raw audio file: %v", err) } fi, err := rawAudioFile.Stat() if err != nil { return nil, fmt.Errorf("error reading raw audio file info: %v", err) } numFrames := fi.Size() / int64(SizeOfInt16) / int64(format.AudioChannels) sampleRate, err := strconv.Atoi(format.AudioSampleRate) if err != nil { return nil, fmt.Errorf("invalid samplerate: %s", format.AudioSampleRate) } return &media.Audio{ Bytes: fi.Size(), Channels: format.AudioChannels, Frames: numFrames, SampleRate: sampleRate, }, nil } type progressReader struct { io.Reader label string total, exp int } func (pw *progressReader) Read(p []byte) (int, error) { n, err := pw.Reader.Read(p) pw.total += n log.Printf("[ProgressReader] [%s] Read %d of %d (%.02f%%) bytes from the provided reader", pw.label, pw.total, pw.exp, (float32(pw.total)/float32(pw.exp))*100.0) return n, err } func (d *Downloader) downloadVideo(ctx context.Context, video *youtubev2.Video, outPath string) (*media.Video, error) { if len(video.Formats) == 0 { return nil, errors.New("error selecting audio format: no format available") } format := SortVideo(video.Formats)[0] log.Printf("selected video format: %s", FormatDebugString(&format, false)) durationMsecs, err := strconv.Atoi(format.ApproxDurationMs) if err != nil { return nil, fmt.Errorf("could not parse video duration: %s", err) } stream, _, err := d.youtubeClient.GetStreamContext(ctx, video, &format) if err != nil { return nil, fmt.Errorf("error fetching video stream: %v", err) } reader := progressReader{Reader: stream, label: "video", exp: int(format.ContentLength)} videoFile, err := os.Create(outPath) if err != nil { return nil, fmt.Errorf("error creating video file: %v", err) } if _, err = io.Copy(videoFile, &reader); err != nil { return nil, fmt.Errorf("error processing video: %v", err) } return &media.Video{ Bytes: format.ContentLength, ThumbnailWidth: thumbnailWidth, ThumbnailHeight: thumbnailHeight, Duration: time.Duration(durationMsecs) * time.Millisecond, }, nil }