├── doc.go ├── .gitignore ├── cmd └── youtubedr │ ├── main.go │ ├── url.go │ ├── version.go │ ├── output_format.go │ ├── download.go │ ├── list.go │ ├── root.go │ ├── downloader.go │ └── info.go ├── YoutubeChange.md ├── .goreleaser.yml ├── itag_test.go ├── protobuilder_test.go ├── downloader ├── progress.go ├── downloader_hq_test.go ├── file_utils_test.go ├── file_utils.go ├── downloader.go └── downloader_test.go ├── decipher_operations.go ├── .github ├── pull_request_template.md └── workflows │ ├── schedule.yaml │ └── go.yaml ├── .golangci.yml ├── logger.go ├── errors_test.go ├── utils_test.go ├── artifacts.go ├── fetch_testdata_helper.go ├── video_id.go ├── player_cache.go ├── LICENSE ├── transcript_test.go ├── errors.go ├── player_parse.go ├── protobuilder.go ├── player_cache_test.go ├── Makefile ├── playlist_test.go ├── utils.go ├── go.mod ├── example_test.go ├── format_list_test.go ├── video.go ├── format_list.go ├── video_test.go ├── response_data.go ├── README.md ├── transcript.go ├── playlist.go ├── decipher.go ├── go.sum ├── client_test.go └── client.go /doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package youtube implement youtube download package in go. 3 | */ 4 | package youtube 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea 2 | /.vscode 3 | download_test 4 | /bin 5 | /dist 6 | /output 7 | *.out 8 | .DS_Store 9 | -------------------------------------------------------------------------------- /cmd/youtubedr/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | ) 7 | 8 | func main() { 9 | exitOnError(rootCmd.Execute()) 10 | } 11 | 12 | func exitOnError(err error) { 13 | if err != nil { 14 | fmt.Fprintln(os.Stderr, err) 15 | os.Exit(1) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /YoutubeChange.md: -------------------------------------------------------------------------------- 1 | Tracking Youtube decipher change 2 | 3 | ### 2022/01/21: 4 | 5 | #### action objects: 6 | 7 | var $z={fA:function(a){a.reverse()},S2:function(a,b){a.splice(0,b)},l6:function(a,b){var c=a[0];a[0]=a[b%a.length];a[b%a.length]=c}}; 8 | #### actions function: 9 | 10 | Npa=function(a){a=a.split("");$z.S2(a,3);$z.fA(a,45);$z.l6(a,31);$z.S2(a,1);$z.fA(a,63);$z.S2(a,2);$z.fA(a,68);return a.join("")}; 11 | 12 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | project_name: youtubedr 3 | 4 | env: 5 | # Build without CGO to don't depend on specific glibc versions 6 | - CGO_ENABLED=0 7 | 8 | builds: 9 | - main: ./cmd/youtubedr 10 | binary: youtubedr 11 | goos: 12 | - windows 13 | - darwin 14 | - linux 15 | - freebsd 16 | goarch: 17 | - amd64 18 | - arm 19 | - arm64 20 | flags: 21 | - -trimpath 22 | -------------------------------------------------------------------------------- /itag_test.go: -------------------------------------------------------------------------------- 1 | package youtube 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | ) 8 | 9 | func TestYoutube_GetItagInfo(t *testing.T) { 10 | require := require.New(t) 11 | client := Client{} 12 | 13 | // url from issue #25 14 | url := "https://www.youtube.com/watch?v=rFejpH_tAHM" 15 | video, err := client.GetVideo(url) 16 | require.NoError(err) 17 | require.GreaterOrEqual(len(video.Formats), 24) 18 | } 19 | -------------------------------------------------------------------------------- /protobuilder_test.go: -------------------------------------------------------------------------------- 1 | package youtube 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestProtoBuilder(t *testing.T) { 10 | var pb ProtoBuilder 11 | 12 | pb.Varint(1, 128) 13 | pb.Varint(2, 1234567890) 14 | pb.Varint(3, 1234567890123456789) 15 | pb.String(4, "Hello") 16 | pb.Bytes(5, []byte{1, 2, 3}) 17 | assert.Equal(t, "CIABENKF2MwEGJWCpu_HnoSRESIFSGVsbG8qAwECAw%3D%3D", pb.ToURLEncodedBase64()) 18 | } 19 | -------------------------------------------------------------------------------- /downloader/progress.go: -------------------------------------------------------------------------------- 1 | package downloader 2 | 3 | type progress struct { 4 | contentLength float64 5 | totalWrittenBytes float64 6 | downloadLevel float64 7 | } 8 | 9 | func (dl *progress) Write(p []byte) (n int, err error) { 10 | n = len(p) 11 | dl.totalWrittenBytes = dl.totalWrittenBytes + float64(n) 12 | currentPercent := (dl.totalWrittenBytes / dl.contentLength) * 100 13 | if (dl.downloadLevel <= currentPercent) && (dl.downloadLevel < 100) { 14 | dl.downloadLevel++ 15 | } 16 | return 17 | } 18 | -------------------------------------------------------------------------------- /downloader/downloader_hq_test.go: -------------------------------------------------------------------------------- 1 | //go:build integration 2 | // +build integration 3 | 4 | package downloader 5 | 6 | import ( 7 | "context" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | func TestDownload_HighQuality(t *testing.T) { 14 | require := require.New(t) 15 | ctx := context.Background() 16 | 17 | video, err := testDownloader.Client.GetVideoContext(ctx, "BaW_jenozKc") 18 | require.NoError(err) 19 | require.NoError(testDownloader.DownloadComposite(ctx, "", video, "hd1080", "mp4", "")) 20 | } 21 | -------------------------------------------------------------------------------- /decipher_operations.go: -------------------------------------------------------------------------------- 1 | package youtube 2 | 3 | type DecipherOperation func([]byte) []byte 4 | 5 | func newSpliceFunc(pos int) DecipherOperation { 6 | return func(bs []byte) []byte { 7 | return bs[pos:] 8 | } 9 | } 10 | 11 | func newSwapFunc(arg int) DecipherOperation { 12 | return func(bs []byte) []byte { 13 | pos := arg % len(bs) 14 | bs[0], bs[pos] = bs[pos], bs[0] 15 | return bs 16 | } 17 | } 18 | 19 | func reverseFunc(bs []byte) []byte { 20 | l, r := 0, len(bs)-1 21 | for l < r { 22 | bs[l], bs[r] = bs[r], bs[l] 23 | l++ 24 | r-- 25 | } 26 | return bs 27 | } 28 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | # Description 2 | 3 | _Please tell us the changes you've made_ 4 | 5 | ## Issues to fix 6 | 7 | Please link issues this PR will fix: \ 8 | \#_[issue number]_ 9 | 10 | if no relevant issue, but this will fix something important for reference 11 | , please free to open an issue. 12 | 13 | ## Reminding 14 | Something you can do before PR to reduce time to merge 15 | 16 | * run "make build" to build the code 17 | * run "make format" to reformat the code 18 | * run "make lint" if you are using unix system 19 | * run "make test-integration" to pass all tests 20 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | linters: 3 | exclusions: 4 | generated: lax 5 | presets: 6 | - comments 7 | - common-false-positives 8 | - legacy 9 | - std-error-handling 10 | rules: 11 | - path: cmd/ 12 | text: InsecureSkipVerify 13 | - path: (.+)\.go$ 14 | text: Subprocess launched with function call 15 | paths: 16 | - third_party$ 17 | - builtin$ 18 | - examples$ 19 | formatters: 20 | exclusions: 21 | generated: lax 22 | paths: 23 | - third_party$ 24 | - builtin$ 25 | - examples$ 26 | -------------------------------------------------------------------------------- /cmd/youtubedr/url.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/spf13/cobra" 7 | ) 8 | 9 | // urlCmd represents the url command 10 | var urlCmd = &cobra.Command{ 11 | Use: "url", 12 | Short: "Only output the stream-url to desired video", 13 | Args: cobra.ExactArgs(1), 14 | Run: func(_ *cobra.Command, args []string) { 15 | video, format, err := getVideoWithFormat(args[0]) 16 | exitOnError(err) 17 | 18 | url, err := downloader.GetStreamURL(video, format) 19 | exitOnError(err) 20 | 21 | fmt.Println(url) 22 | }, 23 | } 24 | 25 | func init() { 26 | addVideoSelectionFlags(urlCmd.Flags()) 27 | rootCmd.AddCommand(urlCmd) 28 | } 29 | -------------------------------------------------------------------------------- /logger.go: -------------------------------------------------------------------------------- 1 | package youtube 2 | 3 | import ( 4 | "fmt" 5 | "log/slog" 6 | "os" 7 | ) 8 | 9 | // The global logger for all Client instances 10 | var Logger = getLogger(os.Getenv("LOGLEVEL")) 11 | 12 | func SetLogLevel(value string) { 13 | Logger = getLogger(value) 14 | } 15 | 16 | func getLogger(logLevel string) *slog.Logger { 17 | levelVar := slog.LevelVar{} 18 | 19 | if logLevel != "" { 20 | if err := levelVar.UnmarshalText([]byte(logLevel)); err != nil { 21 | panic(fmt.Sprintf("Invalid log level %s: %v", logLevel, err)) 22 | } 23 | } 24 | 25 | return slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ 26 | Level: levelVar.Level(), 27 | })) 28 | } 29 | -------------------------------------------------------------------------------- /cmd/youtubedr/version.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "runtime" 6 | 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | var ( 11 | // set through ldflags 12 | version string 13 | commit string 14 | date string 15 | ) 16 | 17 | // versionCmd represents the version command 18 | var versionCmd = &cobra.Command{ 19 | Use: "version", 20 | Short: "Prints version information", 21 | Run: func(_ *cobra.Command, _ []string) { 22 | fmt.Println("Version: ", version) 23 | fmt.Println("Commit: ", commit) 24 | fmt.Println("Date: ", date) 25 | fmt.Println("Go Version: ", runtime.Version()) 26 | }, 27 | } 28 | 29 | func init() { 30 | rootCmd.AddCommand(versionCmd) 31 | } 32 | -------------------------------------------------------------------------------- /downloader/file_utils_test.go: -------------------------------------------------------------------------------- 1 | package downloader 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestSanitizeFilename(t *testing.T) { 8 | fileName := "ac:d\\e\"f/g\\h|i?j*k" 9 | sanitized := SanitizeFilename(fileName) 10 | if sanitized != "abcdefghijk" { 11 | t.Error("Invalid characters must get stripped") 12 | } 13 | 14 | fileName = "aB Cd" 15 | sanitized = SanitizeFilename(fileName) 16 | if sanitized != "aB Cd" { 17 | t.Error("Casing and whitespaces must be preserved") 18 | } 19 | 20 | fileName = "~!@#$%^&()[].," 21 | sanitized = SanitizeFilename(fileName) 22 | if sanitized != "~!@#$%^&()[].," { 23 | t.Error("The common harmless symbols should remain valid") 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /errors_test.go: -------------------------------------------------------------------------------- 1 | package youtube 2 | 3 | import ( 4 | "strconv" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestErrors(t *testing.T) { 11 | t.Parallel() 12 | 13 | tests := []struct { 14 | err error 15 | expected string 16 | }{ 17 | {ErrUnexpectedStatusCode(404), "unexpected status code: 404"}, 18 | {ErrPlayabiltyStatus{"invalid", "for that reason"}, "cannot playback and download, status: invalid, reason: for that reason"}, 19 | {ErrPlaylistStatus{"for that reason"}, "could not load playlist: for that reason"}, 20 | } 21 | for i, tt := range tests { 22 | t.Run(strconv.Itoa(i), func(t *testing.T) { 23 | assert.EqualError(t, tt.err, tt.expected) 24 | }) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /utils_test.go: -------------------------------------------------------------------------------- 1 | package youtube 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | ) 8 | 9 | func TestGetChunks1(t *testing.T) { 10 | require := require.New(t) 11 | chunks := getChunks(13, 5) 12 | 13 | require.Len(chunks, 3) 14 | require.EqualValues(0, chunks[0].start) 15 | require.EqualValues(4, chunks[0].end) 16 | require.EqualValues(5, chunks[1].start) 17 | require.EqualValues(9, chunks[1].end) 18 | require.EqualValues(10, chunks[2].start) 19 | require.EqualValues(12, chunks[2].end) 20 | } 21 | 22 | func TestGetChunks_length(t *testing.T) { 23 | require := require.New(t) 24 | require.Len(getChunks(10, 9), 2) 25 | require.Len(getChunks(10, 10), 1) 26 | require.Len(getChunks(10, 11), 1) 27 | } 28 | -------------------------------------------------------------------------------- /artifacts.go: -------------------------------------------------------------------------------- 1 | package youtube 2 | 3 | import ( 4 | "log/slog" 5 | "os" 6 | "path/filepath" 7 | ) 8 | 9 | // destination for artifacts, used by integration tests 10 | var artifactsFolder = os.Getenv("ARTIFACTS") 11 | 12 | func writeArtifact(name string, content []byte) { 13 | // Ensure folder exists 14 | err := os.MkdirAll(artifactsFolder, os.ModePerm) 15 | if err != nil { 16 | slog.Error("unable to create artifacts folder", "path", artifactsFolder, "error", err) 17 | return 18 | } 19 | 20 | path := filepath.Join(artifactsFolder, name) 21 | err = os.WriteFile(path, content, 0600) 22 | 23 | log := slog.With("path", path) 24 | if err != nil { 25 | log.Error("unable to write artifact", "error", err) 26 | } else { 27 | log.Debug("artifact created") 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /fetch_testdata_helper.go: -------------------------------------------------------------------------------- 1 | //go:build fetch 2 | // +build fetch 3 | 4 | package youtube 5 | 6 | import ( 7 | "fmt" 8 | "io" 9 | "net/http" 10 | "os" 11 | ) 12 | 13 | func init() { 14 | FetchTestData() 15 | } 16 | 17 | // ran via go generate to fetch and update the playlist response data 18 | func FetchTestData() { 19 | f, err := os.Create(testPlaylistResponseDataFile) 20 | exitOnError(err) 21 | requestURL := fmt.Sprintf(playlistFetchURL, testPlaylistID) 22 | resp, err := http.Get(requestURL) 23 | exitOnError(err) 24 | defer resp.Body.Close() 25 | n, err := io.Copy(f, resp.Body) 26 | exitOnError(err) 27 | fmt.Printf("Successfully fetched playlist %s (%d bytes)\n", testPlaylistID, n) 28 | } 29 | 30 | func exitOnError(err error) { 31 | if err != nil { 32 | fmt.Println(err) 33 | os.Exit(1) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /video_id.go: -------------------------------------------------------------------------------- 1 | package youtube 2 | 3 | import ( 4 | "regexp" 5 | "strings" 6 | ) 7 | 8 | var videoRegexpList = []*regexp.Regexp{ 9 | regexp.MustCompile(`(?:v|embed|shorts|watch\?v)(?:=|/)([^"&?/=%]{11})`), 10 | regexp.MustCompile(`(?:=|/)([^"&?/=%]{11})`), 11 | regexp.MustCompile(`([^"&?/=%]{11})`), 12 | } 13 | 14 | // ExtractVideoID extracts the videoID from the given string 15 | func ExtractVideoID(videoID string) (string, error) { 16 | if strings.Contains(videoID, "youtu") || strings.ContainsAny(videoID, "\"?&/<%=") { 17 | for _, re := range videoRegexpList { 18 | if isMatch := re.MatchString(videoID); isMatch { 19 | subs := re.FindStringSubmatch(videoID) 20 | videoID = subs[1] 21 | } 22 | } 23 | } 24 | 25 | if strings.ContainsAny(videoID, "?&/<%=") { 26 | return "", ErrInvalidCharactersInVideoID 27 | } 28 | 29 | if len(videoID) < 10 { 30 | return "", ErrVideoIDMinLength 31 | } 32 | 33 | return videoID, nil 34 | } 35 | -------------------------------------------------------------------------------- /.github/workflows/schedule.yaml: -------------------------------------------------------------------------------- 1 | name: scheduler 2 | 3 | on: 4 | schedule: 5 | - cron: '0 0 */1 * *' 6 | 7 | jobs: 8 | test: 9 | strategy: 10 | matrix: 11 | platform: [ubuntu-24.04] 12 | go-version: [1.23.x] 13 | runs-on: ${{ matrix.platform }} 14 | name: integration tests 15 | env: 16 | GOBIN: /tmp/.bin 17 | steps: 18 | - name: Check out code into the Go module directory. 19 | uses: actions/checkout@v3 20 | 21 | - name: Install Go. 22 | uses: actions/setup-go@v4 23 | with: 24 | go-version: ${{ matrix.go-version }} 25 | 26 | - name: Install ffmpeg 27 | run: | 28 | sudo apt-get update 29 | sudo apt-get install ffmpeg 30 | 31 | - name: Run tests 32 | run: make test-integration 33 | 34 | - name: Archive artifacts 35 | uses: actions/upload-artifact@v4 36 | with: 37 | name: output 38 | path: output 39 | -------------------------------------------------------------------------------- /player_cache.go: -------------------------------------------------------------------------------- 1 | package youtube 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | const defaultCacheExpiration = time.Minute * time.Duration(5) 8 | 9 | type playerCache struct { 10 | key string 11 | expiredAt time.Time 12 | config playerConfig 13 | } 14 | 15 | // Get : get cache when it has same video id and not expired 16 | func (s playerCache) Get(key string) playerConfig { 17 | return s.GetCacheBefore(key, time.Now()) 18 | } 19 | 20 | // GetCacheBefore : can pass time for testing 21 | func (s playerCache) GetCacheBefore(key string, time time.Time) playerConfig { 22 | if key == s.key && s.expiredAt.After(time) { 23 | return s.config 24 | } 25 | return nil 26 | } 27 | 28 | // Set : set cache with default expiration 29 | func (s *playerCache) Set(key string, operations playerConfig) { 30 | s.setWithExpiredTime(key, operations, time.Now().Add(defaultCacheExpiration)) 31 | } 32 | 33 | func (s *playerCache) setWithExpiredTime(key string, config playerConfig, time time.Time) { 34 | s.key = key 35 | s.config = config 36 | s.expiredAt = time 37 | } 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Evan Lin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /transcript_test.go: -------------------------------------------------------------------------------- 1 | package youtube 2 | 3 | import ( 4 | "strconv" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestTranscript(t *testing.T) { 11 | video := &Video{ID: "9_MbW9FK1fA"} 12 | 13 | transcript, err := testClient.GetTranscript(video, "en") 14 | require.NoError(t, err, "get transcript") 15 | require.Greater(t, len(transcript), 0, "no transcript segments found") 16 | 17 | for i, segment := range transcript { 18 | index := strconv.Itoa(i) 19 | 20 | require.NotEmpty(t, segment.Text, "text "+index) 21 | require.NotEmpty(t, segment.Duration, "duration "+index) 22 | require.NotEmpty(t, segment.OffsetText, "offset "+index) 23 | 24 | if i != 0 { 25 | require.NotEmpty(t, segment.StartMs, "startMs "+index) 26 | } 27 | } 28 | 29 | t.Log(transcript.String()) 30 | } 31 | 32 | func TestTranscriptOtherLanguage(t *testing.T) { 33 | video := &Video{ID: "AXwDvYh2-uk"} 34 | 35 | transcript, err := testClient.GetTranscript(video, "id") 36 | require.NoError(t, err, "get transcript") 37 | require.Greater(t, len(transcript), 0, "no transcript segments found") 38 | 39 | for i, segment := range transcript { 40 | index := strconv.Itoa(i) 41 | 42 | require.NotEmpty(t, segment.Text, "text "+index) 43 | require.NotEmpty(t, segment.Duration, "duration "+index) 44 | require.NotEmpty(t, segment.OffsetText, "offset "+index) 45 | 46 | if i != 0 { 47 | require.NotEmpty(t, segment.StartMs, "startMs "+index) 48 | } 49 | } 50 | 51 | t.Log(transcript.String()) 52 | } 53 | -------------------------------------------------------------------------------- /cmd/youtubedr/output_format.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "encoding/xml" 6 | "fmt" 7 | "io" 8 | "strings" 9 | 10 | "github.com/spf13/pflag" 11 | ) 12 | 13 | // the selected output Format 14 | var outputFormat string 15 | 16 | const ( 17 | outputFormatPlain = "plain" 18 | outputFormatJSON = "json" 19 | outputFormatXML = "xml" 20 | ) 21 | 22 | var outputFormats = []string{outputFormatPlain, outputFormatJSON, outputFormatXML} 23 | 24 | func addFormatFlag(flagSet *pflag.FlagSet) { 25 | flagSet.StringVarP(&outputFormat, "format", "f", outputFormatPlain, "The output format ("+strings.Join(outputFormats, "/")+")") 26 | } 27 | 28 | func checkOutputFormat() error { 29 | for i := range outputFormats { 30 | if outputFormats[i] == outputFormat { 31 | return nil 32 | } 33 | } 34 | 35 | return errInvalidFormat(outputFormat) 36 | } 37 | 38 | type outputWriter func(w io.Writer) 39 | 40 | func writeOutput(w io.Writer, v interface{}, plainWriter outputWriter) error { 41 | switch outputFormat { 42 | case outputFormatPlain: 43 | plainWriter(w) 44 | return nil 45 | case outputFormatJSON: 46 | encoder := json.NewEncoder(w) 47 | encoder.SetIndent("", " ") 48 | return encoder.Encode(v) 49 | case outputFormatXML: 50 | return xml.NewEncoder(w).Encode(v) 51 | default: 52 | return errInvalidFormat(outputFormat) 53 | } 54 | } 55 | 56 | type errInvalidFormat string 57 | 58 | func (err errInvalidFormat) Error() string { 59 | return fmt.Sprintf("invalid output format: %s", outputFormat) 60 | } 61 | -------------------------------------------------------------------------------- /.github/workflows/go.yaml: -------------------------------------------------------------------------------- 1 | name: go 2 | 3 | on: 4 | - push 5 | - pull_request 6 | 7 | jobs: 8 | lint: 9 | strategy: 10 | matrix: 11 | platform: [ubuntu-24.04] 12 | go-version: [1.23.x, 1.x] 13 | runs-on: ${{ matrix.platform }} 14 | name: Linters (Static Analysis) for Go 15 | steps: 16 | - name: Checkout code into the Go module directory. 17 | uses: actions/checkout@v3 18 | 19 | - name: Install Go 20 | uses: actions/setup-go@v4 21 | with: 22 | go-version: ${{ matrix.go-version }} 23 | 24 | - name: Linting & vetting. 25 | run: make lint 26 | test: 27 | strategy: 28 | matrix: 29 | platform: [ubuntu-24.04] 30 | go-version: [1.23.x, 1.x] 31 | runs-on: ${{ matrix.platform }} 32 | name: integration tests 33 | env: 34 | GOBIN: /tmp/.bin 35 | steps: 36 | - name: Check out code into the Go module directory. 37 | uses: actions/checkout@v3 38 | 39 | - name: Install Go. 40 | uses: actions/setup-go@v4 41 | with: 42 | go-version: ${{ matrix.go-version }} 43 | 44 | - name: Install ffmpeg 45 | run: | 46 | sudo apt-get update 47 | sudo apt-get install ffmpeg 48 | 49 | - name: Run tests 50 | run: make test-integration 51 | 52 | - name: Archive artifacts 53 | uses: actions/upload-artifact@v4 54 | with: 55 | name: output 56 | path: output 57 | 58 | - name: Upload coverage report 59 | uses: codecov/codecov-action@v3 60 | with: 61 | file: coverage.out 62 | -------------------------------------------------------------------------------- /errors.go: -------------------------------------------------------------------------------- 1 | package youtube 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | const ( 8 | ErrCipherNotFound = constError("cipher not found") 9 | ErrSignatureTimestampNotFound = constError("signature timestamp not found") 10 | ErrInvalidCharactersInVideoID = constError("invalid characters in video id") 11 | ErrVideoIDMinLength = constError("the video id must be at least 10 characters long") 12 | ErrReadOnClosedResBody = constError("http: read on closed response body") 13 | ErrNotPlayableInEmbed = constError("embedding of this video has been disabled") 14 | ErrLoginRequired = constError("login required to confirm your age") 15 | ErrVideoPrivate = constError("user restricted access to this video") 16 | ErrInvalidPlaylist = constError("no playlist detected or invalid playlist ID") 17 | ) 18 | 19 | type constError string 20 | 21 | func (e constError) Error() string { 22 | return string(e) 23 | } 24 | 25 | type ErrPlayabiltyStatus struct { 26 | Status string 27 | Reason string 28 | } 29 | 30 | func (err ErrPlayabiltyStatus) Error() string { 31 | return fmt.Sprintf("cannot playback and download, status: %s, reason: %s", err.Status, err.Reason) 32 | } 33 | 34 | // ErrUnexpectedStatusCode is returned on unexpected HTTP status codes 35 | type ErrUnexpectedStatusCode int 36 | 37 | func (err ErrUnexpectedStatusCode) Error() string { 38 | return fmt.Sprintf("unexpected status code: %d", err) 39 | } 40 | 41 | type ErrPlaylistStatus struct { 42 | Reason string 43 | } 44 | 45 | func (err ErrPlaylistStatus) Error() string { 46 | return fmt.Sprintf("could not load playlist: %s", err.Reason) 47 | } 48 | -------------------------------------------------------------------------------- /player_parse.go: -------------------------------------------------------------------------------- 1 | package youtube 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "log" 8 | "os" 9 | "path/filepath" 10 | "regexp" 11 | "strings" 12 | ) 13 | 14 | type playerConfig []byte 15 | 16 | var basejsPattern = regexp.MustCompile(`(/s/player/[A-Za-z0-9_-]+/[A-Za-z0-9._/-]*/base\.js)`) 17 | 18 | func (c *Client) getPlayerConfig(ctx context.Context, videoID string) (playerConfig, error) { 19 | embedURL := fmt.Sprintf("https://youtube.com/embed/%s?hl=en", videoID) 20 | embedBody, err := c.httpGetBodyBytes(ctx, embedURL) 21 | if err != nil { 22 | return nil, err 23 | } 24 | 25 | // example: /s/player/f676c671/player_ias.vflset/en_US/base.js 26 | playerPath := string(basejsPattern.Find(embedBody)) 27 | if playerPath == "" { 28 | return nil, errors.New("unable to find basejs URL in playerConfig") 29 | } 30 | 31 | // for debugging 32 | var artifactName string 33 | if artifactsFolder != "" { 34 | parts := strings.SplitN(playerPath, "/", 5) 35 | artifactName = "player-" + parts[3] + ".js" 36 | linkName := filepath.Join(artifactsFolder, "video-"+videoID+".js") 37 | if err := os.Symlink(artifactName, linkName); err != nil { 38 | log.Printf("unable to create symlink %s: %v", linkName, err) 39 | } 40 | } 41 | 42 | config := c.playerCache.Get(playerPath) 43 | if config != nil { 44 | return config, nil 45 | } 46 | 47 | config, err = c.httpGetBodyBytes(ctx, "https://youtube.com"+playerPath) 48 | if err != nil { 49 | return nil, err 50 | } 51 | 52 | // for debugging 53 | if artifactName != "" { 54 | writeArtifact(artifactName, config) 55 | } 56 | 57 | c.playerCache.Set(playerPath, config) 58 | return config, nil 59 | } 60 | -------------------------------------------------------------------------------- /protobuilder.go: -------------------------------------------------------------------------------- 1 | package youtube 2 | 3 | import ( 4 | "bytes" 5 | "encoding/base64" 6 | "net/url" 7 | ) 8 | 9 | type ProtoBuilder struct { 10 | byteBuffer bytes.Buffer 11 | } 12 | 13 | func (pb *ProtoBuilder) ToBytes() []byte { 14 | return pb.byteBuffer.Bytes() 15 | } 16 | 17 | func (pb *ProtoBuilder) ToURLEncodedBase64() string { 18 | b64 := base64.URLEncoding.EncodeToString(pb.ToBytes()) 19 | return url.QueryEscape(b64) 20 | } 21 | 22 | func (pb *ProtoBuilder) writeVarint(val int64) error { 23 | if val == 0 { 24 | _, err := pb.byteBuffer.Write([]byte{0}) 25 | return err 26 | } 27 | for { 28 | b := byte(val & 0x7F) 29 | val >>= 7 30 | if val != 0 { 31 | b |= 0x80 32 | } 33 | _, err := pb.byteBuffer.Write([]byte{b}) 34 | if err != nil { 35 | return err 36 | } 37 | if val == 0 { 38 | break 39 | } 40 | } 41 | return nil 42 | } 43 | 44 | func (pb *ProtoBuilder) field(field int, wireType byte) error { 45 | val := int64(field<<3) | int64(wireType&0x07) 46 | return pb.writeVarint(val) 47 | } 48 | 49 | func (pb *ProtoBuilder) Varint(field int, val int64) error { 50 | err := pb.field(field, 0) 51 | if err != nil { 52 | return err 53 | } 54 | return pb.writeVarint(val) 55 | } 56 | 57 | func (pb *ProtoBuilder) String(field int, stringVal string) error { 58 | strBts := []byte(stringVal) 59 | return pb.Bytes(field, strBts) 60 | } 61 | 62 | func (pb *ProtoBuilder) Bytes(field int, bytesVal []byte) error { 63 | if err := pb.field(field, 2); err != nil { 64 | return err 65 | } 66 | 67 | if err := pb.writeVarint(int64(len(bytesVal))); err != nil { 68 | return err 69 | } 70 | 71 | _, err := pb.byteBuffer.Write(bytesVal) 72 | return err 73 | } 74 | -------------------------------------------------------------------------------- /player_cache_test.go: -------------------------------------------------------------------------------- 1 | package youtube 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | ) 7 | 8 | func TestPlayerCache(t *testing.T) { 9 | type args struct { 10 | setVideoID string 11 | getVideoID string 12 | expiredAt string 13 | getCacheAt string 14 | } 15 | tests := []struct { 16 | name string 17 | args args 18 | want []byte 19 | }{ 20 | { 21 | name: "Get cache data with video id", 22 | args: args{ 23 | setVideoID: "test", 24 | getVideoID: "test", 25 | expiredAt: "2021-01-01 00:01:00", 26 | getCacheAt: "2021-01-01 00:00:00", 27 | }, 28 | want: []byte("playerdata"), 29 | }, 30 | { 31 | name: "Get nil when cache is expired", 32 | args: args{ 33 | setVideoID: "test", 34 | getVideoID: "test", 35 | expiredAt: "2021-01-01 00:00:00", 36 | getCacheAt: "2021-01-01 00:00:00", 37 | }, 38 | want: nil, 39 | }, 40 | { 41 | name: "Get nil when video id is not cached", 42 | args: args{ 43 | setVideoID: "test", 44 | getVideoID: "not test", 45 | expiredAt: "2021-01-01 00:00:01", 46 | getCacheAt: "2021-01-01 00:00:00", 47 | }, 48 | want: nil, 49 | }, 50 | } 51 | 52 | for _, tt := range tests { 53 | t.Run(tt.name, func(t *testing.T) { 54 | s := playerCache{} 55 | timeFormat := "2006-01-02 15:04:05" 56 | expiredAt, _ := time.Parse(timeFormat, tt.args.expiredAt) 57 | s.setWithExpiredTime(tt.args.setVideoID, []byte("playerdata"), expiredAt) 58 | getCacheAt, _ := time.Parse(timeFormat, tt.args.getCacheAt) 59 | if got := s.GetCacheBefore(tt.args.getVideoID, getCacheAt); len(got) != len(tt.want) { 60 | t.Errorf("GetCacheBefore() = %v, want %v", got, tt.want) 61 | } 62 | }) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | FILES_TO_FMT ?= $(shell find . -path ./vendor -prune -o -name '*.go' -print) 2 | LOGLEVEL ?= debug 3 | 4 | ## help: Show makefile commands 5 | .PHONY: help 6 | help: Makefile 7 | @echo "---- Project: kkdai/youtube ----" 8 | @echo " Usage: make COMMAND" 9 | @echo 10 | @echo " Management Commands:" 11 | @sed -n 's/^##//p' $< | column -t -s ':' | sed -e 's/^/ /' 12 | @echo 13 | 14 | ## build: Build project 15 | .PHONY: build 16 | build: 17 | goreleaser --rm-dist 18 | 19 | ## deps: Ensures fresh go.mod and go.sum 20 | .PHONY: deps 21 | deps: 22 | go mod tidy 23 | go mod verify 24 | 25 | ## lint: Run golangci-lint check 26 | .PHONY: lint 27 | lint: 28 | command -v golangci-lint || curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $$(go env GOPATH)/bin $(GOLANGCI_LINT_VERSION) 29 | echo "golangci-lint checking..." 30 | golangci-lint run --timeout=30m ./cmd/... ./... 31 | go vet ./... 32 | 33 | ## format: Formats Go code 34 | .PHONY: format 35 | format: 36 | @echo ">> formatting code" 37 | @gofmt -s -w $(FILES_TO_FMT) 38 | 39 | ## test-unit: Run all Youtube Go unit tests 40 | .PHONY: test-unit 41 | test-unit: 42 | LOGLEVEL=${LOGLEVEL} go test -v -cover ./... 43 | 44 | ## test-integration: Run all Youtube Go integration tests 45 | .PHONY: test-integration 46 | test-integration: 47 | mkdir -p output 48 | rm -f output/* 49 | LOGLEVEL=${LOGLEVEL} ARTIFACTS=output go test -v -race -covermode=atomic -coverprofile=coverage.out -tags=integration ./... 50 | 51 | .PHONY: coverage.out 52 | coverage.out: 53 | 54 | ## clean: Clean files and downloaded videos from builds during development 55 | .PHONY: clean 56 | clean: 57 | rm -rf dist *.mp4 *.mkv 58 | -------------------------------------------------------------------------------- /playlist_test.go: -------------------------------------------------------------------------------- 1 | package youtube 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestYoutube_extractPlaylistID(t *testing.T) { 10 | tests := []struct { 11 | name string 12 | url string 13 | expectedID string 14 | expectedError error 15 | }{ 16 | { 17 | "pass-1", 18 | "https://www.youtube.com/watch?v=9UL390els7M&list=PLqAfPOrmacr963ATEroh67fbvjmTzTEx5", 19 | "PLqAfPOrmacr963ATEroh67fbvjmTzTEx5", 20 | nil, 21 | }, 22 | 23 | { 24 | "pass-2", 25 | "PLqAfPOrmacr963ATEroh67fbvjmTzTEx5", 26 | "PLqAfPOrmacr963ATEroh67fbvjmTzTEx5", 27 | nil, 28 | }, 29 | { 30 | "pass-3", 31 | "&list=PLqAfPOrmacr963ATEroh67fbvjmTzTEx5", 32 | "PLqAfPOrmacr963ATEroh67fbvjmTzTEx5", 33 | nil, 34 | }, 35 | { 36 | "pass-4 (extra params)", 37 | "https://www.youtube.com/watch?v=9UL390els7M&list=PLqAfPOrmacr963ATEroh67fbvjmTzTEx5&foo=bar&baz=babar", 38 | "PLqAfPOrmacr963ATEroh67fbvjmTzTEx5", 39 | nil, 40 | }, 41 | { 42 | "pass-5", 43 | "https://www.youtube.com/watch?v=-T4THwne8IE&list=RD-T4THwne8IE", 44 | "RD-T4THwne8IE", 45 | nil, 46 | }, 47 | { 48 | "fail-1-playlist-id-44-char", 49 | "https://www.youtube.com/watch?v=9UL390els7M&list=PLqAfPOrmacr963ATEroh67fbvjmTzTEx5X1212404244", "", 50 | ErrInvalidPlaylist, 51 | }, 52 | { 53 | "fail-2", 54 | "", "", 55 | ErrInvalidPlaylist, 56 | }, 57 | { 58 | "fail-3", 59 | "awevqevqwev", "", 60 | ErrInvalidPlaylist, 61 | }, 62 | } 63 | 64 | for _, v := range tests { 65 | t.Run(v.name, func(t *testing.T) { 66 | id, err := extractPlaylistID(v.url) 67 | 68 | assert.Equal(t, v.expectedError, err) 69 | assert.Equal(t, v.expectedID, id) 70 | }) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /cmd/youtubedr/download.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "os/exec" 8 | "strings" 9 | 10 | "github.com/spf13/cobra" 11 | ) 12 | 13 | // downloadCmd represents the download command 14 | var downloadCmd = &cobra.Command{ 15 | Use: "download", 16 | Short: "Downloads a video from youtube", 17 | Example: `youtubedr -o "Campaign Diary".mp4 https://www.youtube.com/watch\?v\=XbNghLqsVwU`, 18 | Args: cobra.ExactArgs(1), 19 | Run: func(_ *cobra.Command, args []string) { 20 | exitOnError(download(args[0])) 21 | }, 22 | } 23 | 24 | var ( 25 | ffmpegCheck error 26 | outputFile string 27 | outputDir string 28 | ) 29 | 30 | func init() { 31 | rootCmd.AddCommand(downloadCmd) 32 | 33 | downloadCmd.Flags().StringVarP(&outputFile, "filename", "o", "", "The output file, the default is genated by the video title.") 34 | downloadCmd.Flags().StringVarP(&outputDir, "directory", "d", ".", "The output directory.") 35 | addVideoSelectionFlags(downloadCmd.Flags()) 36 | } 37 | 38 | func download(id string) error { 39 | video, format, err := getVideoWithFormat(id) 40 | if err != nil { 41 | return err 42 | } 43 | 44 | log.Println("download to directory", outputDir) 45 | 46 | if strings.HasPrefix(outputQuality, "hd") { 47 | if err := checkFFMPEG(); err != nil { 48 | return err 49 | } 50 | return downloader.DownloadComposite(context.Background(), outputFile, video, outputQuality, mimetype, language) 51 | } 52 | 53 | return downloader.Download(context.Background(), video, format, outputFile) 54 | } 55 | 56 | func checkFFMPEG() error { 57 | fmt.Println("check ffmpeg is installed....") 58 | if err := exec.Command("ffmpeg", "-version").Run(); err != nil { 59 | ffmpegCheck = fmt.Errorf("please check ffmpegCheck is installed correctly") 60 | } 61 | 62 | return ffmpegCheck 63 | } 64 | -------------------------------------------------------------------------------- /cmd/youtubedr/list.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | 8 | "github.com/olekukonko/tablewriter" 9 | "github.com/spf13/cobra" 10 | ) 11 | 12 | type PlaylistInfo struct { 13 | Title string 14 | Author string 15 | Videos []VideoInfo 16 | } 17 | 18 | var ( 19 | // listCmd represents the list command 20 | listCmd = &cobra.Command{ 21 | Use: "list", 22 | Short: "Print metadata of the desired playlist", 23 | Args: cobra.ExactArgs(1), 24 | PreRunE: func(_ *cobra.Command, _ []string) error { 25 | return checkOutputFormat() 26 | }, 27 | Run: func(_ *cobra.Command, args []string) { 28 | playlist, err := getDownloader().GetPlaylist(args[0]) 29 | exitOnError(err) 30 | 31 | playlistInfo := PlaylistInfo{ 32 | Title: playlist.Title, 33 | Author: playlist.Author, 34 | } 35 | for _, v := range playlist.Videos { 36 | playlistInfo.Videos = append(playlistInfo.Videos, VideoInfo{ 37 | ID: v.ID, 38 | Title: v.Title, 39 | Author: v.Author, 40 | Duration: v.Duration.String(), 41 | }) 42 | } 43 | 44 | exitOnError(writeOutput(os.Stdout, &playlistInfo, func(w io.Writer) { 45 | writePlaylistOutput(w, &playlistInfo) 46 | })) 47 | }, 48 | } 49 | ) 50 | 51 | func writePlaylistOutput(w io.Writer, info *PlaylistInfo) { 52 | fmt.Println("Title: ", info.Title) 53 | fmt.Println("Author: ", info.Author) 54 | fmt.Println("# Videos: ", len(info.Videos)) 55 | fmt.Println() 56 | 57 | table := tablewriter.NewWriter(w) 58 | table.SetAutoWrapText(false) 59 | table.SetHeader([]string{"ID", "Author", "Title", "Duration"}) 60 | 61 | for _, vid := range info.Videos { 62 | table.Append([]string{ 63 | vid.ID, 64 | vid.Author, 65 | vid.Title, 66 | fmt.Sprintf("%v", vid.Duration), 67 | }) 68 | } 69 | table.Render() 70 | } 71 | 72 | func init() { 73 | rootCmd.AddCommand(listCmd) 74 | addFormatFlag(listCmd.Flags()) 75 | } 76 | -------------------------------------------------------------------------------- /utils.go: -------------------------------------------------------------------------------- 1 | package youtube 2 | 3 | import ( 4 | "encoding/base64" 5 | 6 | sjson "github.com/bitly/go-simplejson" 7 | ) 8 | 9 | type chunk struct { 10 | start int64 11 | end int64 12 | data chan []byte 13 | } 14 | 15 | func getChunks(totalSize, chunkSize int64) []chunk { 16 | var chunks []chunk 17 | 18 | for start := int64(0); start < totalSize; start += chunkSize { 19 | end := chunkSize + start - 1 20 | if end > totalSize-1 { 21 | end = totalSize - 1 22 | } 23 | 24 | chunks = append(chunks, chunk{start, end, make(chan []byte, 1)}) 25 | } 26 | 27 | return chunks 28 | } 29 | 30 | func getFirstKeyJSON(j *sjson.Json) *sjson.Json { 31 | m, err := j.Map() 32 | if err != nil { 33 | return j 34 | } 35 | 36 | for key := range m { 37 | return j.Get(key) 38 | } 39 | 40 | return j 41 | } 42 | 43 | func isValidJSON(j *sjson.Json) bool { 44 | b, err := j.MarshalJSON() 45 | if err != nil { 46 | return false 47 | } 48 | 49 | if len(b) <= 4 { 50 | return false 51 | } 52 | 53 | return true 54 | } 55 | 56 | func sjsonGetText(j *sjson.Json, paths ...string) string { 57 | for _, path := range paths { 58 | if isValidJSON(j.Get(path)) { 59 | j = j.Get(path) 60 | } 61 | } 62 | 63 | if text, err := j.String(); err == nil { 64 | return text 65 | } 66 | 67 | if isValidJSON(j.Get("text")) { 68 | return j.Get("text").MustString() 69 | } 70 | 71 | if p := j.Get("runs"); isValidJSON(p) { 72 | var text string 73 | 74 | for i := 0; i < len(p.MustArray()); i++ { 75 | if textNode := p.GetIndex(i).Get("text"); isValidJSON(textNode) { 76 | text += textNode.MustString() 77 | } 78 | } 79 | 80 | return text 81 | } 82 | 83 | return "" 84 | } 85 | 86 | func getContinuation(j *sjson.Json) string { 87 | return j.GetPath("continuations"). 88 | GetIndex(0).GetPath("nextContinuationData", "continuation").MustString() 89 | } 90 | 91 | func base64PadEnc(str string) string { 92 | return base64.StdEncoding.EncodeToString([]byte(str)) 93 | } 94 | 95 | func base64Enc(str string) string { 96 | return base64.RawStdEncoding.EncodeToString([]byte(str)) 97 | } 98 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/kkdai/youtube/v2 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.23.3 6 | 7 | require ( 8 | github.com/bitly/go-simplejson v0.5.1 9 | github.com/dop251/goja v0.0.0-20250125213203-5ef83b82af17 10 | github.com/mitchellh/go-homedir v1.1.0 11 | github.com/olekukonko/tablewriter v0.0.5 12 | github.com/spf13/cobra v1.9.1 13 | github.com/spf13/pflag v1.0.6 14 | github.com/spf13/viper v1.19.0 15 | github.com/stretchr/testify v1.10.0 16 | github.com/vbauerster/mpb/v5 v5.4.0 17 | golang.org/x/net v0.35.0 18 | ) 19 | 20 | require ( 21 | github.com/VividCortex/ewma v1.2.0 // indirect 22 | github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d // indirect 23 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 24 | github.com/dlclark/regexp2 v1.11.5 // indirect 25 | github.com/fsnotify/fsnotify v1.8.0 // indirect 26 | github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect 27 | github.com/google/pprof v0.0.0-20250208200701-d0013a598941 // indirect 28 | github.com/hashicorp/hcl v1.0.0 // indirect 29 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 30 | github.com/magiconair/properties v1.8.9 // indirect 31 | github.com/mattn/go-runewidth v0.0.16 // indirect 32 | github.com/mitchellh/mapstructure v1.5.0 // indirect 33 | github.com/pelletier/go-toml/v2 v2.2.3 // indirect 34 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 35 | github.com/rivo/uniseg v0.4.7 // indirect 36 | github.com/rogpeppe/go-internal v1.13.1 // indirect 37 | github.com/sagikazarmark/locafero v0.7.0 // indirect 38 | github.com/sagikazarmark/slog-shim v0.1.0 // indirect 39 | github.com/sourcegraph/conc v0.3.0 // indirect 40 | github.com/spf13/afero v1.12.0 // indirect 41 | github.com/spf13/cast v1.7.1 // indirect 42 | github.com/subosito/gotenv v1.6.0 // indirect 43 | go.uber.org/multierr v1.11.0 // indirect 44 | golang.org/x/exp v0.0.0-20250218142911-aa4b98e5adaa // indirect 45 | golang.org/x/sys v0.30.0 // indirect 46 | golang.org/x/text v0.22.0 // indirect 47 | gopkg.in/ini.v1 v1.67.0 // indirect 48 | gopkg.in/yaml.v3 v3.0.1 // indirect 49 | ) 50 | -------------------------------------------------------------------------------- /example_test.go: -------------------------------------------------------------------------------- 1 | package youtube_test 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | "strings" 8 | 9 | "github.com/kkdai/youtube/v2" 10 | ) 11 | 12 | // ExampleDownload : Example code for how to use this package for download video. 13 | func ExampleClient() { 14 | videoID := "BaW_jenozKc" 15 | client := youtube.Client{} 16 | 17 | video, err := client.GetVideo(videoID) 18 | if err != nil { 19 | panic(err) 20 | } 21 | 22 | formats := video.Formats.WithAudioChannels() // only get videos with audio 23 | stream, _, err := client.GetStream(video, &formats[0]) 24 | if err != nil { 25 | panic(err) 26 | } 27 | defer stream.Close() 28 | 29 | file, err := os.Create("video.mp4") 30 | if err != nil { 31 | panic(err) 32 | } 33 | defer file.Close() 34 | 35 | _, err = io.Copy(file, stream) 36 | if err != nil { 37 | panic(err) 38 | } 39 | } 40 | 41 | // Example usage for playlists: downloading and checking information. 42 | func ExamplePlaylist() { 43 | playlistID := "PLQZgI7en5XEgM0L1_ZcKmEzxW1sCOVZwP" 44 | client := youtube.Client{} 45 | 46 | playlist, err := client.GetPlaylist(playlistID) 47 | if err != nil { 48 | panic(err) 49 | } 50 | 51 | /* ----- Enumerating playlist videos ----- */ 52 | header := fmt.Sprintf("Playlist %s by %s", playlist.Title, playlist.Author) 53 | println(header) 54 | println(strings.Repeat("=", len(header)) + "\n") 55 | 56 | for k, v := range playlist.Videos { 57 | fmt.Printf("(%d) %s - '%s'\n", k+1, v.Author, v.Title) 58 | } 59 | 60 | /* ----- Downloading the 1st video ----- */ 61 | entry := playlist.Videos[0] 62 | video, err := client.VideoFromPlaylistEntry(entry) 63 | if err != nil { 64 | panic(err) 65 | } 66 | // Now it's fully loaded. 67 | 68 | fmt.Printf("Downloading %s by '%s'!\n", video.Title, video.Author) 69 | 70 | stream, _, err := client.GetStream(video, &video.Formats[0]) 71 | if err != nil { 72 | panic(err) 73 | } 74 | 75 | file, err := os.Create("video.mp4") 76 | 77 | if err != nil { 78 | panic(err) 79 | } 80 | 81 | defer file.Close() 82 | _, err = io.Copy(file, stream) 83 | 84 | if err != nil { 85 | panic(err) 86 | } 87 | 88 | println("Downloaded /video.mp4") 89 | } 90 | -------------------------------------------------------------------------------- /downloader/file_utils.go: -------------------------------------------------------------------------------- 1 | package downloader 2 | 3 | import ( 4 | "mime" 5 | "regexp" 6 | ) 7 | 8 | const defaultExtension = ".mov" 9 | 10 | // Rely on hardcoded canonical mime types, as the ones provided by Go aren't exhaustive [1]. 11 | // This seems to be a recurring problem for youtube downloaders, see [2]. 12 | // The implementation is based on mozilla's list [3], IANA [4] and Youtube's support [5]. 13 | // [1] https://github.com/golang/go/blob/ed7888aea6021e25b0ea58bcad3f26da2b139432/src/mime/type.go#L60 14 | // [2] https://github.com/ZiTAL/youtube-dl/blob/master/mime.types 15 | // [3] https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types 16 | // [4] https://www.iana.org/assignments/media-types/media-types.xhtml#video 17 | // [5] https://support.google.com/youtube/troubleshooter/2888402?hl=en 18 | var canonicals = map[string]string{ 19 | "video/quicktime": ".mov", 20 | "video/x-msvideo": ".avi", 21 | "video/x-matroska": ".mkv", 22 | "video/mpeg": ".mpeg", 23 | "video/webm": ".webm", 24 | "video/3gpp2": ".3g2", 25 | "video/x-flv": ".flv", 26 | "video/3gpp": ".3gp", 27 | "video/mp4": ".mp4", 28 | "video/ogg": ".ogv", 29 | "video/mp2t": ".ts", 30 | } 31 | 32 | func pickIdealFileExtension(mediaType string) string { 33 | mediaType, _, err := mime.ParseMediaType(mediaType) 34 | if err != nil { 35 | return defaultExtension 36 | } 37 | 38 | if extension, ok := canonicals[mediaType]; ok { 39 | return extension 40 | } 41 | 42 | // Our last resort is to ask the operating system, but these give multiple results and are rarely canonical. 43 | extensions, err := mime.ExtensionsByType(mediaType) 44 | if err != nil || extensions == nil { 45 | return defaultExtension 46 | } 47 | 48 | return extensions[0] 49 | } 50 | 51 | func SanitizeFilename(fileName string) string { 52 | // Characters not allowed on mac 53 | // :/ 54 | // Characters not allowed on linux 55 | // / 56 | // Characters not allowed on windows 57 | // <>:"/\|?* 58 | 59 | // Ref https://docs.microsoft.com/en-us/windows/win32/fileio/naming-a-file#naming-conventions 60 | 61 | fileName = regexp.MustCompile(`[:/<>\:"\\|?*]`).ReplaceAllString(fileName, "") 62 | fileName = regexp.MustCompile(`\s+`).ReplaceAllString(fileName, " ") 63 | 64 | return fileName 65 | } 66 | -------------------------------------------------------------------------------- /cmd/youtubedr/root.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | homedir "github.com/mitchellh/go-homedir" 8 | "github.com/spf13/cobra" 9 | "github.com/spf13/viper" 10 | ) 11 | 12 | var ( 13 | cfgFile string 14 | logLevel string 15 | ) 16 | 17 | // rootCmd represents the base command when called without any subcommands 18 | var rootCmd = &cobra.Command{ 19 | Use: os.Args[0], 20 | Short: "Youtube downloader written in Golang", 21 | Long: `This tool is meant to be used to download CC0 licenced content, we do not support nor recommend using it for illegal activities. 22 | 23 | Use the HTTP_PROXY environment variable to set a HTTP or SOCSK5 proxy. The proxy type is determined by the URL scheme. 24 | "http", "https", and "socks5" are supported. If the scheme is empty, "http" is assumed.`, 25 | // Uncomment the following line if your bare application 26 | // has an action associated with it: 27 | // Run: func(cmd *cobra.Command, args []string) { }, 28 | } 29 | 30 | func init() { 31 | cobra.OnInitialize(initConfig) 32 | 33 | // Here you will define your flags and configuration settings. 34 | // Cobra supports persistent flags, which, if defined here, 35 | // will be global for your application. 36 | 37 | rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.youtubedr.yaml)") 38 | rootCmd.PersistentFlags().StringVar(&logLevel, "log-level", "info", "Set log level (error/warn/info/debug)") 39 | rootCmd.PersistentFlags().BoolVar(&insecureSkipVerify, "insecure", false, "Skip TLS server certificate verification") 40 | } 41 | 42 | // initConfig reads in config file and ENV variables if set. 43 | func initConfig() { 44 | if cfgFile != "" { 45 | // Use config file from the flag. 46 | viper.SetConfigFile(cfgFile) 47 | } else { 48 | // Find home directory. 49 | home, err := homedir.Dir() 50 | if err != nil { 51 | fmt.Println(err) 52 | os.Exit(1) 53 | } 54 | 55 | // Search config in home directory with name ".youtube" (without extension). 56 | viper.AddConfigPath(home) 57 | viper.SetConfigName(".youtubedr") 58 | } 59 | 60 | viper.AutomaticEnv() // read in environment variables that match 61 | 62 | // If a config file is found, read it in. 63 | if err := viper.ReadInConfig(); err == nil { 64 | fmt.Println("Using config file:", viper.ConfigFileUsed()) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /cmd/youtubedr/downloader.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/tls" 5 | "fmt" 6 | "net" 7 | "net/http" 8 | "net/url" 9 | "strconv" 10 | "time" 11 | 12 | "github.com/spf13/pflag" 13 | "golang.org/x/net/http/httpproxy" 14 | 15 | "github.com/kkdai/youtube/v2" 16 | ytdl "github.com/kkdai/youtube/v2/downloader" 17 | ) 18 | 19 | var ( 20 | insecureSkipVerify bool // skip TLS server validation 21 | outputQuality string // itag number or quality string 22 | mimetype string 23 | language string 24 | downloader *ytdl.Downloader 25 | ) 26 | 27 | func addVideoSelectionFlags(flagSet *pflag.FlagSet) { 28 | flagSet.StringVarP(&outputQuality, "quality", "q", "medium", "The itag number or quality label (hd720, medium)") 29 | flagSet.StringVarP(&mimetype, "mimetype", "m", "", "Mime-Type to filter (mp4, webm, av01, avc1) - applicable if --quality used is quality label") 30 | flagSet.StringVarP(&language, "language", "l", "", "Language to filter") 31 | } 32 | 33 | func getDownloader() *ytdl.Downloader { 34 | if downloader != nil { 35 | return downloader 36 | } 37 | 38 | proxyFunc := httpproxy.FromEnvironment().ProxyFunc() 39 | httpTransport := &http.Transport{ 40 | // Proxy: http.ProxyFromEnvironment() does not work. Why? 41 | Proxy: func(r *http.Request) (uri *url.URL, err error) { 42 | return proxyFunc(r.URL) 43 | }, 44 | IdleConnTimeout: 60 * time.Second, 45 | TLSHandshakeTimeout: 10 * time.Second, 46 | ExpectContinueTimeout: 1 * time.Second, 47 | ForceAttemptHTTP2: true, 48 | DialContext: (&net.Dialer{ 49 | Timeout: 30 * time.Second, 50 | KeepAlive: 30 * time.Second, 51 | }).DialContext, 52 | } 53 | 54 | youtube.SetLogLevel(logLevel) 55 | 56 | if insecureSkipVerify { 57 | youtube.Logger.Info("Skip server certificate verification") 58 | httpTransport.TLSClientConfig = &tls.Config{ 59 | InsecureSkipVerify: true, 60 | } 61 | } 62 | 63 | downloader = &ytdl.Downloader{ 64 | OutputDir: outputDir, 65 | } 66 | downloader.HTTPClient = &http.Client{Transport: httpTransport} 67 | 68 | return downloader 69 | } 70 | 71 | func getVideoWithFormat(videoID string) (*youtube.Video, *youtube.Format, error) { 72 | dl := getDownloader() 73 | video, err := dl.GetVideo(videoID) 74 | if err != nil { 75 | return nil, nil, err 76 | } 77 | 78 | itag, _ := strconv.Atoi(outputQuality) 79 | formats := video.Formats 80 | 81 | if language != "" { 82 | formats = formats.Language(language) 83 | } 84 | if mimetype != "" { 85 | formats = formats.Type(mimetype) 86 | } 87 | if outputQuality != "" { 88 | formats = formats.Quality(outputQuality) 89 | } 90 | if itag > 0 { 91 | formats = formats.Itag(itag) 92 | } 93 | if formats == nil { 94 | return nil, nil, fmt.Errorf("unable to find the specified format") 95 | } 96 | 97 | formats.Sort() 98 | 99 | // select the first format 100 | return video, &formats[0], nil 101 | } 102 | -------------------------------------------------------------------------------- /format_list_test.go: -------------------------------------------------------------------------------- 1 | package youtube 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | type filter struct { 10 | Quality string 11 | ItagNo int 12 | MimeType string 13 | Language string 14 | AudioChannels int 15 | } 16 | 17 | func (list FormatList) Filter(filter filter) FormatList { 18 | if filter.ItagNo > 0 { 19 | list = list.Itag(filter.ItagNo) 20 | } 21 | if filter.AudioChannels > 0 { 22 | list = list.AudioChannels(filter.AudioChannels) 23 | } 24 | if filter.Quality != "" { 25 | list = list.Quality(filter.Quality) 26 | } 27 | if filter.MimeType != "" { 28 | list = list.Type(filter.MimeType) 29 | } 30 | if filter.Language != "" { 31 | list = list.Language(filter.Language) 32 | } 33 | return list 34 | } 35 | 36 | func TestFormatList_Filter(t *testing.T) { 37 | t.Parallel() 38 | 39 | format1 := Format{ 40 | ItagNo: 1, 41 | Quality: "medium", 42 | QualityLabel: "360p", 43 | } 44 | 45 | format2 := Format{ 46 | ItagNo: 2, 47 | Quality: "large", 48 | QualityLabel: "480p", 49 | MimeType: `video/mp4; codecs="avc1.42001E, mp4a.40.2"`, 50 | AudioChannels: 1, 51 | } 52 | 53 | formatStereo := Format{ 54 | ItagNo: 3, 55 | URL: "stereo", 56 | AudioChannels: 2, 57 | } 58 | 59 | list := FormatList{ 60 | format1, 61 | format2, 62 | formatStereo, 63 | } 64 | 65 | tests := []struct { 66 | name string 67 | args filter 68 | want []Format 69 | }{ 70 | { 71 | name: "empty list with quality small", 72 | args: filter{ 73 | Quality: "small", 74 | }, 75 | }, 76 | { 77 | name: "empty list with other itagNo", 78 | args: filter{ 79 | ItagNo: 99, 80 | }, 81 | }, 82 | { 83 | name: "empty list with other mimeType", 84 | args: filter{ 85 | MimeType: "other", 86 | }, 87 | }, 88 | { 89 | name: "empty list with other audioChannels", 90 | args: filter{ 91 | AudioChannels: 7, 92 | }, 93 | }, 94 | { 95 | name: "audioChannels stereo", 96 | args: filter{ 97 | AudioChannels: formatStereo.AudioChannels, 98 | }, 99 | want: []Format{formatStereo}, 100 | }, 101 | { 102 | name: "find by medium quality", 103 | args: filter{ 104 | Quality: "medium", 105 | }, 106 | want: []Format{format1}, 107 | }, 108 | { 109 | name: "find by 480p", 110 | args: filter{ 111 | Quality: "480p", 112 | }, 113 | want: []Format{format2}, 114 | }, 115 | } 116 | 117 | for _, tt := range tests { 118 | t.Run(tt.name, func(t *testing.T) { 119 | formats := list.Filter(tt.args) 120 | 121 | if tt.want == nil { 122 | assert.Empty(t, formats) 123 | } else { 124 | assert.Equal(t, tt.want, []Format(formats)) 125 | } 126 | }) 127 | } 128 | } 129 | 130 | func TestFormatList_Sort(t *testing.T) { 131 | t.Parallel() 132 | 133 | list := FormatList{ 134 | {Width: 512}, 135 | {Width: 768, MimeType: "mp4"}, 136 | {Width: 768, MimeType: "opus"}, 137 | } 138 | 139 | list.Sort() 140 | 141 | assert.Equal(t, FormatList{ 142 | {Width: 768, MimeType: "mp4"}, 143 | {Width: 768, MimeType: "opus"}, 144 | {Width: 512}, 145 | }, list) 146 | } 147 | -------------------------------------------------------------------------------- /cmd/youtubedr/info.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | "strconv" 8 | "strings" 9 | 10 | "github.com/olekukonko/tablewriter" 11 | "github.com/spf13/cobra" 12 | ) 13 | 14 | // Define two new struct in local scope 15 | type VideoFormat struct { 16 | Itag int 17 | FPS int 18 | VideoQuality string 19 | AudioQuality string 20 | AudioChannels int 21 | Language string 22 | Size int64 23 | Bitrate int 24 | MimeType string 25 | } 26 | 27 | type VideoInfo struct { 28 | ID string 29 | Title string 30 | Author string 31 | Duration string 32 | Description string 33 | Formats []VideoFormat 34 | } 35 | 36 | // infoCmd represents the info command 37 | var infoCmd = &cobra.Command{ 38 | Use: "info", 39 | Short: "Print metadata of the desired video", 40 | Args: cobra.ExactArgs(1), 41 | PreRunE: func(_ *cobra.Command, _ []string) error { 42 | return checkOutputFormat() 43 | }, 44 | Run: func(_ *cobra.Command, args []string) { 45 | video, err := getDownloader().GetVideo(args[0]) 46 | exitOnError(err) 47 | 48 | videoInfo := VideoInfo{ 49 | Title: video.Title, 50 | Author: video.Author, 51 | Duration: video.Duration.String(), 52 | Description: video.Description, 53 | } 54 | 55 | for _, format := range video.Formats { 56 | bitrate := format.AverageBitrate 57 | if bitrate == 0 { 58 | // Some formats don't have the average bitrate 59 | bitrate = format.Bitrate 60 | } 61 | 62 | size := format.ContentLength 63 | if size == 0 { 64 | // Some formats don't have this information 65 | size = int64(float64(bitrate) * video.Duration.Seconds() / 8) 66 | } 67 | 68 | videoInfo.Formats = append(videoInfo.Formats, VideoFormat{ 69 | Itag: format.ItagNo, 70 | FPS: format.FPS, 71 | VideoQuality: format.QualityLabel, 72 | AudioQuality: strings.ToLower(strings.TrimPrefix(format.AudioQuality, "AUDIO_QUALITY_")), 73 | AudioChannels: format.AudioChannels, 74 | Size: size, 75 | Bitrate: bitrate, 76 | MimeType: format.MimeType, 77 | Language: format.LanguageDisplayName(), 78 | }) 79 | } 80 | 81 | exitOnError(writeOutput(os.Stdout, &videoInfo, func(w io.Writer) { 82 | writeInfoOutput(w, &videoInfo) 83 | })) 84 | }, 85 | } 86 | 87 | func writeInfoOutput(w io.Writer, info *VideoInfo) { 88 | fmt.Println("Title: ", info.Title) 89 | fmt.Println("Author: ", info.Author) 90 | fmt.Println("Duration: ", info.Duration) 91 | if printDescription { 92 | fmt.Println("Description:", info.Description) 93 | } 94 | fmt.Println() 95 | 96 | table := tablewriter.NewWriter(w) 97 | table.SetAutoWrapText(false) 98 | table.SetHeader([]string{ 99 | "itag", 100 | "fps", 101 | "video\nquality", 102 | "audio\nquality", 103 | "audio\nchannels", 104 | "size [MB]", 105 | "bitrate", 106 | "MimeType", 107 | "language", 108 | }) 109 | 110 | for _, format := range info.Formats { 111 | table.Append([]string{ 112 | strconv.Itoa(format.Itag), 113 | strconv.Itoa(format.FPS), 114 | format.VideoQuality, 115 | format.AudioQuality, 116 | strconv.Itoa(format.AudioChannels), 117 | fmt.Sprintf("%0.1f", float64(format.Size)/1024/1024), 118 | strconv.Itoa(format.Bitrate), 119 | format.MimeType, 120 | format.Language, 121 | }) 122 | } 123 | 124 | table.Render() 125 | } 126 | 127 | var printDescription bool 128 | 129 | func init() { 130 | rootCmd.AddCommand(infoCmd) 131 | addFormatFlag(infoCmd.Flags()) 132 | infoCmd.Flags().BoolVarP(&printDescription, "description", "d", false, "Print description") 133 | } 134 | -------------------------------------------------------------------------------- /video.go: -------------------------------------------------------------------------------- 1 | package youtube 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "net/url" 8 | "regexp" 9 | "sort" 10 | "strconv" 11 | "strings" 12 | "time" 13 | ) 14 | 15 | type Video struct { 16 | ID string 17 | Title string 18 | Description string 19 | Author string 20 | ChannelID string 21 | ChannelHandle string 22 | Views int 23 | Duration time.Duration 24 | PublishDate time.Time 25 | Formats FormatList 26 | Thumbnails Thumbnails 27 | DASHManifestURL string // URI of the DASH manifest file 28 | HLSManifestURL string // URI of the HLS manifest file 29 | CaptionTracks []CaptionTrack 30 | } 31 | 32 | const dateFormat = "2006-01-02" 33 | 34 | func (v *Video) parseVideoInfo(body []byte) error { 35 | var prData playerResponseData 36 | if err := json.Unmarshal(body, &prData); err != nil { 37 | return fmt.Errorf("unable to parse player response JSON: %w", err) 38 | } 39 | 40 | if err := v.isVideoFromInfoDownloadable(prData); err != nil { 41 | return err 42 | } 43 | 44 | return v.extractDataFromPlayerResponse(prData) 45 | } 46 | 47 | func (v *Video) isVideoFromInfoDownloadable(prData playerResponseData) error { 48 | return v.isVideoDownloadable(prData, false) 49 | } 50 | 51 | var playerResponsePattern = regexp.MustCompile(`var ytInitialPlayerResponse\s*=\s*(\{.+?\});`) 52 | 53 | func (v *Video) parseVideoPage(body []byte) error { 54 | initialPlayerResponse := playerResponsePattern.FindSubmatch(body) 55 | if len(initialPlayerResponse) < 2 { 56 | return errors.New("no ytInitialPlayerResponse found in the server's answer") 57 | } 58 | 59 | var prData playerResponseData 60 | if err := json.Unmarshal(initialPlayerResponse[1], &prData); err != nil { 61 | return fmt.Errorf("unable to parse player response JSON: %w", err) 62 | } 63 | 64 | if err := v.isVideoFromPageDownloadable(prData); err != nil { 65 | return err 66 | } 67 | 68 | return v.extractDataFromPlayerResponse(prData) 69 | } 70 | 71 | func (v *Video) isVideoFromPageDownloadable(prData playerResponseData) error { 72 | return v.isVideoDownloadable(prData, true) 73 | } 74 | 75 | func (v *Video) isVideoDownloadable(prData playerResponseData, isVideoPage bool) error { 76 | // Check if video is downloadable 77 | switch prData.PlayabilityStatus.Status { 78 | case "OK": 79 | return nil 80 | case "LOGIN_REQUIRED": 81 | // for some reason they use same status message for age-restricted and private videos 82 | if strings.HasPrefix(prData.PlayabilityStatus.Reason, "This video is private") { 83 | return ErrVideoPrivate 84 | } 85 | return ErrLoginRequired 86 | } 87 | 88 | if !isVideoPage && !prData.PlayabilityStatus.PlayableInEmbed { 89 | return ErrNotPlayableInEmbed 90 | } 91 | 92 | return &ErrPlayabiltyStatus{ 93 | Status: prData.PlayabilityStatus.Status, 94 | Reason: prData.PlayabilityStatus.Reason, 95 | } 96 | } 97 | 98 | func (v *Video) extractDataFromPlayerResponse(prData playerResponseData) error { 99 | v.Title = prData.VideoDetails.Title 100 | v.Description = prData.VideoDetails.ShortDescription 101 | v.Author = prData.VideoDetails.Author 102 | v.Thumbnails = prData.VideoDetails.Thumbnail.Thumbnails 103 | v.ChannelID = prData.VideoDetails.ChannelID 104 | v.CaptionTracks = prData.Captions.PlayerCaptionsTracklistRenderer.CaptionTracks 105 | 106 | if views, _ := strconv.Atoi(prData.VideoDetails.ViewCount); views > 0 { 107 | v.Views = views 108 | } 109 | 110 | if seconds, _ := strconv.Atoi(prData.VideoDetails.LengthSeconds); seconds > 0 { 111 | v.Duration = time.Duration(seconds) * time.Second 112 | } 113 | 114 | if seconds, _ := strconv.Atoi(prData.Microformat.PlayerMicroformatRenderer.LengthSeconds); seconds > 0 { 115 | v.Duration = time.Duration(seconds) * time.Second 116 | } 117 | 118 | if str := prData.Microformat.PlayerMicroformatRenderer.PublishDate; str != "" { 119 | v.PublishDate, _ = time.Parse(dateFormat, str) 120 | } 121 | 122 | if profileURL, err := url.Parse(prData.Microformat.PlayerMicroformatRenderer.OwnerProfileURL); err == nil && len(profileURL.Path) > 1 { 123 | v.ChannelHandle = profileURL.Path[1:] 124 | } 125 | 126 | // Assign Streams 127 | v.Formats = append(prData.StreamingData.Formats, prData.StreamingData.AdaptiveFormats...) 128 | if len(v.Formats) == 0 { 129 | return errors.New("no formats found in the server's answer") 130 | } 131 | 132 | // Sort formats by bitrate 133 | sort.SliceStable(v.Formats, v.SortBitrateDesc) 134 | 135 | v.HLSManifestURL = prData.StreamingData.HlsManifestURL 136 | v.DASHManifestURL = prData.StreamingData.DashManifestURL 137 | 138 | return nil 139 | } 140 | 141 | func (v *Video) SortBitrateDesc(i int, j int) bool { 142 | return v.Formats[i].Bitrate > v.Formats[j].Bitrate 143 | } 144 | 145 | func (v *Video) SortBitrateAsc(i int, j int) bool { 146 | return v.Formats[i].Bitrate < v.Formats[j].Bitrate 147 | } 148 | -------------------------------------------------------------------------------- /format_list.go: -------------------------------------------------------------------------------- 1 | package youtube 2 | 3 | import ( 4 | "sort" 5 | "strconv" 6 | "strings" 7 | ) 8 | 9 | type FormatList []Format 10 | 11 | // Type returns a new FormatList filtered by itag 12 | func (list FormatList) Select(f func(Format) bool) (result FormatList) { 13 | for i := range list { 14 | if f(list[i]) { 15 | result = append(result, list[i]) 16 | } 17 | } 18 | return result 19 | } 20 | 21 | // Type returns a new FormatList filtered by itag 22 | func (list FormatList) Itag(itagNo int) FormatList { 23 | return list.Select(func(f Format) bool { 24 | return f.ItagNo == itagNo 25 | }) 26 | } 27 | 28 | // Type returns a new FormatList filtered by mime type 29 | func (list FormatList) Type(value string) FormatList { 30 | return list.Select(func(f Format) bool { 31 | return strings.Contains(f.MimeType, value) 32 | }) 33 | } 34 | 35 | // Type returns a new FormatList filtered by display name 36 | func (list FormatList) Language(displayName string) FormatList { 37 | return list.Select(func(f Format) bool { 38 | return f.LanguageDisplayName() == displayName 39 | }) 40 | } 41 | 42 | // Quality returns a new FormatList filtered by quality, quality label or itag, 43 | // but not audio quality 44 | func (list FormatList) Quality(quality string) FormatList { 45 | itag, _ := strconv.Atoi(quality) 46 | 47 | return list.Select(func(f Format) bool { 48 | return itag == f.ItagNo || strings.Contains(f.Quality, quality) || strings.Contains(f.QualityLabel, quality) 49 | }) 50 | } 51 | 52 | // AudioChannels returns a new FormatList filtered by the matching AudioChannels 53 | func (list FormatList) AudioChannels(n int) FormatList { 54 | return list.Select(func(f Format) bool { 55 | return f.AudioChannels == n 56 | }) 57 | } 58 | 59 | // AudioChannels returns a new FormatList filtered by the matching AudioChannels 60 | func (list FormatList) WithAudioChannels() FormatList { 61 | return list.Select(func(f Format) bool { 62 | return f.AudioChannels > 0 63 | }) 64 | } 65 | 66 | // FilterQuality reduces the format list to formats matching the quality 67 | func (v *Video) FilterQuality(quality string) { 68 | v.Formats = v.Formats.Quality(quality) 69 | v.Formats.Sort() 70 | } 71 | 72 | // Sort sorts all formats fields 73 | func (list FormatList) Sort() { 74 | sort.SliceStable(list, func(i, j int) bool { 75 | return sortFormat(i, j, list) 76 | }) 77 | } 78 | 79 | // sortFormat sorts video by resolution, FPS, codec (av01, vp9, avc1), bitrate 80 | // sorts audio by default, codec (mp4, opus), channels, bitrate, sample rate 81 | func sortFormat(i int, j int, formats FormatList) bool { 82 | 83 | // Sort by Width 84 | if formats[i].Width == formats[j].Width { 85 | // Format 137 downloads slowly, give it less priority 86 | // see https://github.com/kkdai/youtube/pull/171 87 | switch 137 { 88 | case formats[i].ItagNo: 89 | return false 90 | case formats[j].ItagNo: 91 | return true 92 | } 93 | 94 | // Sort by FPS 95 | if formats[i].FPS == formats[j].FPS { 96 | if formats[i].FPS == 0 && formats[i].AudioChannels > 0 && formats[j].AudioChannels > 0 { 97 | // Audio 98 | // Sort by default 99 | if (formats[i].AudioTrack == nil && formats[j].AudioTrack == nil) || (formats[i].AudioTrack != nil && formats[j].AudioTrack != nil && formats[i].AudioTrack.AudioIsDefault == formats[j].AudioTrack.AudioIsDefault) { 100 | // Sort by codec 101 | codec := map[int]int{} 102 | for _, index := range []int{i, j} { 103 | if strings.Contains(formats[index].MimeType, "mp4") { 104 | codec[index] = 1 105 | } else if strings.Contains(formats[index].MimeType, "opus") { 106 | codec[index] = 2 107 | } 108 | } 109 | if codec[i] == codec[j] { 110 | // Sort by Audio Channel 111 | if formats[i].AudioChannels == formats[j].AudioChannels { 112 | // Sort by Audio Bitrate 113 | if formats[i].Bitrate == formats[j].Bitrate { 114 | // Sort by Audio Sample Rate 115 | return formats[i].AudioSampleRate > formats[j].AudioSampleRate 116 | } 117 | return formats[i].Bitrate > formats[j].Bitrate 118 | } 119 | return formats[i].AudioChannels > formats[j].AudioChannels 120 | } 121 | return codec[i] < codec[j] 122 | } else if formats[i].AudioTrack != nil && formats[i].AudioTrack.AudioIsDefault { 123 | return true 124 | } 125 | return false 126 | } 127 | // Video 128 | // Sort by codec 129 | codec := map[int]int{} 130 | for _, index := range []int{i, j} { 131 | if strings.Contains(formats[index].MimeType, "av01") { 132 | codec[index] = 1 133 | } else if strings.Contains(formats[index].MimeType, "vp9") { 134 | codec[index] = 2 135 | } else if strings.Contains(formats[index].MimeType, "avc1") { 136 | codec[index] = 3 137 | } 138 | } 139 | if codec[i] == codec[j] { 140 | // Sort by Audio Bitrate 141 | return formats[i].Bitrate > formats[j].Bitrate 142 | } 143 | return codec[i] < codec[j] 144 | } 145 | return formats[i].FPS > formats[j].FPS 146 | } 147 | return formats[i].Width > formats[j].Width 148 | } 149 | -------------------------------------------------------------------------------- /video_test.go: -------------------------------------------------------------------------------- 1 | package youtube 2 | 3 | import ( 4 | "io" 5 | "testing" 6 | "time" 7 | 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func ExampleClient_GetStream() { 12 | video, err := testClient.GetVideo("https://www.youtube.com/watch?v=9_MbW9FK1fA") 13 | if err != nil { 14 | panic(err) 15 | } 16 | 17 | // Typically youtube only provides separate streams for video and audio. 18 | // If you want audio and video combined, take a look a the downloader package. 19 | formats := video.Formats.Quality("medium") 20 | reader, _, err := testClient.GetStream(video, &formats[0]) 21 | if err != nil { 22 | panic(err) 23 | } 24 | 25 | // do something with the reader 26 | 27 | reader.Close() 28 | } 29 | 30 | func TestExtractVideoId(t *testing.T) { 31 | testcases := []struct { 32 | input string 33 | expectedID string 34 | }{ 35 | { 36 | input: "https://www.youtube.com/watch?v=9_MbW9FK1fA", 37 | expectedID: "9_MbW9FK1fA", 38 | }, 39 | { 40 | input: "https://www.youtube.com/watch?v=-D2IEZbn5Xs&list=PLQUru4nFApg_ocT-XYXFg50_l4V2BRtwi&index=15", 41 | expectedID: "-D2IEZbn5Xs", 42 | }, 43 | } 44 | 45 | for _, tc := range testcases { 46 | t.Run(tc.input, func(t *testing.T) { 47 | id, err := ExtractVideoID(tc.input) 48 | require.NoError(t, err) 49 | require.Equal(t, tc.expectedID, id) 50 | }) 51 | } 52 | 53 | } 54 | 55 | func TestSimpleTest(t *testing.T) { 56 | video, err := testClient.GetVideo("https://www.youtube.com/watch?v=9_MbW9FK1fA") 57 | require.NoError(t, err, "get body") 58 | 59 | _, err = testClient.GetTranscript(video, "en") 60 | require.NoError(t, err, "get transcript") 61 | 62 | // Typically youtube only provides separate streams for video and audio. 63 | // If you want audio and video combined, take a look a the downloader package. 64 | formats := video.Formats.Quality("hd1080") 65 | require.NotEmpty(t, formats) 66 | 67 | start := time.Now() 68 | reader, _, err := testClient.GetStream(video, &formats[0]) 69 | require.NoError(t, err, "get stream") 70 | 71 | t.Log("Duration Milliseconds: ", time.Since(start).Milliseconds()) 72 | 73 | // do something with the reader 74 | b, err := io.ReadAll(reader) 75 | require.NoError(t, err, "read body") 76 | 77 | t.Log("Downloaded ", len(b)) 78 | } 79 | 80 | func TestDownload_Regular(t *testing.T) { 81 | testcases := []struct { 82 | name string 83 | url string 84 | outputFile string 85 | itagNo int 86 | quality string 87 | }{ 88 | { 89 | // Video from issue #25 90 | name: "default", 91 | url: "https://www.youtube.com/watch?v=54e6lBE3BoQ", 92 | outputFile: "default_test.mp4", 93 | quality: "", 94 | }, 95 | { 96 | // Video from issue #25 97 | name: "quality:medium", 98 | url: "https://www.youtube.com/watch?v=54e6lBE3BoQ", 99 | outputFile: "medium_test.mp4", 100 | quality: "medium", 101 | }, 102 | { 103 | name: "without-filename", 104 | url: "https://www.youtube.com/watch?v=n3kPvBCYT3E", 105 | }, 106 | { 107 | name: "Format", 108 | url: "https://www.youtube.com/watch?v=54e6lBE3BoQ", 109 | outputFile: "muxedstream_test.mp4", 110 | itagNo: 18, 111 | }, 112 | { 113 | name: "AdaptiveFormat_video", 114 | url: "https://www.youtube.com/watch?v=54e6lBE3BoQ", 115 | outputFile: "adaptiveStream_video_test.m4v", 116 | itagNo: 134, 117 | }, 118 | { 119 | name: "AdaptiveFormat_audio", 120 | url: "https://www.youtube.com/watch?v=54e6lBE3BoQ", 121 | outputFile: "adaptiveStream_audio_test.m4a", 122 | itagNo: 140, 123 | }, 124 | { 125 | // Video from issue #138 126 | name: "NotPlayableInEmbed", 127 | url: "https://www.youtube.com/watch?v=gr-IqFcNExY", 128 | outputFile: "not_playable_in_embed.mp4", 129 | }, 130 | } 131 | for _, tc := range testcases { 132 | t.Run(tc.name, func(t *testing.T) { 133 | require := require.New(t) 134 | 135 | video, err := testClient.GetVideo(tc.url) 136 | require.NoError(err) 137 | 138 | formats := video.Formats 139 | if tc.itagNo > 0 { 140 | formats = formats.Itag(tc.itagNo) 141 | require.NotEmpty(formats) 142 | } 143 | 144 | url, err := testClient.GetStreamURL(video, &video.Formats[0]) 145 | require.NoError(err) 146 | require.NotEmpty(url) 147 | }) 148 | } 149 | } 150 | 151 | func TestDownload_WhenPlayabilityStatusIsNotOK(t *testing.T) { 152 | testcases := []struct { 153 | issue string 154 | videoID string 155 | err string 156 | }{ 157 | { 158 | issue: "issue#65", 159 | videoID: "9ja-K2FslBU", 160 | err: `status: ERROR`, 161 | }, 162 | { 163 | issue: "issue#59", 164 | videoID: "yZIXLfi8CZQ", 165 | err: ErrVideoPrivate.Error(), 166 | }, 167 | } 168 | 169 | for _, tc := range testcases { 170 | t.Run(tc.issue, func(t *testing.T) { 171 | _, err := testClient.GetVideo(tc.videoID) 172 | require.Error(t, err) 173 | require.Contains(t, err.Error(), tc.err) 174 | }) 175 | } 176 | } 177 | 178 | // See https://github.com/kkdai/youtube/pull/238 179 | func TestDownload_SensitiveContent(t *testing.T) { 180 | _, err := testClient.GetVideo("MS91knuzoOA") 181 | require.EqualError(t, err, "can't bypass age restriction: embedding of this video has been disabled") 182 | } 183 | -------------------------------------------------------------------------------- /response_data.go: -------------------------------------------------------------------------------- 1 | package youtube 2 | 3 | type playerResponseData struct { 4 | Captions struct { 5 | PlayerCaptionsTracklistRenderer struct { 6 | CaptionTracks []CaptionTrack `json:"captionTracks"` 7 | AudioTracks []struct { 8 | CaptionTrackIndices []int `json:"captionTrackIndices"` 9 | } `json:"audioTracks"` 10 | TranslationLanguages []struct { 11 | LanguageCode string `json:"languageCode"` 12 | LanguageName struct { 13 | SimpleText string `json:"simpleText"` 14 | } `json:"languageName"` 15 | } `json:"translationLanguages"` 16 | DefaultAudioTrackIndex int `json:"defaultAudioTrackIndex"` 17 | } `json:"playerCaptionsTracklistRenderer"` 18 | } `json:"captions"` 19 | 20 | PlayabilityStatus struct { 21 | Status string `json:"status"` 22 | Reason string `json:"reason"` 23 | PlayableInEmbed bool `json:"playableInEmbed"` 24 | Miniplayer struct { 25 | MiniplayerRenderer struct { 26 | PlaybackMode string `json:"playbackMode"` 27 | } `json:"miniplayerRenderer"` 28 | } `json:"miniplayer"` 29 | ContextParams string `json:"contextParams"` 30 | } `json:"playabilityStatus"` 31 | StreamingData struct { 32 | ExpiresInSeconds string `json:"expiresInSeconds"` 33 | Formats []Format `json:"formats"` 34 | AdaptiveFormats []Format `json:"adaptiveFormats"` 35 | DashManifestURL string `json:"dashManifestUrl"` 36 | HlsManifestURL string `json:"hlsManifestUrl"` 37 | } `json:"streamingData"` 38 | VideoDetails struct { 39 | VideoID string `json:"videoId"` 40 | Title string `json:"title"` 41 | LengthSeconds string `json:"lengthSeconds"` 42 | Keywords []string `json:"keywords"` 43 | ChannelID string `json:"channelId"` 44 | IsOwnerViewing bool `json:"isOwnerViewing"` 45 | ShortDescription string `json:"shortDescription"` 46 | IsCrawlable bool `json:"isCrawlable"` 47 | Thumbnail struct { 48 | Thumbnails []Thumbnail `json:"thumbnails"` 49 | } `json:"thumbnail"` 50 | AverageRating float64 `json:"averageRating"` 51 | AllowRatings bool `json:"allowRatings"` 52 | ViewCount string `json:"viewCount"` 53 | Author string `json:"author"` 54 | IsPrivate bool `json:"isPrivate"` 55 | IsUnpluggedCorpus bool `json:"isUnpluggedCorpus"` 56 | IsLiveContent bool `json:"isLiveContent"` 57 | } `json:"videoDetails"` 58 | Microformat struct { 59 | PlayerMicroformatRenderer struct { 60 | Thumbnail struct { 61 | Thumbnails []struct { 62 | URL string `json:"url"` 63 | Width int `json:"width"` 64 | Height int `json:"height"` 65 | } `json:"thumbnails"` 66 | } `json:"thumbnail"` 67 | Title struct { 68 | SimpleText string `json:"simpleText"` 69 | } `json:"title"` 70 | Description struct { 71 | SimpleText string `json:"simpleText"` 72 | } `json:"description"` 73 | LengthSeconds string `json:"lengthSeconds"` 74 | OwnerProfileURL string `json:"ownerProfileUrl"` 75 | ExternalChannelID string `json:"externalChannelId"` 76 | IsFamilySafe bool `json:"isFamilySafe"` 77 | AvailableCountries []string `json:"availableCountries"` 78 | IsUnlisted bool `json:"isUnlisted"` 79 | HasYpcMetadata bool `json:"hasYpcMetadata"` 80 | ViewCount string `json:"viewCount"` 81 | Category string `json:"category"` 82 | PublishDate string `json:"publishDate"` 83 | OwnerChannelName string `json:"ownerChannelName"` 84 | UploadDate string `json:"uploadDate"` 85 | } `json:"playerMicroformatRenderer"` 86 | } `json:"microformat"` 87 | } 88 | 89 | type Format struct { 90 | ItagNo int `json:"itag"` 91 | URL string `json:"url"` 92 | MimeType string `json:"mimeType"` 93 | Quality string `json:"quality"` 94 | Cipher string `json:"signatureCipher"` 95 | Bitrate int `json:"bitrate"` 96 | FPS int `json:"fps"` 97 | Width int `json:"width"` 98 | Height int `json:"height"` 99 | LastModified string `json:"lastModified"` 100 | ContentLength int64 `json:"contentLength,string"` 101 | QualityLabel string `json:"qualityLabel"` 102 | ProjectionType string `json:"projectionType"` 103 | AverageBitrate int `json:"averageBitrate"` 104 | AudioQuality string `json:"audioQuality"` 105 | ApproxDurationMs string `json:"approxDurationMs"` 106 | AudioSampleRate string `json:"audioSampleRate"` 107 | AudioChannels int `json:"audioChannels"` 108 | 109 | // InitRange is only available for adaptive formats 110 | InitRange *struct { 111 | Start string `json:"start"` 112 | End string `json:"end"` 113 | } `json:"initRange"` 114 | 115 | // IndexRange is only available for adaptive formats 116 | IndexRange *struct { 117 | Start string `json:"start"` 118 | End string `json:"end"` 119 | } `json:"indexRange"` 120 | 121 | // AudioTrack is only available for videos with multiple audio track languages 122 | AudioTrack *struct { 123 | DisplayName string `json:"displayName"` 124 | ID string `json:"id"` 125 | AudioIsDefault bool `json:"audioIsDefault"` 126 | } 127 | } 128 | 129 | func (f *Format) LanguageDisplayName() string { 130 | if f.AudioTrack == nil { 131 | return "" 132 | } 133 | return f.AudioTrack.DisplayName 134 | } 135 | 136 | type Thumbnails []Thumbnail 137 | 138 | type Thumbnail struct { 139 | URL string 140 | Width uint 141 | Height uint 142 | } 143 | 144 | type CaptionTrack struct { 145 | BaseURL string `json:"baseUrl"` 146 | Name struct { 147 | SimpleText string `json:"simpleText"` 148 | } `json:"name"` 149 | VssID string `json:"vssId"` 150 | LanguageCode string `json:"languageCode"` 151 | Kind string `json:"kind,omitempty"` 152 | IsTranslatable bool `json:"isTranslatable"` 153 | } 154 | -------------------------------------------------------------------------------- /downloader/downloader.go: -------------------------------------------------------------------------------- 1 | package downloader 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "io" 7 | "log/slog" 8 | "os" 9 | "os/exec" 10 | "path/filepath" 11 | 12 | "github.com/kkdai/youtube/v2" 13 | "github.com/vbauerster/mpb/v5" 14 | "github.com/vbauerster/mpb/v5/decor" 15 | ) 16 | 17 | // Downloader offers high level functions to download videos into files 18 | type Downloader struct { 19 | youtube.Client 20 | OutputDir string // optional directory to store the files 21 | } 22 | 23 | func (dl *Downloader) getOutputFile(v *youtube.Video, format *youtube.Format, outputFile string) (string, error) { 24 | if outputFile == "" { 25 | outputFile = SanitizeFilename(v.Title) 26 | outputFile += pickIdealFileExtension(format.MimeType) 27 | } 28 | 29 | if dl.OutputDir != "" { 30 | if err := os.MkdirAll(dl.OutputDir, 0o755); err != nil { 31 | return "", err 32 | } 33 | outputFile = filepath.Join(dl.OutputDir, outputFile) 34 | } 35 | 36 | return outputFile, nil 37 | } 38 | 39 | // Download : Starting download video by arguments. 40 | func (dl *Downloader) Download(ctx context.Context, v *youtube.Video, format *youtube.Format, outputFile string) error { 41 | youtube.Logger.Info( 42 | "Downloading video", 43 | "id", v.ID, 44 | "quality", format.Quality, 45 | "mimeType", format.MimeType, 46 | ) 47 | destFile, err := dl.getOutputFile(v, format, outputFile) 48 | if err != nil { 49 | return err 50 | } 51 | 52 | // Create output file 53 | out, err := os.Create(destFile) 54 | if err != nil { 55 | return err 56 | } 57 | defer out.Close() 58 | 59 | return dl.videoDLWorker(ctx, out, v, format) 60 | } 61 | 62 | // DownloadComposite : Downloads audio and video streams separately and merges them via ffmpeg. 63 | func (dl *Downloader) DownloadComposite(ctx context.Context, outputFile string, v *youtube.Video, quality string, mimetype, language string) error { 64 | videoFormat, audioFormat, err1 := getVideoAudioFormats(v, quality, mimetype, language) 65 | if err1 != nil { 66 | return err1 67 | } 68 | 69 | log := youtube.Logger.With("id", v.ID) 70 | 71 | log.Info( 72 | "Downloading composite video", 73 | "videoQuality", videoFormat.QualityLabel, 74 | "videoMimeType", videoFormat.MimeType, 75 | "audioMimeType", audioFormat.MimeType, 76 | ) 77 | 78 | destFile, err := dl.getOutputFile(v, videoFormat, outputFile) 79 | if err != nil { 80 | return err 81 | } 82 | outputDir := filepath.Dir(destFile) 83 | 84 | // Create temporary video file 85 | videoFile, err := os.CreateTemp(outputDir, "youtube_*.m4v") 86 | if err != nil { 87 | return err 88 | } 89 | defer closeAndRemoveFile(videoFile, log) 90 | 91 | // Create temporary audio file 92 | audioFile, err := os.CreateTemp(outputDir, "youtube_*.m4a") 93 | if err != nil { 94 | return err 95 | } 96 | defer closeAndRemoveFile(audioFile, log) 97 | 98 | log.Debug("Downloading video file...") 99 | err = dl.videoDLWorker(ctx, videoFile, v, videoFormat) 100 | if err != nil { 101 | return err 102 | } 103 | 104 | log.Debug("Downloading audio file...") 105 | err = dl.videoDLWorker(ctx, audioFile, v, audioFormat) 106 | if err != nil { 107 | return err 108 | } 109 | 110 | //nolint:gosec 111 | ffmpegVersionCmd := exec.Command("ffmpeg", "-y", 112 | "-i", videoFile.Name(), 113 | "-i", audioFile.Name(), 114 | "-c", "copy", // Just copy without re-encoding 115 | "-shortest", // Finish encoding when the shortest input stream ends 116 | destFile, 117 | "-loglevel", "warning", 118 | ) 119 | ffmpegVersionCmd.Stderr = os.Stderr 120 | ffmpegVersionCmd.Stdout = os.Stdout 121 | log.Info("merging video and audio", "output", destFile) 122 | 123 | return ffmpegVersionCmd.Run() 124 | } 125 | 126 | func closeAndRemoveFile(file *os.File, log *slog.Logger) { 127 | if err := file.Close(); err != nil { 128 | log.Error("Failed to close file", "error", err) 129 | return 130 | } 131 | err := os.Remove(file.Name()) 132 | if err != nil { 133 | log.Error("Failed to delete file", "error", err) 134 | } 135 | } 136 | 137 | func getVideoAudioFormats(v *youtube.Video, quality string, mimetype, language string) (*youtube.Format, *youtube.Format, error) { 138 | var videoFormats, audioFormats youtube.FormatList 139 | 140 | formats := v.Formats 141 | if mimetype != "" { 142 | formats = formats.Type(mimetype) 143 | } 144 | 145 | videoFormats = formats.Type("video").AudioChannels(0) 146 | audioFormats = formats.Type("audio") 147 | 148 | if quality != "" { 149 | videoFormats = videoFormats.Quality(quality) 150 | } 151 | 152 | if language != "" { 153 | audioFormats = audioFormats.Language(language) 154 | } 155 | 156 | if len(videoFormats) == 0 { 157 | return nil, nil, errors.New("no video format found after filtering") 158 | } 159 | 160 | if len(audioFormats) == 0 { 161 | return nil, nil, errors.New("no audio format found after filtering") 162 | } 163 | 164 | videoFormats.Sort() 165 | audioFormats.Sort() 166 | 167 | return &videoFormats[0], &audioFormats[0], nil 168 | } 169 | 170 | func (dl *Downloader) videoDLWorker(ctx context.Context, out *os.File, video *youtube.Video, format *youtube.Format) error { 171 | stream, size, err := dl.GetStreamContext(ctx, video, format) 172 | if err != nil { 173 | return err 174 | } 175 | 176 | prog := &progress{ 177 | contentLength: float64(size), 178 | } 179 | 180 | // create progress bar 181 | progress := mpb.New(mpb.WithWidth(64)) 182 | bar := progress.AddBar( 183 | int64(prog.contentLength), 184 | 185 | mpb.PrependDecorators( 186 | decor.CountersKibiByte("% .2f / % .2f"), 187 | decor.Percentage(decor.WCSyncSpace), 188 | ), 189 | mpb.AppendDecorators( 190 | decor.EwmaETA(decor.ET_STYLE_GO, 90), 191 | decor.Name(" ] "), 192 | decor.EwmaSpeed(decor.UnitKiB, "% .2f", 60), 193 | ), 194 | ) 195 | 196 | reader := bar.ProxyReader(stream) 197 | mw := io.MultiWriter(out, prog) 198 | _, err = io.Copy(mw, reader) 199 | if err != nil { 200 | return err 201 | } 202 | 203 | progress.Wait() 204 | return nil 205 | } 206 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Download Youtube Video in Golang 2 | ================== 3 | 4 | [![GitHub license](https://img.shields.io/badge/license-MIT-blue.svg)](https://raw.githubusercontent.com/kkdai/youtube/master/LICENSE) 5 | [![Go Reference](https://pkg.go.dev/badge/github.com/kkdai/youtube.svg)](https://pkg.go.dev/github.com/kkdai/youtube/v2) 6 | [![Build Status](https://github.com/kkdai/youtube/workflows/go/badge.svg?branch=master)](https://github.com/kkdai/youtube/actions) 7 | [![Coverage](https://codecov.io/gh/kkdai/youtube/branch/master/graph/badge.svg)](https://codecov.io/gh/kkdai/youtube) 8 | [![](https://goreportcard.com/badge/github.com/kkdai/youtube)](https://goreportcard.com/badge/github.com/kkdai/youtube) 9 | 10 | 11 | This package is a Youtube video download package, for more detail refer [https://github.com/ytdl-org/youtube-dl](https://github.com/ytdl-org/youtube-dl) for more download options. 12 | 13 | This tool is meant to be used to download CC0 licenced content, we do not support nor recommend using it for illegal activities. 14 | 15 | ## Overview 16 | * [Install](#installation) 17 | * [Usage](#usage) 18 | * [Example: Download video from \[dotGo 2015 - Rob Pike - Simplicity is Complicated\]](#download-dotGo-2015-rob-pike-video) 19 | 20 | ## Installation 21 | 22 | ### Run Manually 23 | 24 | ```shell 25 | git clone https://github.com/kkdai/youtube.git && cd youtube 26 | go run ./cmd/youtubedr 27 | ``` 28 | 29 | ### Install via Go 30 | 31 | Please ensure you have installed Go 1.23 or later. 32 | 33 | ```shell 34 | go install github.com/kkdai/youtube/v2/cmd/youtubedr@latest 35 | ``` 36 | 37 | ### Mac 38 | 39 | ```shell 40 | brew install youtubedr 41 | ``` 42 | 43 | ### in Termux 44 | ```shell 45 | pkg install youtubedr 46 | ``` 47 | ### You can also find this package in 48 | - [archlinux](https://aur.archlinux.org/packages/youtubedr/) (thanks to [cjsthompson](https://github.com/cjsthompson)) 49 | - [Termux package](https://github.com/termux/termux-packages/tree/master/packages/youtubedr) (thanks to [kcubeterm](https://github.com/kcubeterm)) 50 | - [Homebrew](https://formulae.brew.sh/formula/youtubedr) (thanks to [kkc](https://github.com/kkc)) 51 | 52 | ## Usage 53 | 54 | ### Use the binary directly 55 | It's really simple to use, just get the video id from youtube url - ex: `https://www.youtube.com/watch?v=rFejpH_tAHM`, the video id is `rFejpH_tAHM` 56 | 57 | ```shell 58 | youtubedr download rFejpH_tAHM 59 | youtubedr download https://www.youtube.com/watch?v=rFejpH_tAHM 60 | ``` 61 | 62 | 63 | ### Use this package in your golang program 64 | 65 | Please check out the [example_test.go](example_test.go) for example code. 66 | 67 | 68 | ## Example: 69 | * ### Get information of dotGo-2015-rob-pike video for downloading 70 | 71 | Download video from [dotGo 2015 - Rob Pike - Simplicity is Complicated](https://www.youtube.com/watch?v=rFejpH_tAHM) 72 | 73 | ``` 74 | youtubedr info https://www.youtube.com/watch?v=rFejpH_tAHM 75 | 76 | Title: dotGo 2015 - Rob Pike - Simplicity is Complicated 77 | Author: dotconferences 78 | -----available streams----- 79 | itag: 18 , quality: medium , type: video/mp4; codecs="avc1.42001E, mp4a.40.2" 80 | itag: 22 , quality: hd720 , type: video/mp4; codecs="avc1.64001F, mp4a.40.2" 81 | itag: 137 , quality: hd1080 , type: video/mp4; codecs="avc1.640028" 82 | itag: 248 , quality: hd1080 , type: video/webm; codecs="vp9" 83 | ........ 84 | ``` 85 | * ### Download dotGo-2015-rob-pike-video 86 | 87 | Download video from [dotGo 2015 - Rob Pike - Simplicity is Complicated](https://www.youtube.com/watch?v=rFejpH_tAHM) 88 | 89 | ``` 90 | youtubedr download https://www.youtube.com/watch?v=rFejpH_tAHM 91 | ``` 92 | 93 | * ### Download video to specific folder and name 94 | 95 | Download video from [dotGo 2015 - Rob Pike - Simplicity is Complicated](https://www.youtube.com/watch?v=rFejpH_tAHM) to current directory and name the file to simplicity-is-complicated.mp4 96 | 97 | ``` 98 | youtubedr download -d ./ -o simplicity-is-complicated.mp4 https://www.youtube.com/watch?v=rFejpH_tAHM 99 | ``` 100 | 101 | * ### Download video with specific quality 102 | 103 | Download video from [dotGo 2015 - Rob Pike - Simplicity is Complicated](https://www.youtube.com/watch?v=rFejpH_tAHM) with specific quality 104 | 105 | ``` 106 | youtubedr download -q medium https://www.youtube.com/watch?v=rFejpH_tAHM 107 | ``` 108 | 109 | #### Special case by quality hd1080: 110 | Installation of ffmpeg is necessary for hd1080 111 | ``` 112 | ffmpeg //check ffmpeg is installed, if not please download ffmpeg and set to your PATH. 113 | youtubedr download -q hd1080 https://www.youtube.com/watch?v=rFejpH_tAHM 114 | ``` 115 | 116 | 117 | * ### Download video with specific itag 118 | 119 | Download video from [dotGo 2015 - Rob Pike - Simplicity is Complicated](https://www.youtube.com/watch?v=rFejpH_tAHM) 120 | 121 | ``` 122 | youtubedr download -q 18 https://www.youtube.com/watch?v=rFejpH_tAHM 123 | ``` 124 | 125 | ## How it works 126 | 127 | - Parse the video ID you input in URL 128 | - ex: `https://www.youtube.com/watch?v=rFejpH_tAHM`, the video id is `rFejpH_tAHM` 129 | - Get video information via video id. 130 | - Use URL: `http://youtube.com/get_video_info?video_id=` 131 | - Parse and decode video information. 132 | - Download URL in "url=" 133 | - title in "title=" 134 | - Download video from URL 135 | - Need the string combination of "url" 136 | 137 | ## Inspired 138 | - [https://github.com/ytdl-org/youtube-dl](https://github.com/ytdl-org/youtube-dl) 139 | - [https://github.com/lepidosteus/youtube-dl](https://github.com/lepidosteus/youtube-dl) 140 | - [拆解 Youtube 影片下載位置](http://hkgoldenmra.blogspot.tw/2013/05/youtube.html) 141 | - [iawia002/annie](https://github.com/iawia002/annie) 142 | - [How to get url from obfuscate video info: youtube video downloader with php](https://stackoverflow.com/questions/60607291/youtube-video-downloader-with-php) 143 | 144 | 145 | ## Project52 146 | It is one of my [project 52](https://github.com/kkdai/project52). 147 | 148 | 149 | ## License 150 | This package is licensed under MIT license. See LICENSE for details. 151 | -------------------------------------------------------------------------------- /transcript.go: -------------------------------------------------------------------------------- 1 | package youtube 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "strconv" 9 | "strings" 10 | ) 11 | 12 | var ( 13 | ErrTranscriptDisabled = errors.New("transcript is disabled on this video") 14 | ) 15 | 16 | // TranscriptSegment is a single transcipt segment spanning a few milliseconds. 17 | type TranscriptSegment struct { 18 | // Text is the transcipt text. 19 | Text string `json:"text"` 20 | 21 | // StartMs is the start timestamp in ms. 22 | StartMs int `json:"offset"` 23 | 24 | // OffsetText e.g. '4:00'. 25 | OffsetText string `json:"offsetText"` 26 | 27 | // Duration the transcript segment spans in ms. 28 | Duration int `json:"duration"` 29 | } 30 | 31 | func (tr TranscriptSegment) String() string { 32 | return tr.OffsetText + " - " + strings.TrimSpace(tr.Text) 33 | } 34 | 35 | type VideoTranscript []TranscriptSegment 36 | 37 | func (vt VideoTranscript) String() string { 38 | var str string 39 | for _, tr := range vt { 40 | str += tr.String() + "\n" 41 | } 42 | 43 | return str 44 | } 45 | 46 | // GetTranscript fetches the video transcript if available. 47 | // 48 | // Not all videos have transcripts, only relatively new videos. 49 | // If transcripts are disabled or not available, ErrTranscriptDisabled is returned. 50 | func (c *Client) GetTranscript(video *Video, lang string) (VideoTranscript, error) { 51 | return c.GetTranscriptCtx(context.Background(), video, lang) 52 | } 53 | 54 | // GetTranscriptCtx fetches the video transcript if available. 55 | // 56 | // Not all videos have transcripts, only relatively new videos. 57 | // If transcripts are disabled or not available, ErrTranscriptDisabled is returned. 58 | func (c *Client) GetTranscriptCtx(ctx context.Context, video *Video, lang string) (VideoTranscript, error) { 59 | c.assureClient() 60 | 61 | if video == nil || video.ID == "" { 62 | return nil, fmt.Errorf("no video provided") 63 | } 64 | 65 | body, err := c.transcriptDataByInnertube(ctx, video.ID, lang) 66 | if err != nil { 67 | return nil, err 68 | } 69 | 70 | transcript, err := parseTranscript(body) 71 | if err != nil { 72 | return nil, err 73 | } 74 | 75 | return transcript, nil 76 | } 77 | 78 | func parseTranscript(body []byte) (VideoTranscript, error) { 79 | var resp transcriptResp 80 | if err := json.Unmarshal(body, &resp); err != nil { 81 | return nil, err 82 | } 83 | 84 | if len(resp.Actions) > 0 { 85 | // Android client response 86 | if app := resp.Actions[0].AppSegment; app != nil { 87 | return getSegments(app) 88 | } 89 | 90 | // Web client response 91 | if web := resp.Actions[0].WebSegment; web != nil { 92 | return nil, fmt.Errorf("not implemented") 93 | } 94 | } 95 | 96 | return nil, ErrTranscriptDisabled 97 | } 98 | 99 | type segmenter interface { 100 | ParseSegments() []TranscriptSegment 101 | } 102 | 103 | func getSegments(f segmenter) (VideoTranscript, error) { 104 | if segments := f.ParseSegments(); len(segments) > 0 { 105 | return segments, nil 106 | } 107 | 108 | return nil, ErrTranscriptDisabled 109 | } 110 | 111 | // transcriptResp is the JSON structure as returned by the transcript API. 112 | type transcriptResp struct { 113 | Actions []struct { 114 | AppSegment *appData `json:"elementsCommand"` 115 | WebSegment *webData `json:"updateEngagementPanelAction"` 116 | } `json:"actions"` 117 | } 118 | 119 | type appData struct { 120 | TEC struct { 121 | Args struct { 122 | ListArgs struct { 123 | Ow struct { 124 | InitialSeg []struct { 125 | TranscriptSegment struct { 126 | StartMs string `json:"startMs"` 127 | EndMs string `json:"endMs"` 128 | Text struct { 129 | String struct { 130 | // Content is the actual transctipt text 131 | Content string `json:"content"` 132 | } `json:"elementsAttributedString"` 133 | } `json:"snippet"` 134 | StartTimeText struct { 135 | String struct { 136 | // Content is the fomratted timestamp, e.g. '4:00' 137 | Content string `json:"content"` 138 | } `json:"elementsAttributedString"` 139 | } `json:"startTimeText"` 140 | } `json:"transcriptSegmentRenderer"` 141 | } `json:"initialSegments"` 142 | } `json:"overwrite"` 143 | } `json:"transformTranscriptSegmentListArguments"` 144 | } `json:"arguments"` 145 | } `json:"transformEntityCommand"` 146 | } 147 | 148 | func (s *appData) ParseSegments() []TranscriptSegment { 149 | rawSegments := s.TEC.Args.ListArgs.Ow.InitialSeg 150 | segments := make([]TranscriptSegment, 0, len(rawSegments)) 151 | 152 | for _, segment := range rawSegments { 153 | startMs, _ := strconv.Atoi(segment.TranscriptSegment.StartMs) 154 | endMs, _ := strconv.Atoi(segment.TranscriptSegment.EndMs) 155 | 156 | segments = append(segments, TranscriptSegment{ 157 | Text: segment.TranscriptSegment.Text.String.Content, 158 | StartMs: startMs, 159 | OffsetText: segment.TranscriptSegment.StartTimeText.String.Content, 160 | Duration: endMs - startMs, 161 | }) 162 | } 163 | 164 | return segments 165 | } 166 | 167 | type webData struct { 168 | Content struct { 169 | TR struct { 170 | Body struct { 171 | TBR struct { 172 | Cues []struct { 173 | Transcript struct { 174 | FormattedStartOffset struct { 175 | SimpleText string `json:"simpleText"` 176 | } `json:"formattedStartOffset"` 177 | Cues []struct { 178 | TranscriptCueRenderer struct { 179 | Cue struct { 180 | SimpleText string `json:"simpleText"` 181 | } `json:"cue"` 182 | StartOffsetMs string `json:"startOffsetMs"` 183 | DurationMs string `json:"durationMs"` 184 | } `json:"transcriptCueRenderer"` 185 | } `json:"cues"` 186 | } `json:"transcriptCueGroupRenderer"` 187 | } `json:"cueGroups"` 188 | } `json:"transcriptSearchPanelRenderer"` 189 | } `json:"content"` 190 | } `json:"transcriptRenderer"` 191 | } `json:"content"` 192 | } 193 | 194 | func (s *webData) ParseSegments() []TranscriptSegment { 195 | // TODO: doesn't actually work now, check json. 196 | cues := s.Content.TR.Body.TBR.Cues 197 | segments := make([]TranscriptSegment, 0, len(cues)) 198 | 199 | for _, s := range cues { 200 | formatted := s.Transcript.FormattedStartOffset.SimpleText 201 | segment := s.Transcript.Cues[0].TranscriptCueRenderer 202 | start, _ := strconv.Atoi(segment.StartOffsetMs) 203 | duration, _ := strconv.Atoi(segment.DurationMs) 204 | 205 | segments = append(segments, TranscriptSegment{ 206 | Text: segment.Cue.SimpleText, 207 | StartMs: start, 208 | OffsetText: formatted, 209 | Duration: duration, 210 | }) 211 | } 212 | 213 | return segments 214 | } 215 | -------------------------------------------------------------------------------- /playlist.go: -------------------------------------------------------------------------------- 1 | package youtube 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "regexp" 8 | "runtime/debug" 9 | "strconv" 10 | "time" 11 | 12 | sjson "github.com/bitly/go-simplejson" 13 | ) 14 | 15 | var ( 16 | playlistIDRegex = regexp.MustCompile("^[A-Za-z0-9_-]{13,42}$") 17 | playlistInURLRegex = regexp.MustCompile("[&?]list=([A-Za-z0-9_-]{13,42})(&.*)?$") 18 | ) 19 | 20 | type Playlist struct { 21 | ID string 22 | Title string 23 | Description string 24 | Author string 25 | Videos []*PlaylistEntry 26 | } 27 | 28 | type PlaylistEntry struct { 29 | ID string 30 | Title string 31 | Author string 32 | Duration time.Duration 33 | Thumbnails Thumbnails 34 | } 35 | 36 | func extractPlaylistID(url string) (string, error) { 37 | if playlistIDRegex.Match([]byte(url)) { 38 | return url, nil 39 | } 40 | 41 | matches := playlistInURLRegex.FindStringSubmatch(url) 42 | 43 | if matches != nil { 44 | return matches[1], nil 45 | } 46 | 47 | return "", ErrInvalidPlaylist 48 | } 49 | 50 | // structs for playlist extraction 51 | 52 | // Title: metadata.playlistMetadataRenderer.title | sidebar.playlistSidebarRenderer.items[0].playlistSidebarPrimaryInfoRenderer.title.runs[0].text 53 | // Description: metadata.playlistMetadataRenderer.description 54 | // Author: sidebar.playlistSidebarRenderer.items[1].playlistSidebarSecondaryInfoRenderer.videoOwner.videoOwnerRenderer.title.runs[0].text 55 | 56 | // Videos: contents.twoColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0].itemSectionRenderer.contents[0].playlistVideoListRenderer.contents 57 | // ID: .videoId 58 | // Title: title.runs[0].text 59 | // Author: .shortBylineText.runs[0].text 60 | // Duration: .lengthSeconds 61 | // Thumbnails .thumbnails 62 | 63 | // TODO?: Author thumbnails: sidebar.playlistSidebarRenderer.items[0].playlistSidebarPrimaryInfoRenderer.thumbnailRenderer.playlistVideoThumbnailRenderer.thumbnail.thumbnails 64 | func (p *Playlist) parsePlaylistInfo(ctx context.Context, client *Client, body []byte) (err error) { 65 | var j *sjson.Json 66 | j, err = sjson.NewJson(body) 67 | if err != nil { 68 | return err 69 | } 70 | 71 | defer func() { 72 | stack := debug.Stack() 73 | if r := recover(); r != nil { 74 | err = fmt.Errorf("JSON parsing error: %v\n%s", r, stack) 75 | } 76 | }() 77 | 78 | renderer := j.GetPath("alerts").GetIndex(0).GetPath("alertRenderer") 79 | if renderer != nil && renderer.GetPath("type").MustString() == "ERROR" { 80 | message := renderer.GetPath("text", "runs").GetIndex(0).GetPath("text").MustString() 81 | 82 | return ErrPlaylistStatus{Reason: message} 83 | } 84 | 85 | // Metadata can be located in multiple places depending on client type 86 | var metadata *sjson.Json 87 | if node, ok := j.CheckGet("metadata"); ok { 88 | metadata = node 89 | } else if node, ok := j.CheckGet("header"); ok { 90 | metadata = node 91 | } else { 92 | return fmt.Errorf("no playlist header / metadata found") 93 | } 94 | 95 | metadata = metadata.Get("playlistHeaderRenderer") 96 | 97 | p.Title = sjsonGetText(metadata, "title") 98 | p.Description = sjsonGetText(metadata, "description", "descriptionText") 99 | p.Author = j.GetPath("sidebar", "playlistSidebarRenderer", "items").GetIndex(1). 100 | GetPath("playlistSidebarSecondaryInfoRenderer", "videoOwner", "videoOwnerRenderer", "title", "runs"). 101 | GetIndex(0).Get("text").MustString() 102 | 103 | if len(p.Author) == 0 { 104 | p.Author = sjsonGetText(metadata, "owner", "ownerText") 105 | } 106 | 107 | contents, ok := j.CheckGet("contents") 108 | if !ok { 109 | return fmt.Errorf("contents not found in json body") 110 | } 111 | 112 | // contents can have different keys with same child structure 113 | firstPart := getFirstKeyJSON(contents).GetPath("tabs").GetIndex(0). 114 | GetPath("tabRenderer", "content", "sectionListRenderer", "contents").GetIndex(0) 115 | 116 | // This extra nested item is only set with the web client 117 | if n := firstPart.GetPath("itemSectionRenderer", "contents").GetIndex(0); isValidJSON(n) { 118 | firstPart = n 119 | } 120 | 121 | vJSON, err := firstPart.GetPath("playlistVideoListRenderer", "contents").MarshalJSON() 122 | if err != nil { 123 | return err 124 | } 125 | 126 | if len(vJSON) <= 4 { 127 | return fmt.Errorf("no video data found in JSON") 128 | } 129 | 130 | entries, continuation, err := extractPlaylistEntries(vJSON) 131 | if err != nil { 132 | return err 133 | } 134 | 135 | if len(continuation) == 0 { 136 | continuation = getContinuation(firstPart.Get("playlistVideoListRenderer")) 137 | } 138 | 139 | if len(entries) == 0 { 140 | return fmt.Errorf("no videos found in playlist") 141 | } 142 | 143 | p.Videos = entries 144 | 145 | for continuation != "" { 146 | data := prepareInnertubePlaylistData(continuation, true, *client.client) 147 | 148 | body, err := client.httpPostBodyBytes(ctx, "https://www.youtube.com/youtubei/v1/browse?key="+client.client.key, data) 149 | if err != nil { 150 | return err 151 | } 152 | 153 | j, err := sjson.NewJson(body) 154 | if err != nil { 155 | return err 156 | } 157 | 158 | next := j.GetPath("onResponseReceivedActions").GetIndex(0). 159 | GetPath("appendContinuationItemsAction", "continuationItems") 160 | 161 | if !isValidJSON(next) { 162 | next = j.GetPath("continuationContents", "playlistVideoListContinuation", "contents") 163 | } 164 | 165 | vJSON, err := next.MarshalJSON() 166 | if err != nil { 167 | return err 168 | } 169 | 170 | entries, token, err := extractPlaylistEntries(vJSON) 171 | if err != nil { 172 | return err 173 | } 174 | 175 | if len(token) > 0 { 176 | continuation = token 177 | } else { 178 | continuation = getContinuation(j.GetPath("continuationContents", "playlistVideoListContinuation")) 179 | } 180 | 181 | p.Videos = append(p.Videos, entries...) 182 | } 183 | 184 | return err 185 | } 186 | 187 | func extractPlaylistEntries(data []byte) ([]*PlaylistEntry, string, error) { 188 | var vids []*videosJSONExtractor 189 | 190 | if err := json.Unmarshal(data, &vids); err != nil { 191 | return nil, "", err 192 | } 193 | 194 | entries := make([]*PlaylistEntry, 0, len(vids)) 195 | 196 | var continuation string 197 | for _, v := range vids { 198 | if v.Renderer == nil { 199 | if v.Continuation.Endpoint.Command.Token != "" { 200 | continuation = v.Continuation.Endpoint.Command.Token 201 | } 202 | 203 | continue 204 | } 205 | 206 | entries = append(entries, v.PlaylistEntry()) 207 | } 208 | 209 | return entries, continuation, nil 210 | } 211 | 212 | type videosJSONExtractor struct { 213 | Renderer *struct { 214 | ID string `json:"videoId"` 215 | Title withRuns `json:"title"` 216 | Author withRuns `json:"shortBylineText"` 217 | Duration string `json:"lengthSeconds"` 218 | Thumbnail struct { 219 | Thumbnails []Thumbnail `json:"thumbnails"` 220 | } `json:"thumbnail"` 221 | } `json:"playlistVideoRenderer"` 222 | Continuation struct { 223 | Endpoint struct { 224 | Command struct { 225 | Token string `json:"token"` 226 | } `json:"continuationCommand"` 227 | } `json:"continuationEndpoint"` 228 | } `json:"continuationItemRenderer"` 229 | } 230 | 231 | func (vje videosJSONExtractor) PlaylistEntry() *PlaylistEntry { 232 | ds, err := strconv.Atoi(vje.Renderer.Duration) 233 | if err != nil { 234 | panic("invalid video duration: " + vje.Renderer.Duration) 235 | } 236 | return &PlaylistEntry{ 237 | ID: vje.Renderer.ID, 238 | Title: vje.Renderer.Title.String(), 239 | Author: vje.Renderer.Author.String(), 240 | Duration: time.Second * time.Duration(ds), 241 | Thumbnails: vje.Renderer.Thumbnail.Thumbnails, 242 | } 243 | } 244 | 245 | type withRuns struct { 246 | Runs []struct { 247 | Text string `json:"text"` 248 | } `json:"runs"` 249 | } 250 | 251 | func (wr withRuns) String() string { 252 | if len(wr.Runs) > 0 { 253 | return wr.Runs[0].Text 254 | } 255 | return "" 256 | } 257 | -------------------------------------------------------------------------------- /decipher.go: -------------------------------------------------------------------------------- 1 | package youtube 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "errors" 7 | "fmt" 8 | "net/url" 9 | "regexp" 10 | "strconv" 11 | 12 | "github.com/dop251/goja" 13 | ) 14 | 15 | func (c *Client) decipherURL(ctx context.Context, videoID string, cipher string) (string, error) { 16 | params, err := url.ParseQuery(cipher) 17 | if err != nil { 18 | return "", err 19 | } 20 | 21 | uri, err := url.Parse(params.Get("url")) 22 | if err != nil { 23 | return "", err 24 | } 25 | query := uri.Query() 26 | 27 | config, err := c.getPlayerConfig(ctx, videoID) 28 | if err != nil { 29 | return "", err 30 | } 31 | 32 | // decrypt s-parameter 33 | bs, err := config.decrypt([]byte(params.Get("s"))) 34 | if err != nil { 35 | return "", err 36 | } 37 | query.Add(params.Get("sp"), string(bs)) 38 | 39 | query, err = c.decryptNParam(config, query) 40 | if err != nil { 41 | return "", err 42 | } 43 | 44 | uri.RawQuery = query.Encode() 45 | 46 | return uri.String(), nil 47 | } 48 | 49 | // see https://github.com/kkdai/youtube/pull/244 50 | func (c *Client) unThrottle(ctx context.Context, videoID string, urlString string) (string, error) { 51 | config, err := c.getPlayerConfig(ctx, videoID) 52 | if err != nil { 53 | return "", err 54 | } 55 | 56 | uri, err := url.Parse(urlString) 57 | if err != nil { 58 | return "", err 59 | } 60 | 61 | // for debugging 62 | if artifactsFolder != "" { 63 | writeArtifact("video-"+videoID+".url", []byte(uri.String())) 64 | } 65 | 66 | query, err := c.decryptNParam(config, uri.Query()) 67 | if err != nil { 68 | return "", err 69 | } 70 | 71 | uri.RawQuery = query.Encode() 72 | return uri.String(), nil 73 | } 74 | 75 | func (c *Client) decryptNParam(config playerConfig, query url.Values) (url.Values, error) { 76 | // decrypt n-parameter 77 | nSig := query.Get("v") 78 | log := Logger.With("n", nSig) 79 | 80 | if nSig != "" { 81 | nDecoded, err := config.decodeNsig(nSig) 82 | if err != nil { 83 | return nil, fmt.Errorf("unable to decode nSig: %w", err) 84 | } 85 | query.Set("v", nDecoded) 86 | log = log.With("decoded", nDecoded) 87 | } 88 | 89 | log.Debug("nParam") 90 | 91 | return query, nil 92 | } 93 | 94 | const ( 95 | jsvarStr = "[a-zA-Z_\\$][a-zA-Z_0-9]*" 96 | reverseStr = ":function\\(a\\)\\{" + 97 | "(?:return )?a\\.reverse\\(\\)" + 98 | "\\}" 99 | spliceStr = ":function\\(a,b\\)\\{" + 100 | "a\\.splice\\(0,b\\)" + 101 | "\\}" 102 | swapStr = ":function\\(a,b\\)\\{" + 103 | "var c=a\\[0\\];a\\[0\\]=a\\[b(?:%a\\.length)?\\];a\\[b(?:%a\\.length)?\\]=c(?:;return a)?" + 104 | "\\}" 105 | ) 106 | 107 | var ( 108 | nFunctionNameRegexp = regexp.MustCompile("\\.get\\(\"n\"\\)\\)&&\\(b=([a-zA-Z0-9$]{0,3})\\[(\\d+)\\](.+)\\|\\|([a-zA-Z0-9]{0,3})") 109 | actionsObjRegexp = regexp.MustCompile(fmt.Sprintf( 110 | "var (%s)=\\{((?:(?:%s%s|%s%s|%s%s),?\\n?)+)\\};", jsvarStr, jsvarStr, swapStr, jsvarStr, spliceStr, jsvarStr, reverseStr)) 111 | 112 | actionsFuncRegexp = regexp.MustCompile(fmt.Sprintf( 113 | "function(?: %s)?\\(a\\)\\{"+ 114 | "a=a\\.split\\(\"\"\\);\\s*"+ 115 | "((?:(?:a=)?%s\\.%s\\(a,\\d+\\);)+)"+ 116 | "return a\\.join\\(\"\"\\)"+ 117 | "\\}", jsvarStr, jsvarStr, jsvarStr)) 118 | 119 | reverseRegexp = regexp.MustCompile(fmt.Sprintf("(?m)(?:^|,)(%s)%s", jsvarStr, reverseStr)) 120 | spliceRegexp = regexp.MustCompile(fmt.Sprintf("(?m)(?:^|,)(%s)%s", jsvarStr, spliceStr)) 121 | swapRegexp = regexp.MustCompile(fmt.Sprintf("(?m)(?:^|,)(%s)%s", jsvarStr, swapStr)) 122 | ) 123 | 124 | func (config playerConfig) decodeNsig(encoded string) (string, error) { 125 | fBody, err := config.getNFunction() 126 | if err != nil { 127 | return "", err 128 | } 129 | 130 | return evalJavascript(fBody, encoded) 131 | } 132 | 133 | func evalJavascript(jsFunction, arg string) (string, error) { 134 | const myName = "myFunction" 135 | 136 | vm := goja.New() 137 | _, err := vm.RunString(myName + "=" + jsFunction) 138 | if err != nil { 139 | return "", err 140 | } 141 | 142 | var output func(string) string 143 | err = vm.ExportTo(vm.Get(myName), &output) 144 | if err != nil { 145 | return "", err 146 | } 147 | 148 | return output(arg), nil 149 | } 150 | 151 | func (config playerConfig) getNFunction() (string, error) { 152 | nameResult := nFunctionNameRegexp.FindSubmatch(config) 153 | if len(nameResult) == 0 { 154 | return "", errors.New("unable to extract n-function name") 155 | } 156 | 157 | var name string 158 | if idx, _ := strconv.Atoi(string(nameResult[2])); idx == 0 { 159 | name = string(nameResult[4]) 160 | } else { 161 | name = string(nameResult[1]) 162 | } 163 | 164 | return config.extraFunction(name) 165 | 166 | } 167 | 168 | func (config playerConfig) extraFunction(name string) (string, error) { 169 | // find the beginning of the function 170 | def := []byte(name + "=function(") 171 | start := bytes.Index(config, def) 172 | if start < 1 { 173 | return "", fmt.Errorf("unable to extract n-function body: looking for '%s'", def) 174 | } 175 | 176 | // start after the first curly bracket 177 | pos := start + bytes.IndexByte(config[start:], '{') + 1 178 | 179 | var strChar byte 180 | 181 | // find the bracket closing the function 182 | for brackets := 1; brackets > 0; pos++ { 183 | b := config[pos] 184 | switch b { 185 | case '{': 186 | if strChar == 0 { 187 | brackets++ 188 | } 189 | case '}': 190 | if strChar == 0 { 191 | brackets-- 192 | } 193 | case '`', '"', '\'': 194 | if config[pos-1] == '\\' && config[pos-2] != '\\' { 195 | continue 196 | } 197 | if strChar == 0 { 198 | strChar = b 199 | } else if strChar == b { 200 | strChar = 0 201 | } 202 | } 203 | } 204 | 205 | return string(config[start:pos]), nil 206 | } 207 | 208 | func (config playerConfig) decrypt(cyphertext []byte) ([]byte, error) { 209 | operations, err := config.parseDecipherOps() 210 | if err != nil { 211 | return nil, err 212 | } 213 | 214 | // apply operations 215 | bs := []byte(cyphertext) 216 | for _, op := range operations { 217 | bs = op(bs) 218 | } 219 | 220 | return bs, nil 221 | } 222 | 223 | /* 224 | parses decipher operations from https://youtube.com/s/player/4fbb4d5b/player_ias.vflset/en_US/base.js 225 | 226 | var Mt={ 227 | splice:function(a,b){a.splice(0,b)}, 228 | reverse:function(a){a.reverse()}, 229 | EQ:function(a,b){var c=a[0];a[0]=a[b%a.length];a[b%a.length]=c}}; 230 | 231 | a=a.split(""); 232 | Mt.splice(a,3); 233 | Mt.EQ(a,39); 234 | Mt.splice(a,2); 235 | Mt.EQ(a,1); 236 | Mt.splice(a,1); 237 | Mt.EQ(a,35); 238 | Mt.EQ(a,51); 239 | Mt.splice(a,2); 240 | Mt.reverse(a,52); 241 | return a.join("") 242 | */ 243 | func (config playerConfig) parseDecipherOps() (operations []DecipherOperation, err error) { 244 | objResult := actionsObjRegexp.FindSubmatch(config) 245 | funcResult := actionsFuncRegexp.FindSubmatch(config) 246 | if len(objResult) < 3 || len(funcResult) < 2 { 247 | return nil, fmt.Errorf("error parsing signature tokens (#obj=%d, #func=%d)", len(objResult), len(funcResult)) 248 | } 249 | 250 | obj := objResult[1] 251 | objBody := objResult[2] 252 | funcBody := funcResult[1] 253 | 254 | var reverseKey, spliceKey, swapKey string 255 | 256 | if result := reverseRegexp.FindSubmatch(objBody); len(result) > 1 { 257 | reverseKey = string(result[1]) 258 | } 259 | if result := spliceRegexp.FindSubmatch(objBody); len(result) > 1 { 260 | spliceKey = string(result[1]) 261 | } 262 | if result := swapRegexp.FindSubmatch(objBody); len(result) > 1 { 263 | swapKey = string(result[1]) 264 | } 265 | 266 | regex, err := regexp.Compile(fmt.Sprintf("(?:a=)?%s\\.(%s|%s|%s)\\(a,(\\d+)\\)", regexp.QuoteMeta(string(obj)), regexp.QuoteMeta(reverseKey), regexp.QuoteMeta(spliceKey), regexp.QuoteMeta(swapKey))) 267 | if err != nil { 268 | return nil, err 269 | } 270 | 271 | var ops []DecipherOperation 272 | for _, s := range regex.FindAllSubmatch(funcBody, -1) { 273 | switch string(s[1]) { 274 | case reverseKey: 275 | ops = append(ops, reverseFunc) 276 | case swapKey: 277 | arg, _ := strconv.Atoi(string(s[2])) 278 | ops = append(ops, newSwapFunc(arg)) 279 | case spliceKey: 280 | arg, _ := strconv.Atoi(string(s[2])) 281 | ops = append(ops, newSpliceFunc(arg)) 282 | } 283 | } 284 | return ops, nil 285 | } 286 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0= 2 | github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= 3 | github.com/VividCortex/ewma v1.1.1/go.mod h1:2Tkkvm3sRDVXaiyucHiACn4cqf7DpdyLvmxzcbUokwA= 4 | github.com/VividCortex/ewma v1.2.0 h1:f58SaIzcDXrSy3kWaHNvuJgJ3Nmz59Zji6XoJR/q1ow= 5 | github.com/VividCortex/ewma v1.2.0/go.mod h1:nz4BbCtbLyFDeC9SUHbtcT5644juEuWfUAUnGx7j5l4= 6 | github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d h1:licZJFw2RwpHMqeKTCYkitsPqHNxTmd4SNR5r94FGM8= 7 | github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d/go.mod h1:asat636LX7Bqt5lYEZ27JNDcqxfjdBQuJ/MM4CN/Lzo= 8 | github.com/bitly/go-simplejson v0.5.1 h1:xgwPbetQScXt1gh9BmoJ6j9JMr3TElvuIyjR8pgdoow= 9 | github.com/bitly/go-simplejson v0.5.1/go.mod h1:YOPVLzCfwK14b4Sff3oP1AmGhI9T9Vsg84etUnlyp+Q= 10 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 11 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 12 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 13 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 14 | github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= 15 | github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= 16 | github.com/dop251/goja v0.0.0-20250125213203-5ef83b82af17 h1:spJaibPy2sZNwo6Q0HjBVufq7hBUj5jNFOKRoogCBow= 17 | github.com/dop251/goja v0.0.0-20250125213203-5ef83b82af17/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4= 18 | github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= 19 | github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 20 | github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= 21 | github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= 22 | github.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TCYl6lbukKPc7b5x0n1s6Q= 23 | github.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg= 24 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 25 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 26 | github.com/google/pprof v0.0.0-20250208200701-d0013a598941 h1:43XjGa6toxLpeksjcxs1jIoIyr+vUfOqY2c6HB4bpoc= 27 | github.com/google/pprof v0.0.0-20250208200701-d0013a598941/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= 28 | github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= 29 | github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= 30 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 31 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 32 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 33 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 34 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 35 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 36 | github.com/magiconair/properties v1.8.9 h1:nWcCbLq1N2v/cpNsy5WvQ37Fb+YElfq20WJ/a8RkpQM= 37 | github.com/magiconair/properties v1.8.9/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= 38 | github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= 39 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 40 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 41 | github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= 42 | github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= 43 | github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= 44 | github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= 45 | github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= 46 | github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= 47 | github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= 48 | github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= 49 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 50 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 51 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 52 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 53 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 54 | github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= 55 | github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= 56 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 57 | github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo= 58 | github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k= 59 | github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= 60 | github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= 61 | github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= 62 | github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= 63 | github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs= 64 | github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4= 65 | github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= 66 | github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= 67 | github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= 68 | github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= 69 | github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= 70 | github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 71 | github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= 72 | github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= 73 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 74 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 75 | github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= 76 | github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= 77 | github.com/vbauerster/mpb/v5 v5.4.0 h1:n8JPunifvQvh6P1D1HAl2Ur9YcmKT1tpoUuiea5mlmg= 78 | github.com/vbauerster/mpb/v5 v5.4.0/go.mod h1:fi4wVo7BVQ22QcvFObm+VwliQXlV1eBT8JDaKXR4JGI= 79 | go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= 80 | go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= 81 | golang.org/x/exp v0.0.0-20250218142911-aa4b98e5adaa h1:t2QcU6V556bFjYgu4L6C+6VrCPyJZ+eyRsABUPs1mz4= 82 | golang.org/x/exp v0.0.0-20250218142911-aa4b98e5adaa/go.mod h1:BHOTPb3L19zxehTsLoJXVaTktb06DFgmdW6Wb9s8jqk= 83 | golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= 84 | golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= 85 | golang.org/x/sys v0.0.0-20201218084310-7d0127a74742/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 86 | golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= 87 | golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 88 | golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= 89 | golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= 90 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 91 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= 92 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 93 | gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= 94 | gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= 95 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 96 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 97 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 98 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 99 | -------------------------------------------------------------------------------- /client_test.go: -------------------------------------------------------------------------------- 1 | package youtube 2 | 3 | import ( 4 | "io" 5 | "testing" 6 | "time" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | "golang.org/x/net/context" 11 | ) 12 | 13 | const ( 14 | dwlURL string = "https://www.youtube.com/watch?v=rFejpH_tAHM" 15 | streamURL string = "https://www.youtube.com/watch?v=a9LDPn-MO4I" 16 | errURL string = "https://www.youtube.com/watch?v=I8oGsuQ" 17 | ) 18 | 19 | var testClient = Client{} 20 | var testWebClient = Client{client: &WebClient} 21 | 22 | func TestParseVideo(t *testing.T) { 23 | video, err := testClient.GetVideo(dwlURL) 24 | assert.NoError(t, err) 25 | assert.NotNil(t, video) 26 | 27 | _, err = testClient.GetVideo(errURL) 28 | assert.IsType(t, err, &ErrPlayabiltyStatus{}) 29 | } 30 | 31 | func TestYoutube_findVideoID(t *testing.T) { 32 | type args struct { 33 | url string 34 | } 35 | tests := []struct { 36 | name string 37 | args args 38 | wantErr bool 39 | expectedErr error 40 | }{ 41 | { 42 | name: "valid url", 43 | args: args{ 44 | dwlURL, 45 | }, 46 | wantErr: false, 47 | expectedErr: nil, 48 | }, 49 | { 50 | name: "valid id", 51 | args: args{ 52 | "rFejpH_tAHM", 53 | }, 54 | wantErr: false, 55 | expectedErr: nil, 56 | }, 57 | { 58 | name: "invalid character in id", 59 | args: args{ 60 | " 0 { 416 | return c.ChunkSize 417 | } 418 | 419 | return Size10Mb 420 | } 421 | 422 | func (c *Client) getMaxRoutines(limit int) int { 423 | routines := 10 424 | 425 | if c.MaxRoutines > 0 { 426 | routines = c.MaxRoutines 427 | } 428 | 429 | if limit > 0 && routines > limit { 430 | routines = limit 431 | } 432 | 433 | return routines 434 | } 435 | 436 | func (c *Client) downloadChunked(ctx context.Context, req *http.Request, w *io.PipeWriter, format *Format) { 437 | chunks := getChunks(format.ContentLength, c.getChunkSize()) 438 | maxRoutines := c.getMaxRoutines(len(chunks)) 439 | 440 | cancelCtx, cancel := context.WithCancel(ctx) 441 | abort := func(err error) { 442 | w.CloseWithError(err) 443 | cancel() 444 | } 445 | 446 | currentChunk := atomic.Uint32{} 447 | for i := 0; i < maxRoutines; i++ { 448 | go func() { 449 | for { 450 | chunkIndex := int(currentChunk.Add(1)) - 1 451 | if chunkIndex >= len(chunks) { 452 | // no more chunks 453 | return 454 | } 455 | 456 | chunk := &chunks[chunkIndex] 457 | err := c.downloadChunk(req.Clone(cancelCtx), chunk) 458 | close(chunk.data) 459 | 460 | if err != nil { 461 | abort(err) 462 | return 463 | } 464 | } 465 | }() 466 | } 467 | 468 | go func() { 469 | // copy chunks into the PipeWriter 470 | for i := 0; i < len(chunks); i++ { 471 | select { 472 | case <-cancelCtx.Done(): 473 | abort(context.Canceled) 474 | return 475 | case data := <-chunks[i].data: 476 | _, err := io.Copy(w, bytes.NewBuffer(data)) 477 | if err != nil { 478 | abort(err) 479 | } 480 | } 481 | } 482 | 483 | // everything succeeded 484 | w.Close() 485 | }() 486 | } 487 | 488 | // GetStreamURL returns the url for a specific format 489 | func (c *Client) GetStreamURL(video *Video, format *Format) (string, error) { 490 | return c.GetStreamURLContext(context.Background(), video, format) 491 | } 492 | 493 | // GetStreamURLContext returns the url for a specific format with a context 494 | func (c *Client) GetStreamURLContext(ctx context.Context, video *Video, format *Format) (string, error) { 495 | if format == nil { 496 | return "", ErrNoFormat 497 | } 498 | 499 | c.assureClient() 500 | 501 | if format.URL != "" { 502 | if c.client.androidVersion > 0 { 503 | return format.URL, nil 504 | } 505 | 506 | return c.unThrottle(ctx, video.ID, format.URL) 507 | } 508 | 509 | // TODO: check rest of this function, is it redundant? 510 | 511 | cipher := format.Cipher 512 | if cipher == "" { 513 | return "", ErrCipherNotFound 514 | } 515 | 516 | uri, err := c.decipherURL(ctx, video.ID, cipher) 517 | if err != nil { 518 | return "", err 519 | } 520 | 521 | return uri, err 522 | } 523 | 524 | // httpDo sends an HTTP request and returns an HTTP response. 525 | func (c *Client) httpDo(req *http.Request) (*http.Response, error) { 526 | client := c.HTTPClient 527 | if client == nil { 528 | client = http.DefaultClient 529 | } 530 | 531 | req.Header.Set("User-Agent", c.client.userAgent) 532 | req.Header.Set("Origin", "https://youtube.com") 533 | req.Header.Set("Sec-Fetch-Mode", "navigate") 534 | 535 | if len(c.consentID) == 0 { 536 | c.consentID = strconv.Itoa(rand.Intn(899) + 100) //nolint:gosec 537 | } 538 | 539 | req.AddCookie(&http.Cookie{ 540 | Name: "CONSENT", 541 | Value: "YES+cb.20210328-17-p0.en+FX+" + c.consentID, 542 | Path: "/", 543 | Domain: ".youtube.com", 544 | }) 545 | 546 | res, err := client.Do(req) 547 | 548 | log := slog.With("method", req.Method, "url", req.URL) 549 | 550 | if err == nil && res.StatusCode != http.StatusOK { 551 | err = ErrUnexpectedStatusCode(res.StatusCode) 552 | res.Body.Close() 553 | res = nil 554 | } 555 | 556 | if err != nil { 557 | log.Debug("HTTP request failed", "error", err) 558 | } else { 559 | log.Debug("HTTP request succeeded", "status", res.Status) 560 | } 561 | 562 | return res, err 563 | } 564 | 565 | // httpGet does a HTTP GET request, checks the response to be a 200 OK and returns it 566 | func (c *Client) httpGet(ctx context.Context, url string) (*http.Response, error) { 567 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) 568 | if err != nil { 569 | return nil, err 570 | } 571 | 572 | resp, err := c.httpDo(req) 573 | if err != nil { 574 | return nil, err 575 | } 576 | 577 | if resp.StatusCode != http.StatusOK { 578 | resp.Body.Close() 579 | return nil, ErrUnexpectedStatusCode(resp.StatusCode) 580 | } 581 | 582 | return resp, nil 583 | } 584 | 585 | // httpGetBodyBytes reads the whole HTTP body and returns it 586 | func (c *Client) httpGetBodyBytes(ctx context.Context, url string) ([]byte, error) { 587 | resp, err := c.httpGet(ctx, url) 588 | if err != nil { 589 | return nil, err 590 | } 591 | defer resp.Body.Close() 592 | 593 | return io.ReadAll(resp.Body) 594 | } 595 | 596 | // httpPost does a HTTP POST request with a body, checks the response to be a 200 OK and returns it 597 | func (c *Client) httpPost(ctx context.Context, url string, body interface{}) (*http.Response, error) { 598 | data, err := json.Marshal(body) 599 | if err != nil { 600 | return nil, err 601 | } 602 | 603 | req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(data)) 604 | if err != nil { 605 | return nil, err 606 | } 607 | 608 | req.Header.Set("X-Youtube-Client-Name", "3") 609 | req.Header.Set("X-Youtube-Client-Version", c.client.version) 610 | req.Header.Set("Content-Type", "application/json") 611 | req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") 612 | 613 | if xgoogvisitorid, err := c.getVisitorId(); err != nil { 614 | return nil, err 615 | } else { 616 | req.Header.Set("x-goog-visitor-id", xgoogvisitorid) 617 | } 618 | 619 | resp, err := c.httpDo(req) 620 | if err != nil { 621 | return nil, err 622 | } 623 | 624 | if resp.StatusCode != http.StatusOK { 625 | resp.Body.Close() 626 | return nil, ErrUnexpectedStatusCode(resp.StatusCode) 627 | } 628 | 629 | return resp, nil 630 | } 631 | 632 | var VisitorIdMaxAge = 10 * time.Hour 633 | 634 | func (c *Client) getVisitorId() (string, error) { 635 | var err error 636 | if c.visitorId.value == "" || time.Since(c.visitorId.updated) > VisitorIdMaxAge { 637 | err = c.refreshVisitorId() 638 | } 639 | 640 | return c.visitorId.value, err 641 | } 642 | 643 | func (c *Client) refreshVisitorId() error { 644 | const sep = "\nytcfg.set(" 645 | 646 | req, err := http.NewRequest(http.MethodGet, "https://www.youtube.com", nil) 647 | if err != nil { 648 | return err 649 | } 650 | 651 | resp, err := c.httpDo(req) 652 | if err != nil { 653 | return err 654 | } 655 | defer resp.Body.Close() 656 | data, err := io.ReadAll(resp.Body) 657 | if err != nil { 658 | return err 659 | } 660 | _, data1, found := strings.Cut(string(data), sep) 661 | if !found { 662 | return err 663 | } 664 | var value struct { 665 | InnertubeContext struct { 666 | Client struct { 667 | VisitorData string 668 | } 669 | } `json:"INNERTUBE_CONTEXT"` 670 | } 671 | if err := json.NewDecoder(strings.NewReader(data1)).Decode(&value); err != nil { 672 | return err 673 | } 674 | 675 | if c.visitorId.value, err = url.PathUnescape(value.InnertubeContext.Client.VisitorData); err != nil { 676 | return err 677 | } 678 | 679 | c.visitorId.updated = time.Now() 680 | return nil 681 | } 682 | 683 | // httpPostBodyBytes reads the whole HTTP body and returns it 684 | func (c *Client) httpPostBodyBytes(ctx context.Context, url string, body interface{}) ([]byte, error) { 685 | resp, err := c.httpPost(ctx, url, body) 686 | if err != nil { 687 | return nil, err 688 | } 689 | defer resp.Body.Close() 690 | 691 | return io.ReadAll(resp.Body) 692 | } 693 | 694 | // downloadChunk writes the response data into the data channel of the chunk. 695 | // Downloading in multiple chunks is much faster: 696 | // https://github.com/kkdai/youtube/pull/190 697 | func (c *Client) downloadChunk(req *http.Request, chunk *chunk) error { 698 | q := req.URL.Query() 699 | q.Set("range", fmt.Sprintf("%d-%d", chunk.start, chunk.end)) 700 | req.URL.RawQuery = q.Encode() 701 | 702 | resp, err := c.httpDo(req) 703 | if err != nil { 704 | return err 705 | } 706 | defer resp.Body.Close() 707 | 708 | if resp.StatusCode != http.StatusOK { 709 | return ErrUnexpectedStatusCode(resp.StatusCode) 710 | } 711 | 712 | expected := int(chunk.end-chunk.start) + 1 713 | data, err := io.ReadAll(resp.Body) 714 | n := len(data) 715 | 716 | if err != nil { 717 | return err 718 | } 719 | 720 | if n != expected { 721 | return fmt.Errorf("chunk at offset %d has invalid size: expected=%d actual=%d", chunk.start, expected, n) 722 | } 723 | 724 | chunk.data <- data 725 | 726 | return nil 727 | } 728 | --------------------------------------------------------------------------------