From cc74da0871ea2f6d34286b75518387d992fdfa51 Mon Sep 17 00:00:00 2001 From: Rob Watson Date: Fri, 24 Sep 2021 07:15:40 +0200 Subject: [PATCH] Improve audio and video format selection --- backend/{ => cmd/clipper}/main.go | 0 backend/cmd/ytdebug/main.go | 46 ++++++++++++++++ backend/go.mod | 8 ++- backend/go.sum | 1 + backend/youtube/helpers.go | 92 +++++++++++++++++++++++++++++++ backend/youtube/helpers_test.go | 81 +++++++++++++++++++++++++++ backend/youtube/youtube.go | 30 ++++------ 7 files changed, 238 insertions(+), 20 deletions(-) rename backend/{ => cmd/clipper}/main.go (100%) create mode 100644 backend/cmd/ytdebug/main.go create mode 100644 backend/youtube/helpers.go create mode 100644 backend/youtube/helpers_test.go diff --git a/backend/main.go b/backend/cmd/clipper/main.go similarity index 100% rename from backend/main.go rename to backend/cmd/clipper/main.go diff --git a/backend/cmd/ytdebug/main.go b/backend/cmd/ytdebug/main.go new file mode 100644 index 0000000..dea168d --- /dev/null +++ b/backend/cmd/ytdebug/main.go @@ -0,0 +1,46 @@ +package main + +import ( + "context" + "flag" + "fmt" + "log" + + "git.netflux.io/rob/clipper/youtube" + + youtubev2 "github.com/kkdai/youtube/v2" +) + +func main() { + var ( + verbose bool + audioOnly bool + videoOnly bool + ) + flag.BoolVar(&verbose, "v", false, "verbose output") + flag.BoolVar(&audioOnly, "audio", false, "only print audio formats") + flag.BoolVar(&videoOnly, "video", false, "only print video formats") + flag.Parse() + + videoID := flag.Arg(0) + ctx := context.Background() + var youtubeClient youtubev2.Client + + video, err := youtubeClient.GetVideoContext(ctx, videoID) + if err != nil { + log.Fatal(err) + } + formats := video.Formats + + switch { + case audioOnly: + formats = youtube.SortAudio(formats) + case videoOnly: + formats = youtube.SortVideo(formats) + } + + fmt.Println("In descending order of preference:") + for n, f := range formats { + fmt.Printf("%d: %s\n", n+1, youtube.FormatDebugString(&f, verbose)) + } +} diff --git a/backend/go.mod b/backend/go.mod index 8068560..d898c2c 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -2,9 +2,15 @@ module git.netflux.io/rob/clipper go 1.17 -require github.com/kkdai/youtube/v2 v2.7.4 +require ( + github.com/kkdai/youtube/v2 v2.7.4 + github.com/stretchr/testify v1.7.0 +) require ( github.com/bitly/go-simplejson v0.5.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect golang.org/x/net v0.0.0-20210614182718-04defd469f4e // indirect + gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect ) diff --git a/backend/go.sum b/backend/go.sum index 7b08e1e..04dcd1e 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -639,6 +639,7 @@ google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp0 google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/ini.v1 v1.51.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= diff --git a/backend/youtube/helpers.go b/backend/youtube/helpers.go new file mode 100644 index 0000000..9bf21f4 --- /dev/null +++ b/backend/youtube/helpers.go @@ -0,0 +1,92 @@ +package youtube + +import ( + "fmt" + "sort" + "strings" + + youtubev2 "github.com/kkdai/youtube/v2" +) + +func FormatDebugString(format *youtubev2.Format, includeURL bool) string { + var url string + if includeURL { + url = format.URL + } + return fmt.Sprintf( + "iTag = %d, mime_type = %s, quality = %s, quality_label = %s, bitrate = %d, fps = %d, width = %d, height = %d, content_length = %d, duration = %v, audio_channels = %d, audio_sample_rate = %s, audio_quality = %s, url = %s", + format.ItagNo, + format.MimeType, + format.Quality, + format.QualityLabel, + format.Bitrate, + format.FPS, + format.Width, + format.Height, + format.ContentLength, + format.ApproxDurationMs, + format.AudioChannels, + format.AudioSampleRate, + format.AudioQuality, + url, + ) +} + +// SortAudio returns the provided formats ordered in descending preferred +// order. The ideal candidate is 44.1kHz stereo audio in a webm container, with +// the highest available bitrate. +func SortAudio(inFormats youtubev2.FormatList) youtubev2.FormatList { + // TODO: sort in-place. + var formats youtubev2.FormatList + for _, format := range inFormats { + if format.FPS == 0 && format.AudioChannels > 0 { + formats = append(formats, format) + } + } + sort.SliceStable(formats, func(i, j int) bool { + isOpusI := strings.Contains(formats[i].MimeType, "opus") + isOpusJ := strings.Contains(formats[j].MimeType, "opus") + if isOpusI && isOpusJ { + isStereoI := formats[i].AudioChannels == 2 + isStereoJ := formats[j].AudioChannels == 2 + if isStereoI && isStereoJ { + is44kI := formats[i].AudioSampleRate == "44100" + is44kJ := formats[j].AudioSampleRate == "44100" + if is44kI && is44kJ { + return formats[i].Bitrate > formats[j].Bitrate + } + return is44kI + } + return isStereoI + } + return isOpusI + }) + return formats +} + +// SortVideo returns the provided formats ordered in descending preferred +// order. The ideal candidate is video in an mp4 container with a medium +// bitrate, with audio channels (needed to allow synced playback on the +// website). +func SortVideo(inFormats youtubev2.FormatList) youtubev2.FormatList { + // TODO: sort in-place. + var formats youtubev2.FormatList + for _, format := range inFormats { + if format.FPS > 0 && format.AudioChannels > 0 { + formats = append(formats, format) + } + } + sort.SliceStable(formats, func(i, j int) bool { + isMP4I := strings.Contains(formats[i].MimeType, "mp4") + isMP4J := strings.Contains(formats[j].MimeType, "mp4") + if isMP4I && isMP4J { + return compareQualityLabel(formats[i].QualityLabel, formats[j].QualityLabel) + } + return strings.Contains(formats[i].MimeType, "mp4") + }) + return formats +} + +func compareQualityLabel(a, b string) bool { + return (a == "360p" || a == "480p") && (b != "360p" && b != "480p") +} diff --git a/backend/youtube/helpers_test.go b/backend/youtube/helpers_test.go new file mode 100644 index 0000000..9547e92 --- /dev/null +++ b/backend/youtube/helpers_test.go @@ -0,0 +1,81 @@ +package youtube_test + +import ( + "testing" + + "git.netflux.io/rob/clipper/youtube" + youtubev2 "github.com/kkdai/youtube/v2" + "github.com/stretchr/testify/assert" +) + +func TestSortAudio(t *testing.T) { + formats := []youtubev2.Format{ + { + MimeType: `audio/webm; codecs="opus"`, + Bitrate: 350_000, + AudioChannels: 2, + AudioSampleRate: "16000", + }, + { + MimeType: `audio/webm; codecs="opus"`, + Bitrate: 350_000, + AudioChannels: 2, + AudioSampleRate: "44100", + }, + { + MimeType: `audio/mp4; codecs="mp4a.40.2"`, + Bitrate: 250_000, + AudioChannels: 2, + AudioSampleRate: "44100", + }, + { + MimeType: `audio/webm; codecs="opus"`, + Bitrate: 125_000, + AudioChannels: 2, + AudioSampleRate: "44100", + }, + } + + sortedFormats := youtube.SortAudio(formats) + + assert.Equal(t, formats[1], sortedFormats[0]) + assert.Equal(t, formats[3], sortedFormats[1]) + assert.Equal(t, formats[0], sortedFormats[2]) + assert.Equal(t, formats[2], sortedFormats[3]) +} + +func TestSortVideo(t *testing.T) { + formats := []youtubev2.Format{ + { + MimeType: `video/mp4; codecs="avc1.42001E, mp4a.40.2"`, + QualityLabel: "240p", + FPS: 30, + AudioChannels: 2, + }, + { + MimeType: `audio/webm; codecs="opus"`, + QualityLabel: "", + FPS: 0, + AudioChannels: 2, + }, + { + MimeType: `video/mp4; codecs="avc1.42001E, mp4a.40.2"`, + QualityLabel: "720p", + FPS: 30, + AudioChannels: 2, + }, + { + MimeType: `video/mp4; codecs="avc1.42001E, mp4a.40.2"`, + QualityLabel: "360p", + FPS: 30, + AudioChannels: 2, + }, + } + + sortedFormats := youtube.SortVideo(formats) + + assert.Len(t, sortedFormats, 3) + assert.Equal(t, formats[3], sortedFormats[0]) + assert.Equal(t, formats[0], sortedFormats[1]) + assert.Equal(t, formats[2], sortedFormats[2]) +} diff --git a/backend/youtube/youtube.go b/backend/youtube/youtube.go index 83f91df..1f459b3 100644 --- a/backend/youtube/youtube.go +++ b/backend/youtube/youtube.go @@ -45,6 +45,7 @@ type Downloader struct { youtubeClient YoutubeClient } +// NewDownloader returns a new *Downloader. func NewDownloader(youtubeClient YoutubeClient) *Downloader { return &Downloader{youtubeClient: youtubeClient} } @@ -54,11 +55,6 @@ type audioResult struct { err error } -// videoMediaSet represents the video part of a media.MediaSet: -type videoMediaSet struct { - bytes int64 -} - type videoResult struct { *media.Video err error @@ -130,19 +126,13 @@ func (d *Downloader) Download(ctx context.Context, videoID string) (*media.Media } func (d *Downloader) downloadAudio(ctx context.Context, video *youtubev2.Video, outPath, rawOutPath string) (*media.Audio, error) { - var format *youtubev2.Format - for _, candidate := range video.Formats.WithAudioChannels() { - if format == nil || (candidate.ContentLength > 0 && candidate.ContentLength < format.ContentLength) { - candidate := candidate - format = &candidate - } - } - if format == nil { + if len(video.Formats) == 0 { return nil, errors.New("error selecting audio format: no format available") } - log.Printf("selected audio format: %+v", format) + format := SortAudio(video.Formats)[0] + log.Printf("selected audio format: %s", FormatDebugString(&format, false)) - stream, _, err := d.youtubeClient.GetStreamContext(ctx, video, format) + stream, _, err := d.youtubeClient.GetStreamContext(ctx, video, &format) if err != nil { return nil, fmt.Errorf("error fetching audio stream: %v", err) } @@ -206,11 +196,13 @@ func thumbnailGridSize(seconds int) (int, int) { } func (d *Downloader) downloadVideo(ctx context.Context, video *youtubev2.Video, outPath, thumbnailOutPath string) (*media.Video, error) { - // TODO: check if iTag 18 always exists, and works in a good variety of browsers. - format := video.Formats.FindByItag(videoItag) - log.Printf("selected video format: %+v", format) + 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)) - stream, _, err := d.youtubeClient.GetStreamContext(ctx, video, format) + stream, _, err := d.youtubeClient.GetStreamContext(ctx, video, &format) if err != nil { return nil, fmt.Errorf("error fetching video stream: %v", err) }