139 lines
3.8 KiB
Go
139 lines
3.8 KiB
Go
|
package youtube
|
||
|
|
||
|
import (
|
||
|
"bytes"
|
||
|
"encoding/json"
|
||
|
"errors"
|
||
|
"fmt"
|
||
|
"io"
|
||
|
"log"
|
||
|
"os"
|
||
|
"os/exec"
|
||
|
"strconv"
|
||
|
|
||
|
"git.netflux.io/rob/clipper/media"
|
||
|
|
||
|
youtubev2 "github.com/kkdai/youtube/v2"
|
||
|
)
|
||
|
|
||
|
const (
|
||
|
SizeOfInt16 = 2
|
||
|
|
||
|
EncodedAudioCodec = "pcm_s16le"
|
||
|
EncodedAudioFormat = "s16le"
|
||
|
EncodedAudioSampleRate = 48000
|
||
|
)
|
||
|
|
||
|
// YoutubeClient wraps the youtube.Client client.
|
||
|
type YoutubeClient interface {
|
||
|
GetVideo(string) (*youtubev2.Video, error)
|
||
|
GetStream(*youtubev2.Video, *youtubev2.Format) (io.ReadCloser, int64, error)
|
||
|
}
|
||
|
|
||
|
// 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
|
||
|
}
|
||
|
|
||
|
func NewDownloader(youtubeClient YoutubeClient) *Downloader {
|
||
|
return &Downloader{youtubeClient: youtubeClient}
|
||
|
}
|
||
|
|
||
|
// 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(videoID string) (*media.MediaSet, error) {
|
||
|
video, err := d.youtubeClient.GetVideo(videoID)
|
||
|
if err != nil {
|
||
|
return nil, fmt.Errorf("error fetching video: %v", err)
|
||
|
}
|
||
|
|
||
|
// TODO: improve selection of audio and video format.
|
||
|
// Perhaps download both separately?
|
||
|
var format *youtubev2.Format
|
||
|
for i := range video.Formats {
|
||
|
candidate := video.Formats[i]
|
||
|
if candidate.FPS == 0 || candidate.AudioChannels == 0 {
|
||
|
continue
|
||
|
}
|
||
|
if format == nil || (candidate.ContentLength > 0 && candidate.ContentLength < format.ContentLength) {
|
||
|
format = &candidate
|
||
|
}
|
||
|
}
|
||
|
if format == nil {
|
||
|
return nil, errors.New("error selecting format: no format available")
|
||
|
}
|
||
|
log.Printf("selected format: %+v", format)
|
||
|
|
||
|
stream, _, err := d.youtubeClient.GetStream(video, format)
|
||
|
if err != nil {
|
||
|
return nil, fmt.Errorf("error fetching stream: %v", err)
|
||
|
}
|
||
|
|
||
|
mediaSet := media.MediaSet{ID: videoID, Source: "youtube"}
|
||
|
|
||
|
rawAudioFile, err := os.Create(mediaSet.RawAudioPath())
|
||
|
if err != nil {
|
||
|
return nil, fmt.Errorf("error creating raw audio file: %v", err)
|
||
|
}
|
||
|
|
||
|
encodedAudioFile, err := os.Create(mediaSet.EncodedAudioPath())
|
||
|
if err != nil {
|
||
|
return nil, fmt.Errorf("error creating encoded audio file: %v", err)
|
||
|
}
|
||
|
streamReader := io.TeeReader(stream, encodedAudioFile)
|
||
|
|
||
|
var errOut bytes.Buffer
|
||
|
cmd := exec.Command("ffmpeg", "-i", "-", "-f", EncodedAudioFormat, "-ar", strconv.Itoa(EncodedAudioSampleRate), "-acodec", EncodedAudioCodec, "-")
|
||
|
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(mediaSet.RawAudioPath())
|
||
|
if err != nil {
|
||
|
return nil, fmt.Errorf("error reading file: %v", err)
|
||
|
}
|
||
|
|
||
|
fi, err := rawAudioFile.Stat()
|
||
|
if err != nil {
|
||
|
return nil, fmt.Errorf("error reading 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)
|
||
|
}
|
||
|
|
||
|
mediaSet.Bytes = fi.Size()
|
||
|
mediaSet.Channels = format.AudioChannels
|
||
|
mediaSet.Frames = numFrames
|
||
|
mediaSet.SampleRate = sampleRate
|
||
|
|
||
|
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)
|
||
|
}
|
||
|
|
||
|
return &mediaSet, nil
|
||
|
}
|