Improve audio and video format selection
This commit is contained in:
parent
b64ce1d424
commit
cc74da0871
|
@ -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))
|
||||||
|
}
|
||||||
|
}
|
|
@ -2,9 +2,15 @@ module git.netflux.io/rob/clipper
|
||||||
|
|
||||||
go 1.17
|
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 (
|
require (
|
||||||
github.com/bitly/go-simplejson v0.5.0 // indirect
|
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
|
golang.org/x/net v0.0.0-20210614182718-04defd469f4e // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect
|
||||||
)
|
)
|
||||||
|
|
|
@ -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=
|
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/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 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/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/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||||
gopkg.in/ini.v1 v1.51.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
gopkg.in/ini.v1 v1.51.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||||
|
|
|
@ -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")
|
||||||
|
}
|
|
@ -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])
|
||||||
|
}
|
|
@ -45,6 +45,7 @@ type Downloader struct {
|
||||||
youtubeClient YoutubeClient
|
youtubeClient YoutubeClient
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewDownloader returns a new *Downloader.
|
||||||
func NewDownloader(youtubeClient YoutubeClient) *Downloader {
|
func NewDownloader(youtubeClient YoutubeClient) *Downloader {
|
||||||
return &Downloader{youtubeClient: youtubeClient}
|
return &Downloader{youtubeClient: youtubeClient}
|
||||||
}
|
}
|
||||||
|
@ -54,11 +55,6 @@ type audioResult struct {
|
||||||
err error
|
err error
|
||||||
}
|
}
|
||||||
|
|
||||||
// videoMediaSet represents the video part of a media.MediaSet:
|
|
||||||
type videoMediaSet struct {
|
|
||||||
bytes int64
|
|
||||||
}
|
|
||||||
|
|
||||||
type videoResult struct {
|
type videoResult struct {
|
||||||
*media.Video
|
*media.Video
|
||||||
err error
|
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) {
|
func (d *Downloader) downloadAudio(ctx context.Context, video *youtubev2.Video, outPath, rawOutPath string) (*media.Audio, error) {
|
||||||
var format *youtubev2.Format
|
if len(video.Formats) == 0 {
|
||||||
for _, candidate := range video.Formats.WithAudioChannels() {
|
|
||||||
if format == nil || (candidate.ContentLength > 0 && candidate.ContentLength < format.ContentLength) {
|
|
||||||
candidate := candidate
|
|
||||||
format = &candidate
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if format == nil {
|
|
||||||
return nil, errors.New("error selecting audio format: no format available")
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("error fetching audio stream: %v", err)
|
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) {
|
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.
|
if len(video.Formats) == 0 {
|
||||||
format := video.Formats.FindByItag(videoItag)
|
return nil, errors.New("error selecting audio format: no format available")
|
||||||
log.Printf("selected video format: %+v", format)
|
}
|
||||||
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("error fetching video stream: %v", err)
|
return nil, fmt.Errorf("error fetching video stream: %v", err)
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue